Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c78212263b | ||
| 27600872a8 | |||
|
|
cc12011ce2 |
@@ -1,6 +1,9 @@
|
|||||||
POSTGRES_DB=fable
|
POSTGRES_DB=fable
|
||||||
POSTGRES_USER=fable
|
POSTGRES_USER=fable
|
||||||
POSTGRES_PASSWORD=change_me_for_real_environment
|
POSTGRES_PASSWORD=change_me_for_real_environment
|
||||||
JWT_SECRET=change_me_before_production
|
DATABASE_URL=postgres://fable:change_me_for_real_environment@127.0.0.1:5432/fable?sslmode=disable
|
||||||
|
TOKEN_SECRET=change_me_before_production
|
||||||
|
TOKEN_TTL=24h
|
||||||
|
VITE_API_URL=http://127.0.0.1:3000/api
|
||||||
GATEWAY_PORT=3000
|
GATEWAY_PORT=3000
|
||||||
WEB_PORT=5173
|
WEB_PORT=5173
|
||||||
|
|||||||
18
README.md
18
README.md
@@ -16,14 +16,14 @@
|
|||||||
|
|
||||||
Все данные в интерфейсе и API являются явно демонстрационными заглушками. Реальные люди, подразделения, интеграции, юридические сведения и брендовые материалы не добавлялись.
|
Все данные в интерфейсе и API являются явно демонстрационными заглушками. Реальные люди, подразделения, интеграции, юридические сведения и брендовые материалы не добавлялись.
|
||||||
|
|
||||||
Backend сейчас использует in-memory demo store внутри Go-сервисов. PostgreSQL schema и Docker PostgreSQL подготовлены, но постоянное хранение через БД остается следующим этапом.
|
Backend теперь использует PostgreSQL через GORM в Go-сервисах. Демо-данные остаются только как seed-набор для локального запуска и проверки сценариев.
|
||||||
|
|
||||||
## Локальный запуск
|
## Локальный запуск
|
||||||
|
|
||||||
Установить зависимости:
|
Установить зависимости:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
pnpm install
|
||||||
```
|
```
|
||||||
|
|
||||||
Запустить весь проект без Docker:
|
Запустить весь проект без Docker:
|
||||||
@@ -40,13 +40,15 @@ npm run dev
|
|||||||
npm run dev:backend
|
npm run dev:backend
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Для этого локально должен быть доступен PostgreSQL на `127.0.0.1:5432` или должен быть задан `DATABASE_URL`.
|
||||||
|
|
||||||
Запустить только gateway с fallback demo API:
|
Запустить только gateway с fallback demo API:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev:gateway
|
npm run dev:gateway
|
||||||
```
|
```
|
||||||
|
|
||||||
В обычном локальном режиме gateway работает с fallback demo API, если Go-сервисы не запущены и `*_SERVICE_URL` не заданы.
|
`npm run dev:gateway` оставляет только gateway. Для рабочего backend-контура используйте `npm run dev:backend`, потому что Go-сервисы теперь требуют реальную БД и не должны подменяться скрытым demo-store.
|
||||||
|
|
||||||
Запустить frontend:
|
Запустить frontend:
|
||||||
|
|
||||||
@@ -60,7 +62,13 @@ npm run dev:web
|
|||||||
npm run test:go
|
npm run test:go
|
||||||
```
|
```
|
||||||
|
|
||||||
Docker сейчас необязателен. Для текущей разработки используйте `npm run dev:backend` и `npm run dev:web`.
|
Docker сейчас необязателен, но PostgreSQL обязателен для Go-сервисов. Для текущей разработки используйте `npm run dev:backend` и `npm run dev:web`.
|
||||||
|
|
||||||
|
Проверить сборку всего проекта:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run check
|
||||||
|
```
|
||||||
|
|
||||||
Запуск всего окружения через Docker, если он понадобится позже:
|
Запуск всего окружения через Docker, если он понадобится позже:
|
||||||
|
|
||||||
@@ -82,7 +90,7 @@ Internal services health through gateway: `http://localhost:3000/api/services/he
|
|||||||
|
|
||||||
Пароль: `demo_password`
|
Пароль: `demo_password`
|
||||||
|
|
||||||
Auth service принимает демо-учетную запись и возвращает demo token. Это не production-аутентификация; для промышленного контура нужен подписанный JWT или другой проверяемый token format.
|
Auth service принимает демо-учетную запись из seed-данных, проверяет хэш пароля и выдает подписанный token для локального backend-контура.
|
||||||
|
|
||||||
## Backend API
|
## Backend API
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Elysia } from 'elysia';
|
|||||||
import { WebStandardAdapter } from 'elysia/adapter/web-standard';
|
import { WebStandardAdapter } from 'elysia/adapter/web-standard';
|
||||||
|
|
||||||
type ContentType = 'news' | 'article' | 'video' | 'audio' | 'graphic' | 'event';
|
type ContentType = 'news' | 'article' | 'video' | 'audio' | 'graphic' | 'event';
|
||||||
type ContentStatus = 'Черновик' | 'На модерации' | 'На проверке' | 'Опубликовано' | 'Архив';
|
type ContentStatus = 'Черновик' | 'На модерации' | 'На проверке' | 'Опубликовано' | 'Возвращен' | 'Архив';
|
||||||
type Visibility = 'Публично' | 'После входа' | 'По роли';
|
type Visibility = 'Публично' | 'После входа' | 'По роли';
|
||||||
type RoleCode = 'администратор' | 'редактор' | 'менеджер' | 'пользователь';
|
type RoleCode = 'администратор' | 'редактор' | 'менеджер' | 'пользователь';
|
||||||
|
|
||||||
@@ -28,6 +28,11 @@ type ContentItem = {
|
|||||||
mimeType?: string;
|
mimeType?: string;
|
||||||
fileName?: string;
|
fileName?: string;
|
||||||
fileSize?: number;
|
fileSize?: number;
|
||||||
|
moderatorComment?: string;
|
||||||
|
reviewComment?: string;
|
||||||
|
ratingAverage?: number;
|
||||||
|
ratingCount?: number;
|
||||||
|
myRating?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type MediaKind = NonNullable<ContentItem['mediaKind']>;
|
type MediaKind = NonNullable<ContentItem['mediaKind']>;
|
||||||
@@ -40,6 +45,18 @@ type StoredUpload = {
|
|||||||
data: Buffer;
|
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 = {
|
type UserProfile = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -76,6 +93,28 @@ const serviceUrls: Record<string, string | undefined> = {
|
|||||||
const categories = ['Новости', 'Статьи', 'Видео', 'Аудио', 'Графика', 'Мероприятия'];
|
const categories = ['Новости', 'Статьи', 'Видео', 'Аудио', 'Графика', 'Мероприятия'];
|
||||||
const tags = ['медиапроизводство', 'интервью', 'анонс', 'образование', 'редакция', 'архив'];
|
const tags = ['медиапроизводство', 'интервью', 'анонс', 'образование', 'редакция', 'архив'];
|
||||||
const contentTypes: ContentType[] = ['news', 'article', 'video', 'audio', 'graphic', 'event'];
|
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 = {
|
const demoUser: UserProfile = {
|
||||||
id: 'demo-user-1',
|
id: 'demo-user-1',
|
||||||
@@ -88,6 +127,7 @@ const demoUser: UserProfile = {
|
|||||||
const users: UserProfile[] = [
|
const users: UserProfile[] = [
|
||||||
demoUser,
|
demoUser,
|
||||||
{ id: 'demo-user-2', name: 'Демо-редактор', login: 'demo_editor', roles: ['редактор'], subscriptions: ['Видео'] },
|
{ 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: ['Аудио'] }
|
{ id: 'demo-user-3', name: 'Демо-пользователь', login: 'demo_user', roles: ['пользователь'], subscriptions: ['Аудио'] }
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -298,6 +338,97 @@ function searchContent(query: Record<string, string | undefined>) {
|
|||||||
return sorted;
|
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 } {
|
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';
|
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/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))) }))
|
.get('/api/services/health', async () => ({ items: await Promise.all(Object.entries(serviceUrls).map(([name, url]) => readServiceHealth(name, url))) }))
|
||||||
.post('/api/auth/register', ({ body, set }) => {
|
.post('/api/auth/register', ({ body, set }) => {
|
||||||
const payload = body as Partial<{ login: string; password: string; name: string }>;
|
const payload = body as Partial<{ login: string; email: string; password: string; name: string }>;
|
||||||
if (!payload.login || !payload.password || payload.password.length < 8) {
|
const login = normalizeLogin(payload.login ?? payload.email ?? '');
|
||||||
|
if (!login || !payload.password || payload.password.length < 8) {
|
||||||
return fail(set, 400, 'VALIDATION_ERROR', 'Укажите логин и пароль не короче 8 символов');
|
return fail(set, 400, 'VALIDATION_ERROR', 'Укажите логин и пароль не короче 8 символов');
|
||||||
}
|
}
|
||||||
const user: UserProfile = {
|
const user: UserProfile = {
|
||||||
id: `demo-user-${Date.now()}`,
|
id: `demo-user-${Date.now()}`,
|
||||||
name: payload.name?.trim() || 'Демо-пользователь',
|
name: payload.name?.trim() || 'Демо-пользователь',
|
||||||
login: payload.login,
|
login,
|
||||||
roles: ['пользователь'],
|
roles: ['пользователь'],
|
||||||
subscriptions: []
|
subscriptions: []
|
||||||
};
|
};
|
||||||
@@ -551,11 +683,12 @@ const app = new Elysia({ adapter: WebStandardAdapter })
|
|||||||
return { token, user };
|
return { token, user };
|
||||||
})
|
})
|
||||||
.post('/api/auth/login', ({ body, set }) => {
|
.post('/api/auth/login', ({ body, set }) => {
|
||||||
const payload = body as Partial<{ login: string; password: string }>;
|
const payload = body as Partial<{ login: string; email: string; password: string }>;
|
||||||
if (!payload.login || !payload.password) {
|
const login = normalizeLogin(payload.login ?? payload.email ?? '');
|
||||||
|
if (!login || !payload.password) {
|
||||||
return fail(set, 400, 'VALIDATION_ERROR', 'Укажите логин и пароль');
|
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()}`;
|
const token = `demo-token-${crypto.randomUUID()}`;
|
||||||
tokens.set(token, user);
|
tokens.set(token, user);
|
||||||
return { token, user };
|
return { token, user };
|
||||||
@@ -585,7 +718,7 @@ const app = new Elysia({ adapter: WebStandardAdapter })
|
|||||||
}
|
}
|
||||||
return { ok: true };
|
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 }) => {
|
.get('/api/content/:id', ({ params, set }) => {
|
||||||
const item = content.find((entry) => entry.id === params.id);
|
const item = content.find((entry) => entry.id === params.id);
|
||||||
if (!item) {
|
if (!item) {
|
||||||
@@ -602,7 +735,7 @@ const app = new Elysia({ adapter: WebStandardAdapter })
|
|||||||
if (!auth.user.roles.some((role) => role === 'администратор' || role === 'редактор' || role === 'менеджер')) {
|
if (!auth.user.roles.some((role) => role === 'администратор' || role === 'редактор' || role === 'менеджер')) {
|
||||||
return fail(set, 403, 'FORBIDDEN', 'Создание материалов требует роли редактора, менеджера или администратора');
|
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) {
|
if (!payload.title || !payload.category || !payload.type) {
|
||||||
return fail(set, 400, 'VALIDATION_ERROR', 'Укажите название, категорию и тип материала');
|
return fail(set, 400, 'VALIDATION_ERROR', 'Укажите название, категорию и тип материала');
|
||||||
}
|
}
|
||||||
@@ -617,14 +750,19 @@ const app = new Elysia({ adapter: WebStandardAdapter })
|
|||||||
author: auth.user.name,
|
author: auth.user.name,
|
||||||
publishedAt: new Date().toISOString().slice(0, 10),
|
publishedAt: new Date().toISOString().slice(0, 10),
|
||||||
visibility: payload.visibility ?? 'После входа',
|
visibility: payload.visibility ?? 'После входа',
|
||||||
status: 'Черновик',
|
status: payload.status ?? 'Черновик',
|
||||||
views: 0,
|
views: 0,
|
||||||
imageTone: payload.imageTone ?? 'from-university-800 via-slate-700 to-sky-300',
|
imageTone: payload.imageTone ?? 'from-university-800 via-slate-700 to-sky-300',
|
||||||
mediaUrl: payload.mediaUrl,
|
mediaUrl: payload.mediaUrl,
|
||||||
mediaKind: payload.mediaKind,
|
mediaKind: payload.mediaKind,
|
||||||
mimeType: payload.mimeType,
|
mimeType: payload.mimeType,
|
||||||
fileName: payload.fileName,
|
fileName: payload.fileName,
|
||||||
fileSize: payload.fileSize
|
fileSize: payload.fileSize,
|
||||||
|
moderatorComment: payload.moderatorComment,
|
||||||
|
reviewComment: payload.reviewComment,
|
||||||
|
ratingAverage: 0,
|
||||||
|
ratingCount: 0,
|
||||||
|
myRating: 0
|
||||||
};
|
};
|
||||||
content = [item, ...content];
|
content = [item, ...content];
|
||||||
set.status = 201;
|
set.status = 201;
|
||||||
@@ -639,7 +777,24 @@ const app = new Elysia({ adapter: WebStandardAdapter })
|
|||||||
if (index === -1) {
|
if (index === -1) {
|
||||||
return fail(set, 404, 'NOT_FOUND', 'Материал не найден');
|
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] };
|
return { item: content[index] };
|
||||||
})
|
})
|
||||||
.delete('/api/content/:id', ({ request, params, set }) => {
|
.delete('/api/content/:id', ({ request, params, set }) => {
|
||||||
@@ -770,11 +925,13 @@ const app = new Elysia({ adapter: WebStandardAdapter })
|
|||||||
return { item };
|
return { item };
|
||||||
})
|
})
|
||||||
.get('/api/analytics/summary', ({ request, set }) => {
|
.get('/api/analytics/summary', ({ request, set }) => {
|
||||||
const auth = requireRole(request, set, 'администратор');
|
const auth = requireUser(request, set);
|
||||||
if (!auth.user) {
|
if (!auth.user) {
|
||||||
return auth.response;
|
return auth.response;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
materials: content.filter((item) => item.author === auth.user?.name).length,
|
||||||
|
comments: comments.length,
|
||||||
totalViews: content.reduce((sum, item) => sum + item.views, 0),
|
totalViews: content.reduce((sum, item) => sum + item.views, 0),
|
||||||
subscribers: speakers.reduce((sum, item) => sum + item.subscribers, 0),
|
subscribers: speakers.reduce((sum, item) => sum + item.subscribers, 0),
|
||||||
activeUsers: users.length,
|
activeUsers: users.length,
|
||||||
@@ -788,8 +945,18 @@ const app = new Elysia({ adapter: WebStandardAdapter })
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
users: users.length,
|
users: users.length,
|
||||||
content: content.length,
|
materials: content.length,
|
||||||
moderationQueue: content.filter((item) => item.status !== 'Опубликовано').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: ['администратор', 'редактор', 'менеджер', 'пользователь']
|
roles: ['администратор', 'редактор', 'менеджер', 'пользователь']
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
|
|||||||
1
apps/web/.env.example
Normal file
1
apps/web/.env.example
Normal file
@@ -0,0 +1 @@
|
|||||||
|
VITE_API_URL=http://localhost:8000/api
|
||||||
9
apps/web/.gitignore
vendored
Normal file
9
apps/web/.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
coverage
|
||||||
|
.env
|
||||||
|
.DS_Store
|
||||||
|
*.local
|
||||||
|
*.log
|
||||||
|
*.tsbuildinfo
|
||||||
|
.backend_known_hosts
|
||||||
3
apps/web/.npmrc
Normal file
3
apps/web/.npmrc
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
save-exact=true
|
||||||
|
strict-peer-dependencies=true
|
||||||
|
auto-install-peers=false
|
||||||
6
apps/web/.prettierrc
Normal file
6
apps/web/.prettierrc
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": false,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"printWidth": 88
|
||||||
|
}
|
||||||
156
apps/web/DESIGN.md
Normal file
156
apps/web/DESIGN.md
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
---
|
||||||
|
version: alpha
|
||||||
|
name: DSTU Editorial Signal
|
||||||
|
description: A calm editorial media system for a large technical university.
|
||||||
|
colors:
|
||||||
|
primary: "#123C36"
|
||||||
|
primary-strong: "#082B27"
|
||||||
|
accent: "#D65A3A"
|
||||||
|
accent-soft: "#F6DDD4"
|
||||||
|
paper: "#F5F2EA"
|
||||||
|
surface: "#FFFDF8"
|
||||||
|
ink: "#17201E"
|
||||||
|
muted: "#66706D"
|
||||||
|
line: "#D9D8D0"
|
||||||
|
success: "#247A5A"
|
||||||
|
warning: "#A96616"
|
||||||
|
danger: "#B43D3D"
|
||||||
|
on-primary: "#FFFFFF"
|
||||||
|
typography:
|
||||||
|
display:
|
||||||
|
fontFamily: "Georgia, Cambria, serif"
|
||||||
|
fontSize: 4rem
|
||||||
|
fontWeight: 500
|
||||||
|
lineHeight: 1.02
|
||||||
|
letterSpacing: "-0.035em"
|
||||||
|
h1:
|
||||||
|
fontFamily: "Georgia, Cambria, serif"
|
||||||
|
fontSize: 3rem
|
||||||
|
fontWeight: 500
|
||||||
|
lineHeight: 1.08
|
||||||
|
h2:
|
||||||
|
fontFamily: "Georgia, Cambria, serif"
|
||||||
|
fontSize: 2rem
|
||||||
|
fontWeight: 500
|
||||||
|
lineHeight: 1.15
|
||||||
|
body:
|
||||||
|
fontFamily: "Inter, Arial, sans-serif"
|
||||||
|
fontSize: 1rem
|
||||||
|
fontWeight: 400
|
||||||
|
lineHeight: 1.65
|
||||||
|
label:
|
||||||
|
fontFamily: "Inter, Arial, sans-serif"
|
||||||
|
fontSize: 0.75rem
|
||||||
|
fontWeight: 700
|
||||||
|
lineHeight: 1.2
|
||||||
|
letterSpacing: "0.08em"
|
||||||
|
rounded:
|
||||||
|
sm: 0.375rem
|
||||||
|
md: 0.75rem
|
||||||
|
lg: 1.25rem
|
||||||
|
spacing:
|
||||||
|
xs: 0.25rem
|
||||||
|
sm: 0.5rem
|
||||||
|
md: 1rem
|
||||||
|
lg: 1.5rem
|
||||||
|
xl: 2.5rem
|
||||||
|
2xl: 4rem
|
||||||
|
components:
|
||||||
|
button-primary:
|
||||||
|
backgroundColor: "{colors.primary}"
|
||||||
|
textColor: "{colors.on-primary}"
|
||||||
|
rounded: "{rounded.sm}"
|
||||||
|
padding: "0.75rem 1.125rem"
|
||||||
|
height: "2.75rem"
|
||||||
|
button-accent:
|
||||||
|
backgroundColor: "{colors.accent}"
|
||||||
|
textColor: "{colors.on-primary}"
|
||||||
|
rounded: "{rounded.sm}"
|
||||||
|
padding: "0.75rem 1.125rem"
|
||||||
|
height: "2.75rem"
|
||||||
|
card:
|
||||||
|
backgroundColor: "{colors.surface}"
|
||||||
|
textColor: "{colors.ink}"
|
||||||
|
rounded: "{rounded.md}"
|
||||||
|
padding: "1.25rem"
|
||||||
|
motion:
|
||||||
|
feedback: 140ms
|
||||||
|
content: 240ms
|
||||||
|
easing: "cubic-bezier(0.2, 0, 0, 1)"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The portal should feel like the digital edition of a respected technical university
|
||||||
|
newspaper combined with the calm wayfinding of a contemporary campus. It is made for
|
||||||
|
students checking what happens today, researchers reading long-form work, and staff
|
||||||
|
moving material through an editorial workflow.
|
||||||
|
|
||||||
|
The page is a publication first and a software dashboard second. Public pages should
|
||||||
|
feel authored and alive. Administrative pages inherit the same typography, color and
|
||||||
|
editorial hierarchy rather than becoming a generic enterprise product.
|
||||||
|
|
||||||
|
## Colors
|
||||||
|
|
||||||
|
The canvas is warm paper, never sterile white. Deep green is institutional and
|
||||||
|
architectural; it anchors navigation, important buttons and large information blocks.
|
||||||
|
Terracotta is scarce and energetic, reserved for the current moment: primary calls to
|
||||||
|
action, live status and selected states. Body copy uses softened ink rather than black.
|
||||||
|
|
||||||
|
Status colors communicate meaning and never replace a written label.
|
||||||
|
|
||||||
|
## Typography
|
||||||
|
|
||||||
|
Headlines use a restrained editorial serif. Interface copy, metadata and controls use a
|
||||||
|
neutral sans serif. Large titles are allowed to breathe, while dashboards use smaller,
|
||||||
|
denser headings. Long articles have a comfortable measure of roughly 68 characters.
|
||||||
|
|
||||||
|
Uppercase is reserved for short section labels and metadata, never paragraphs or
|
||||||
|
navigation menus.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
Public pages use a 12-column editorial grid with deliberate asymmetry: a leading story
|
||||||
|
may occupy seven columns while a compact news rail occupies five. The maximum content
|
||||||
|
width follows the available viewport with a readable editorial cap. On narrow screens,
|
||||||
|
content becomes a single readable column without decorative reordering.
|
||||||
|
|
||||||
|
Dashboard layouts use a fixed desktop rail and a drawer on mobile. Tables may scroll,
|
||||||
|
but the page itself must not.
|
||||||
|
|
||||||
|
Responsive behavior is fluid rather than tied to named device widths. Layouts use
|
||||||
|
`minmax()`, `clamp()`, fractional columns and content-based wrapping. Media tabs scroll
|
||||||
|
when space is limited and collections gain columns only when their content fits.
|
||||||
|
|
||||||
|
## Elevation & Depth
|
||||||
|
|
||||||
|
Depth is mostly created with borders, overlapping paper surfaces and spacing. Shadows
|
||||||
|
are soft and rare. Hover states may lift a card by two pixels but should never make the
|
||||||
|
interface feel springy.
|
||||||
|
|
||||||
|
## Shapes
|
||||||
|
|
||||||
|
Corners are modest. Buttons and inputs use 0.375rem corners; cards use 0.75rem; feature
|
||||||
|
media may use 1.25rem. Pills are reserved for compact filters, tags and status, not general
|
||||||
|
containers.
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
Cards expose a clear reading order: section, title, summary, then metadata. Images do
|
||||||
|
not carry text overlays except in the single leading story. Forms use persistent labels,
|
||||||
|
visible focus rings and errors placed next to fields.
|
||||||
|
|
||||||
|
Motion is quick and mechanical. Menus, dialogs and panels may combine opacity with a
|
||||||
|
small transform. Content transitions use cross-fades. Nothing bounces or takes longer
|
||||||
|
than 300ms. Reduced-motion users receive immediate state changes.
|
||||||
|
|
||||||
|
## Do's and Don'ts
|
||||||
|
|
||||||
|
- **Do** make the first screen feel like today's university edition.
|
||||||
|
- **Do** let real Russian headlines create the visual rhythm.
|
||||||
|
- **Do** preserve generous reading space around long-form content.
|
||||||
|
- **Do** use terracotta sparingly so it keeps meaning.
|
||||||
|
- **Don't** use gradients, glassmorphism, neon glows or giant rounded containers.
|
||||||
|
- **Don't** make every card animate independently.
|
||||||
|
- **Don't** turn the public portal into a grid of identical dashboard widgets.
|
||||||
|
- **Don't** hide essential labels behind icons.
|
||||||
BIN
apps/web/IMG_4963.png
Normal file
BIN
apps/web/IMG_4963.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
104
apps/web/README.md
Normal file
104
apps/web/README.md
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
# ДГТУ МЕДИА
|
||||||
|
|
||||||
|
Frontend MVP информационной системы управления медиаконтентом университета. Проект
|
||||||
|
выполнен на JavaScript/JSX, React, Vite и Tailwind CSS.
|
||||||
|
|
||||||
|
## Запуск
|
||||||
|
|
||||||
|
Требуются Node.js 22+ и npm.
|
||||||
|
|
||||||
|
Из корня монорепозитория:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Для запуска только frontend:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev:web
|
||||||
|
```
|
||||||
|
|
||||||
|
Откройте `http://localhost:5173`. В dev-режиме Vite проксирует `/api` на `http://localhost:3000`.
|
||||||
|
|
||||||
|
## Проверки
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm --workspace @fable/web run lint
|
||||||
|
npm --workspace @fable/web run test
|
||||||
|
npm --workspace @fable/web run build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Структура
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/
|
||||||
|
app/ маршрутизация, layouts и состояние сессии
|
||||||
|
pages/ публичные страницы и ролевые кабинеты
|
||||||
|
shared/
|
||||||
|
api/ Axios-клиент и общая обработка ошибок
|
||||||
|
data/ реалистичные mock-данные
|
||||||
|
lib/ небольшие общие helpers
|
||||||
|
ui/ переиспользуемые компоненты
|
||||||
|
```
|
||||||
|
|
||||||
|
В `DESIGN.md` описаны дизайн-токены, визуальный характер, правила компонентов и
|
||||||
|
анимаций.
|
||||||
|
|
||||||
|
## Реализованные страницы
|
||||||
|
|
||||||
|
- главная редакционная страница;
|
||||||
|
- каталог с поиском и фильтрами через URL;
|
||||||
|
- страница материала;
|
||||||
|
- афиша событий;
|
||||||
|
- медиаканалы: радио, журналы и социальные сети;
|
||||||
|
- страница о медиапортале;
|
||||||
|
- вход и тестовые роли;
|
||||||
|
- профиль пользователя;
|
||||||
|
- кабинет редактора и форма материала;
|
||||||
|
- очередь модератора;
|
||||||
|
- статистика и пользователи администратора;
|
||||||
|
- страницы 403 и 404 с SVG-анимацией.
|
||||||
|
|
||||||
|
## Тестовые роли
|
||||||
|
|
||||||
|
Основной demo-вход: `demo_admin` / `demo_password`.
|
||||||
|
|
||||||
|
| Роль | Учётная запись |
|
||||||
|
| --- | --- |
|
||||||
|
| Пользователь | `user@dstu.ru` |
|
||||||
|
| Редактор | `editor@dstu.ru` |
|
||||||
|
| Модератор | `moderator@dstu.ru` |
|
||||||
|
| Администратор | `admin@dstu.ru` |
|
||||||
|
|
||||||
|
Роль также можно переключить в шапке кабинета для демонстрации интерфейсов.
|
||||||
|
|
||||||
|
## Ожидаемые backend endpoints
|
||||||
|
|
||||||
|
- `POST /auth/login`, `POST /auth/refresh`, `POST /auth/logout`;
|
||||||
|
- `GET/PATCH /users/me`;
|
||||||
|
- CRUD `/materials`, `/categories`, `/tags`, `/events`;
|
||||||
|
- `/materials/:id/comments`, `/subscriptions`, `/notifications`;
|
||||||
|
- `/moderation/queue`, `/moderation/:id/approve`, `/moderation/:id/return`;
|
||||||
|
- `/admin/users`, `/admin/stats`, `/admin/audit-log`;
|
||||||
|
- `POST /uploads`.
|
||||||
|
|
||||||
|
Адрес API можно переопределить через `VITE_API_URL`. По умолчанию интерфейс работает с `/api` и ожидает gateway на `localhost:3000`.
|
||||||
|
|
||||||
|
## Безопасность зависимостей
|
||||||
|
|
||||||
|
Проект закреплён на pnpm 11. В `pnpm-workspace.yaml` включены:
|
||||||
|
|
||||||
|
- `minimumReleaseAge: 1440`;
|
||||||
|
- `blockExoticSubdeps: true`;
|
||||||
|
- `trustPolicy: no-downgrade`;
|
||||||
|
- явный `allowBuilds` только для `esbuild` и `@tailwindcss/oxide`.
|
||||||
|
|
||||||
|
Lockfile необходимо хранить в репозитории.
|
||||||
|
|
||||||
|
## Ограничения MVP
|
||||||
|
|
||||||
|
- данные backend по-прежнему демонстрационные и в основном живут в in-memory store;
|
||||||
|
- внешние изображения загружаются с Unsplash;
|
||||||
|
- график администратора демонстрационный.
|
||||||
31
apps/web/eslint.config.js
Normal file
31
apps/web/eslint.config.js
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import js from "@eslint/js";
|
||||||
|
import globals from "globals";
|
||||||
|
import reactHooks from "eslint-plugin-react-hooks";
|
||||||
|
import reactRefresh from "eslint-plugin-react-refresh";
|
||||||
|
|
||||||
|
export default [
|
||||||
|
{ ignores: ["dist", "coverage"] },
|
||||||
|
{
|
||||||
|
...js.configs.recommended,
|
||||||
|
files: ["**/*.{js,jsx}"],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2022,
|
||||||
|
globals: { ...globals.browser, ...globals.node, ...globals.vitest },
|
||||||
|
parserOptions: {
|
||||||
|
ecmaFeatures: { jsx: true },
|
||||||
|
sourceType: "module",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
"react-hooks": reactHooks,
|
||||||
|
"react-refresh": reactRefresh,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...reactHooks.configs.recommended.rules,
|
||||||
|
"react-refresh/only-export-components": [
|
||||||
|
"warn",
|
||||||
|
{ allowConstantExport: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -5,12 +5,22 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta
|
<meta
|
||||||
name="description"
|
name="description"
|
||||||
content="Демо-прототип университетской платформы управления медиаконтентом Fable"
|
content="Медиапортал ДГТУ: новости, исследования, события и университетская жизнь."
|
||||||
/>
|
/>
|
||||||
<title>Fable | Медиаплатформа университета</title>
|
<meta name="theme-color" content="#f5f2ea" />
|
||||||
|
<title>ДГТУ МЕДИА</title>
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
const savedTheme = localStorage.getItem("theme");
|
||||||
|
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||||
|
if (savedTheme === "dark" || (!savedTheme && prefersDark)) {
|
||||||
|
document.documentElement.classList.add("dark");
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,26 +1,49 @@
|
|||||||
{
|
{
|
||||||
"name": "@fable/web",
|
"name": "@fable/web",
|
||||||
"private": true,
|
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host 0.0.0.0",
|
"dev": "vite --host 0.0.0.0",
|
||||||
"build": "tsc --noEmit && vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview --host 0.0.0.0"
|
"preview": "vite preview --host 0.0.0.0",
|
||||||
|
"check": "eslint .",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react": "^19.1.0",
|
"@hookform/resolvers": "5.2.2",
|
||||||
"react-dom": "^19.1.0"
|
"@tanstack/react-query": "5.90.2",
|
||||||
|
"axios": "1.12.2",
|
||||||
|
"clsx": "2.1.1",
|
||||||
|
"framer-motion": "12.23.22",
|
||||||
|
"lucide-react": "0.544.0",
|
||||||
|
"react": "19.2.7",
|
||||||
|
"react-dom": "19.2.7",
|
||||||
|
"react-hook-form": "7.63.0",
|
||||||
|
"react-router-dom": "7.9.1",
|
||||||
|
"tailwind-merge": "3.3.1",
|
||||||
|
"zod": "4.1.11",
|
||||||
|
"zustand": "5.0.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^24.0.0",
|
"@eslint/js": "9.36.0",
|
||||||
"@types/react": "^19.1.0",
|
"@tailwindcss/vite": "4.1.13",
|
||||||
"@types/react-dom": "^19.1.0",
|
"@testing-library/jest-dom": "6.8.0",
|
||||||
"@vitejs/plugin-react": "^5.0.0",
|
"@testing-library/react": "16.3.0",
|
||||||
"autoprefixer": "^10.4.20",
|
"@testing-library/user-event": "14.6.1",
|
||||||
"postcss": "^8.4.49",
|
"@vitejs/plugin-react": "5.0.4",
|
||||||
"tailwindcss": "^3.4.17",
|
"autoprefixer": "10.4.21",
|
||||||
"typescript": "^5.9.0",
|
"eslint": "9.36.0",
|
||||||
"vite": "^8.0.16"
|
"eslint-plugin-react-hooks": "5.2.0",
|
||||||
|
"eslint-plugin-react-refresh": "0.4.22",
|
||||||
|
"globals": "16.4.0",
|
||||||
|
"jsdom": "27.0.0",
|
||||||
|
"postcss": "8.5.6",
|
||||||
|
"prettier": "3.6.2",
|
||||||
|
"tailwindcss": "4.1.13",
|
||||||
|
"vite": "7.1.7",
|
||||||
|
"vitest": "3.2.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
plugins: {
|
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
1891
apps/web/src/App.tsx
1891
apps/web/src/App.tsx
File diff suppressed because it is too large
Load Diff
162
apps/web/src/app/App.jsx
Normal file
162
apps/web/src/app/App.jsx
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
import { lazy, Suspense } from "react";
|
||||||
|
import { Navigate, Route, Routes } from "react-router-dom";
|
||||||
|
import { PublicLayout } from "./layouts/PublicLayout";
|
||||||
|
import { useSession } from "./store/session";
|
||||||
|
import { HomePage } from "../pages/HomePage";
|
||||||
|
import { NotFoundPage } from "../pages/NotFoundPage";
|
||||||
|
import { Skeleton } from "../shared/ui/States";
|
||||||
|
|
||||||
|
const MaterialsPage = lazy(() =>
|
||||||
|
import("../pages/MaterialsPage").then((module) => ({
|
||||||
|
default: module.MaterialsPage,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const MaterialPage = lazy(() =>
|
||||||
|
import("../pages/MaterialPage").then((module) => ({
|
||||||
|
default: module.MaterialPage,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const EventsPage = lazy(() =>
|
||||||
|
import("../pages/EventsPage").then((module) => ({
|
||||||
|
default: module.EventsPage,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const AboutPage = lazy(() =>
|
||||||
|
import("../pages/AboutPage").then((module) => ({
|
||||||
|
default: module.AboutPage,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const MediaChannelsPage = lazy(() =>
|
||||||
|
import("../pages/MediaChannelsPage").then((module) => ({
|
||||||
|
default: module.MediaChannelsPage,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const LoginPage = lazy(() =>
|
||||||
|
import("../pages/LoginPage").then((module) => ({
|
||||||
|
default: module.LoginPage,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const ForbiddenPage = lazy(() =>
|
||||||
|
import("../pages/ForbiddenPage").then((module) => ({
|
||||||
|
default: module.ForbiddenPage,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
const CabinetLayout = lazy(() =>
|
||||||
|
import("./layouts/CabinetLayout").then((module) => ({
|
||||||
|
default: module.CabinetLayout,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const CabinetHomePage = lazy(() =>
|
||||||
|
import("../pages/cabinet/CabinetHomePage").then((module) => ({
|
||||||
|
default: module.CabinetHomePage,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const ProfilePage = lazy(() =>
|
||||||
|
import("../pages/cabinet/ProfilePage").then((module) => ({
|
||||||
|
default: module.ProfilePage,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const EditorPage = lazy(() =>
|
||||||
|
import("../pages/cabinet/EditorPage").then((module) => ({
|
||||||
|
default: module.EditorPage,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const MaterialFormPage = lazy(() =>
|
||||||
|
import("../pages/cabinet/MaterialFormPage").then((module) => ({
|
||||||
|
default: module.MaterialFormPage,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const ModeratorPage = lazy(() =>
|
||||||
|
import("../pages/cabinet/ModeratorPage").then((module) => ({
|
||||||
|
default: module.ModeratorPage,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const AdminPage = lazy(() =>
|
||||||
|
import("../pages/cabinet/AdminPage").then((module) => ({
|
||||||
|
default: module.AdminPage,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
function CabinetFallback() {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4 p-6">
|
||||||
|
<Skeleton className="h-8 w-64" />
|
||||||
|
<Skeleton className="h-32 w-full" />
|
||||||
|
<Skeleton className="h-56 w-full" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RequireAuth({ children, roles }) {
|
||||||
|
const user = useSession((state) => state.user);
|
||||||
|
if (!user) return <Navigate to="/login" replace />;
|
||||||
|
if (roles && !roles.includes(user.role)) return <Navigate to="/403" replace />;
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<CabinetFallback />}>
|
||||||
|
<Routes>
|
||||||
|
<Route element={<PublicLayout />}>
|
||||||
|
<Route index element={<HomePage />} />
|
||||||
|
<Route path="materials" element={<MaterialsPage />} />
|
||||||
|
<Route path="materials/:slug" element={<MaterialPage />} />
|
||||||
|
<Route path="events" element={<EventsPage />} />
|
||||||
|
<Route path="media" element={<MediaChannelsPage />} />
|
||||||
|
<Route path="about" element={<AboutPage />} />
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
<Route path="/403" element={<ForbiddenPage />} />
|
||||||
|
|
||||||
|
<Route
|
||||||
|
path="/cabinet"
|
||||||
|
element={
|
||||||
|
<RequireAuth>
|
||||||
|
<CabinetLayout />
|
||||||
|
</RequireAuth>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Route index element={<CabinetHomePage />} />
|
||||||
|
<Route path="profile" element={<ProfilePage />} />
|
||||||
|
<Route
|
||||||
|
path="editor"
|
||||||
|
element={
|
||||||
|
<RequireAuth roles={["editor", "admin"]}>
|
||||||
|
<EditorPage />
|
||||||
|
</RequireAuth>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="editor/new"
|
||||||
|
element={
|
||||||
|
<RequireAuth roles={["editor", "admin"]}>
|
||||||
|
<MaterialFormPage />
|
||||||
|
</RequireAuth>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="moderation"
|
||||||
|
element={
|
||||||
|
<RequireAuth roles={["moderator", "admin"]}>
|
||||||
|
<ModeratorPage />
|
||||||
|
</RequireAuth>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="admin"
|
||||||
|
element={
|
||||||
|
<RequireAuth roles={["admin"]}>
|
||||||
|
<AdminPage />
|
||||||
|
</RequireAuth>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
<Route path="*" element={<NotFoundPage />} />
|
||||||
|
</Routes>
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
apps/web/src/app/App.test.jsx
Normal file
21
apps/web/src/app/App.test.jsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { screen } from "@testing-library/react";
|
||||||
|
import { MemoryRouter } from "react-router-dom";
|
||||||
|
import { renderWithProviders } from "../test/renderWithProviders";
|
||||||
|
import { App } from "./App";
|
||||||
|
import { useSession } from "./store/session";
|
||||||
|
|
||||||
|
describe("защищенные маршруты", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useSession.setState({ user: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("перенаправляет гостя со страницы кабинета на вход", async () => {
|
||||||
|
renderWithProviders(
|
||||||
|
<MemoryRouter initialEntries={["/cabinet"]}>
|
||||||
|
<App />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await screen.findByRole("heading", { name: "Вход в систему" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
160
apps/web/src/app/layouts/CabinetLayout.jsx
Normal file
160
apps/web/src/app/layouts/CabinetLayout.jsx
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import {
|
||||||
|
BarChart3,
|
||||||
|
BookOpen,
|
||||||
|
ChevronRight,
|
||||||
|
FilePenLine,
|
||||||
|
LayoutDashboard,
|
||||||
|
LogOut,
|
||||||
|
Menu,
|
||||||
|
ShieldCheck,
|
||||||
|
UserRound,
|
||||||
|
Users,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { AnimatePresence } from "framer-motion";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Link, NavLink, Outlet, useLocation, useNavigate } from "react-router-dom";
|
||||||
|
import { useSession } from "../store/session";
|
||||||
|
import { Button } from "../../shared/ui/Button";
|
||||||
|
import { Badge } from "../../shared/ui/Badge";
|
||||||
|
import { DstuLogo } from "../../shared/ui/DstuLogo";
|
||||||
|
import { cn } from "../../shared/lib/cn";
|
||||||
|
import { ThemeToggle } from "../../shared/ui/ThemeToggle";
|
||||||
|
import { PageTransition } from "../../shared/ui/PageTransition";
|
||||||
|
|
||||||
|
const roleLabels = {
|
||||||
|
user: "Пользователь",
|
||||||
|
editor: "Редактор",
|
||||||
|
moderator: "Модератор",
|
||||||
|
admin: "Администратор",
|
||||||
|
};
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ to: "/cabinet", label: "Обзор", icon: LayoutDashboard, roles: ["user", "editor", "moderator", "admin"], end: true },
|
||||||
|
{ to: "/cabinet/profile", label: "Профиль", icon: UserRound, roles: ["user", "editor", "moderator", "admin"] },
|
||||||
|
{ to: "/cabinet/editor", label: "Мои материалы", icon: FilePenLine, roles: ["editor", "admin"] },
|
||||||
|
{ to: "/cabinet/moderation", label: "Модерация", icon: ShieldCheck, roles: ["moderator", "admin"] },
|
||||||
|
{ to: "/cabinet/admin", label: "Управление", icon: Users, roles: ["admin"] },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function CabinetLayout() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const user = useSession((state) => state.user);
|
||||||
|
const logout = useSession((state) => state.logout);
|
||||||
|
const switchRole = useSession((state) => state.switchRole);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
const visibleNav = navItems.filter((item) => item.roles.includes(user.role));
|
||||||
|
|
||||||
|
const navigation = (
|
||||||
|
<>
|
||||||
|
<div className="flex h-18 items-center justify-between border-b border-white/10 px-5">
|
||||||
|
<Link to="/" className="flex items-center gap-3 text-white" onClick={() => setOpen(false)}>
|
||||||
|
<DstuLogo className="size-9" />
|
||||||
|
<span className="font-serif text-lg">ДГТУ МЕДИА</span>
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
className="grid size-10 place-items-center rounded-md text-white/80 hover:bg-white/10 hover:text-white lg:hidden"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
aria-label="Закрыть меню"
|
||||||
|
>
|
||||||
|
<X className="size-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<nav className="flex-1 space-y-1 p-4">
|
||||||
|
{visibleNav.map(({ to, label, icon: Icon, end }) => (
|
||||||
|
<NavLink
|
||||||
|
key={to}
|
||||||
|
to={to}
|
||||||
|
end={end}
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
cn(
|
||||||
|
"flex items-center gap-3 rounded-md px-3 py-2.5 text-sm font-semibold transition",
|
||||||
|
isActive
|
||||||
|
? "bg-dstu-menu-accent text-dstu-menu"
|
||||||
|
: "text-white/70 hover:bg-white/10 hover:text-white",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon className="size-4.5" />
|
||||||
|
{label}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
<div className="border-t border-white/10 p-4">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
logout();
|
||||||
|
navigate("/");
|
||||||
|
}}
|
||||||
|
className="flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-sm font-semibold text-white/70 hover:bg-white/10 hover:text-white"
|
||||||
|
>
|
||||||
|
<LogOut className="size-4.5" /> Выйти
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-paper lg:grid lg:grid-cols-[clamp(14rem,20vw,18rem)_minmax(0,1fr)]">
|
||||||
|
<aside className="hidden min-h-screen flex-col bg-dstu-menu text-white lg:flex">{navigation}</aside>
|
||||||
|
{open && (
|
||||||
|
<>
|
||||||
|
<button className="fixed inset-0 z-40 bg-ink/35 lg:hidden" onClick={() => setOpen(false)} aria-label="Закрыть меню" />
|
||||||
|
<aside className="fixed inset-y-0 left-0 z-50 flex w-[min(86vw,22rem)] flex-col bg-dstu-menu text-white lg:hidden">
|
||||||
|
{navigation}
|
||||||
|
</aside>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="min-w-0">
|
||||||
|
<header className="sticky top-0 z-30 flex h-18 items-center gap-4 border-b border-line bg-paper/95 px-4 backdrop-blur sm:px-6">
|
||||||
|
<button className="grid size-10 place-items-center rounded-md lg:hidden" onClick={() => setOpen(true)} aria-label="Открыть меню">
|
||||||
|
<Menu className="size-5" />
|
||||||
|
</button>
|
||||||
|
<div className="hidden items-center gap-2 text-sm text-muted sm:flex">
|
||||||
|
<Link to="/cabinet">Кабинет</Link>
|
||||||
|
{location.pathname !== "/cabinet" && (
|
||||||
|
<>
|
||||||
|
<ChevronRight className="size-4" />
|
||||||
|
<span className="text-ink">Раздел</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="ml-auto flex items-center gap-3">
|
||||||
|
<ThemeToggle />
|
||||||
|
<label className="hidden text-xs text-muted md:block">
|
||||||
|
Тестовая роль
|
||||||
|
<select
|
||||||
|
value={user.role}
|
||||||
|
onChange={(event) => {
|
||||||
|
switchRole(event.target.value);
|
||||||
|
navigate("/cabinet");
|
||||||
|
}}
|
||||||
|
className="ml-2 rounded-md border border-line bg-surface px-2 py-1.5 text-ink dark:[color-scheme:dark]"
|
||||||
|
>
|
||||||
|
<option value="user">Пользователь</option>
|
||||||
|
<option value="editor">Редактор</option>
|
||||||
|
<option value="moderator">Модератор</option>
|
||||||
|
<option value="admin">Администратор</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-sm font-semibold">{user.name}</p>
|
||||||
|
<Badge className="mt-1">{roleLabels[user.role]}</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main className="page-shell-wide overflow-x-clip py-4 sm:py-6 lg:py-8">
|
||||||
|
<AnimatePresence mode="wait" initial={false}>
|
||||||
|
<PageTransition key={location.pathname} routeKey={location.pathname}>
|
||||||
|
<Outlet />
|
||||||
|
</PageTransition>
|
||||||
|
</AnimatePresence>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
227
apps/web/src/app/layouts/PublicLayout.jsx
Normal file
227
apps/web/src/app/layouts/PublicLayout.jsx
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
|
||||||
|
import {
|
||||||
|
BookOpen,
|
||||||
|
CalendarDays,
|
||||||
|
RadioTower,
|
||||||
|
LogIn,
|
||||||
|
Menu,
|
||||||
|
Search,
|
||||||
|
UserRound,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Link, NavLink, Outlet, useLocation, useNavigate } from "react-router-dom";
|
||||||
|
import { useSession } from "../store/session";
|
||||||
|
import { Button } from "../../shared/ui/Button";
|
||||||
|
import { DstuLogo } from "../../shared/ui/DstuLogo";
|
||||||
|
import { ThemeToggle } from "../../shared/ui/ThemeToggle";
|
||||||
|
import { PageTransition } from "../../shared/ui/PageTransition";
|
||||||
|
import { cn } from "../../shared/lib/cn";
|
||||||
|
|
||||||
|
const links = [
|
||||||
|
{ to: "/materials", label: "Материалы", icon: BookOpen },
|
||||||
|
{ to: "/events", label: "События", icon: CalendarDays },
|
||||||
|
{ to: "/media", label: "Медиаканалы", icon: RadioTower },
|
||||||
|
{ to: "/about", label: "Об университете" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function PublicLayout() {
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
const [searchOpen, setSearchOpen] = useState(false);
|
||||||
|
const user = useSession((state) => state.user);
|
||||||
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const reduceMotion = useReducedMotion();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-paper">
|
||||||
|
<header className="sticky top-0 z-40 border-b border-dstu-menu-line bg-dstu-menu text-white shadow-sm">
|
||||||
|
<div className="page-shell flex h-18 items-center gap-5">
|
||||||
|
<Link className="flex shrink-0 items-center gap-3" to="/" viewTransition>
|
||||||
|
<DstuLogo className="size-10" />
|
||||||
|
<span>
|
||||||
|
<span className="block font-serif text-lg leading-none">ДГТУ МЕДИА</span>
|
||||||
|
<span className="mt-1 hidden text-xs font-bold uppercase tracking-[0.12em] text-white/55 sm:block">
|
||||||
|
Университетское медиа
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<nav className="ml-8 hidden items-center gap-7 lg:flex" aria-label="Основная навигация">
|
||||||
|
{links.map(({ to, label }) => (
|
||||||
|
<NavLink
|
||||||
|
key={to}
|
||||||
|
to={to}
|
||||||
|
viewTransition
|
||||||
|
className={({ isActive }) =>
|
||||||
|
cn(
|
||||||
|
"text-sm font-semibold transition hover:text-dstu-menu-accent",
|
||||||
|
isActive ? "text-dstu-menu-accent" : "text-white/75",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
<ThemeToggle className="text-white/75 hover:bg-white/10 hover:text-white" />
|
||||||
|
<button
|
||||||
|
className="grid size-10 place-items-center rounded-md text-white/75 transition hover:bg-white/10 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-dstu-menu-accent"
|
||||||
|
onClick={() => setSearchOpen((value) => !value)}
|
||||||
|
aria-label="Открыть поиск"
|
||||||
|
>
|
||||||
|
<Search className="size-5" />
|
||||||
|
</button>
|
||||||
|
{user ? (
|
||||||
|
<Button
|
||||||
|
className="hidden border-white/25 bg-white/10 text-white hover:border-dstu-menu-accent hover:text-dstu-menu-accent sm:inline-flex"
|
||||||
|
variant="secondary"
|
||||||
|
icon={<UserRound className="size-4" />}
|
||||||
|
onClick={() => navigate("/cabinet")}
|
||||||
|
>
|
||||||
|
Кабинет
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
className="hidden border-white/25 bg-white/10 text-white hover:border-dstu-menu-accent hover:text-dstu-menu-accent sm:inline-flex"
|
||||||
|
variant="secondary"
|
||||||
|
icon={<LogIn className="size-4" />}
|
||||||
|
onClick={() => navigate("/login", { state: { from: location.pathname } })}
|
||||||
|
>
|
||||||
|
Войти
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="grid size-10 place-items-center rounded-md text-white/80 hover:bg-white/10 hover:text-white lg:hidden"
|
||||||
|
onClick={() => setMenuOpen(true)}
|
||||||
|
aria-label="Открыть меню"
|
||||||
|
>
|
||||||
|
<Menu className="size-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{searchOpen && (
|
||||||
|
<motion.form
|
||||||
|
initial={reduceMotion ? false : { opacity: 0, y: -8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -8 }}
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const data = new FormData(event.currentTarget);
|
||||||
|
navigate(`/materials?q=${encodeURIComponent(String(data.get("q") ?? ""))}`);
|
||||||
|
setSearchOpen(false);
|
||||||
|
}}
|
||||||
|
className="border-t border-line bg-surface"
|
||||||
|
>
|
||||||
|
<div className="page-shell flex gap-3 py-4">
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
name="q"
|
||||||
|
placeholder="Что вы хотите найти?"
|
||||||
|
className="min-w-0 flex-1 rounded-md border border-line bg-white px-4 py-2.5 text-ink outline-none focus:border-primary focus:ring-2 focus:ring-primary/15 dark:bg-paper"
|
||||||
|
/>
|
||||||
|
<Button type="submit">Найти</Button>
|
||||||
|
</div>
|
||||||
|
</motion.form>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{menuOpen && (
|
||||||
|
<>
|
||||||
|
<motion.button
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="fixed inset-0 z-40 bg-ink/30 lg:hidden"
|
||||||
|
onClick={() => setMenuOpen(false)}
|
||||||
|
aria-label="Закрыть меню"
|
||||||
|
/>
|
||||||
|
<motion.aside
|
||||||
|
initial={reduceMotion ? false : { x: "100%" }}
|
||||||
|
animate={{ x: 0 }}
|
||||||
|
exit={{ x: "100%" }}
|
||||||
|
transition={{ duration: 0.22 }}
|
||||||
|
className="fixed inset-y-0 right-0 z-50 w-[min(88vw,24rem)] bg-dstu-menu p-6 text-white shadow-2xl lg:hidden"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="font-serif text-2xl">Навигация</span>
|
||||||
|
<button
|
||||||
|
className="grid size-10 place-items-center rounded-md hover:bg-white/10"
|
||||||
|
onClick={() => setMenuOpen(false)}
|
||||||
|
aria-label="Закрыть меню"
|
||||||
|
>
|
||||||
|
<X className="size-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<nav className="mt-8 grid gap-2">
|
||||||
|
{links.map(({ to, label, icon: Icon }) => (
|
||||||
|
<NavLink
|
||||||
|
key={to}
|
||||||
|
to={to}
|
||||||
|
onClick={() => setMenuOpen(false)}
|
||||||
|
className="flex items-center gap-3 rounded-md px-3 py-3 font-semibold text-white/80 hover:bg-white/10 hover:text-white"
|
||||||
|
>
|
||||||
|
{Icon && <Icon className="size-5 text-dstu-menu-accent" />}
|
||||||
|
{label}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
<NavLink
|
||||||
|
to={user ? "/cabinet" : "/login"}
|
||||||
|
onClick={() => setMenuOpen(false)}
|
||||||
|
className="mt-4 rounded-md bg-dstu-menu-accent px-4 py-3 text-center font-semibold text-dstu-menu"
|
||||||
|
>
|
||||||
|
{user ? "Личный кабинет" : "Войти"}
|
||||||
|
</NavLink>
|
||||||
|
</nav>
|
||||||
|
</motion.aside>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
<main className="overflow-x-clip">
|
||||||
|
<AnimatePresence mode="wait" initial={false}>
|
||||||
|
<PageTransition key={location.pathname} routeKey={location.pathname}>
|
||||||
|
<Outlet />
|
||||||
|
</PageTransition>
|
||||||
|
</AnimatePresence>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer className="mt-20 bg-dstu-menu text-white">
|
||||||
|
<div className="page-shell grid gap-10 py-14 md:grid-cols-3">
|
||||||
|
<div>
|
||||||
|
<p className="font-serif text-2xl">ДГТУ МЕДИА</p>
|
||||||
|
<p className="mt-3 max-w-sm text-sm leading-6 text-white/65">
|
||||||
|
Новости, исследования и события Донского государственного технического университета.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-bold uppercase tracking-widest text-white/50">Разделы</p>
|
||||||
|
<div className="mt-4 grid gap-2 text-sm">
|
||||||
|
<Link to="/materials">Материалы</Link>
|
||||||
|
<Link to="/events">События</Link>
|
||||||
|
<Link to="/media">Медиаканалы</Link>
|
||||||
|
<Link to="/about">Об университете</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-bold uppercase tracking-widest text-white/50">Контакты</p>
|
||||||
|
<p className="mt-4 text-sm leading-6 text-white/75">
|
||||||
|
пл. Гагарина, 1, Ростов-на-Дону
|
||||||
|
<br />
|
||||||
|
media@dstu.ru
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="border-t border-white/10 px-4 py-5 text-center text-xs text-white/45">
|
||||||
|
© 2026 ДГТУ. Учебный проект.
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
147
apps/web/src/app/store/session.js
Normal file
147
apps/web/src/app/store/session.js
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
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 normalizeUser(JSON.parse(sessionStorage.getItem("user") ?? "null"));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistUser(user) {
|
||||||
|
const normalizedUser = normalizeUser(user);
|
||||||
|
if (normalizedUser) sessionStorage.setItem("user", JSON.stringify(normalizedUser));
|
||||||
|
else sessionStorage.removeItem("user");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getToken(payload) {
|
||||||
|
return payload?.accessToken ?? payload?.token ?? payload?.jwt;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUser(payload) {
|
||||||
|
return normalizeUser(payload?.user ?? payload?.profile ?? payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useSession = create((set, get) => ({
|
||||||
|
user: readStoredUser(),
|
||||||
|
initializing: false,
|
||||||
|
|
||||||
|
register: async (payload) => {
|
||||||
|
const response = await authApi.register(payload);
|
||||||
|
const token = getToken(response);
|
||||||
|
const user = getUser(response);
|
||||||
|
tokenStorage.set(token);
|
||||||
|
set({ user });
|
||||||
|
persistUser(user);
|
||||||
|
return user;
|
||||||
|
},
|
||||||
|
|
||||||
|
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 = normalizeUser(response.user ?? (await authApi.me()));
|
||||||
|
set({ user });
|
||||||
|
persistUser(user);
|
||||||
|
return user;
|
||||||
|
},
|
||||||
|
|
||||||
|
loadMe: async () => {
|
||||||
|
if (!tokenStorage.get()) return get().user;
|
||||||
|
|
||||||
|
set({ initializing: true });
|
||||||
|
try {
|
||||||
|
const user = normalizeUser(await authApi.me());
|
||||||
|
set({ user, initializing: false });
|
||||||
|
persistUser(user);
|
||||||
|
return user;
|
||||||
|
} catch (error) {
|
||||||
|
set({ user: null, initializing: false });
|
||||||
|
persistUser(null);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
logout: async () => {
|
||||||
|
if (tokenStorage.get()) {
|
||||||
|
try {
|
||||||
|
await authApi.logout();
|
||||||
|
} catch {
|
||||||
|
// The local session still must be cleared if backend logout fails.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenStorage.clear();
|
||||||
|
persistUser(null);
|
||||||
|
set({ user: null });
|
||||||
|
},
|
||||||
|
|
||||||
|
changePassword: (payload) => authApi.changePassword(payload),
|
||||||
|
|
||||||
|
updateUser: (patch) => {
|
||||||
|
const user = { ...get().user, ...patch };
|
||||||
|
set({ user });
|
||||||
|
persistUser(user);
|
||||||
|
return 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") {
|
||||||
|
window.addEventListener("auth:unauthorized", () => {
|
||||||
|
persistUser(null);
|
||||||
|
useSession.setState({ user: null });
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
import { useEffect } from 'react';
|
|
||||||
import type { UserProfile } from '../types';
|
|
||||||
|
|
||||||
export type MenuView = 'home' | 'catalog' | 'search' | 'detail' | 'profile' | 'admin';
|
|
||||||
|
|
||||||
type MenuItem = {
|
|
||||||
label: string;
|
|
||||||
view: MenuView;
|
|
||||||
description: string;
|
|
||||||
requiresAuth?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
const menuItems: MenuItem[] = [
|
|
||||||
{ label: 'Новости и статьи', view: 'catalog', description: 'Публичные публикации, анонсы и материалы редакций' },
|
|
||||||
{ label: 'Медиа', view: 'catalog', description: 'Видео, аудио, текстовые и графические материалы' },
|
|
||||||
{ label: 'Мероприятия', view: 'catalog', description: 'Демо-анонсы до подтверждения отдельной сущности Event' },
|
|
||||||
{ label: 'Спикеры', view: 'home', description: 'Поиск и подписки на демонстрационных спикеров' },
|
|
||||||
{ label: 'Категории и теги', view: 'search', description: 'Фильтры, быстрые теги и полнотекстовый поиск' },
|
|
||||||
{ label: 'Личный кабинет', view: 'profile', description: 'Профиль, подписки, уведомления и роли', requiresAuth: true },
|
|
||||||
{ label: 'Администрирование', view: 'admin', description: 'Модерация, пользователи, роли, журнал действий', requiresAuth: true }
|
|
||||||
];
|
|
||||||
|
|
||||||
export function AppMenu({
|
|
||||||
open,
|
|
||||||
user,
|
|
||||||
onNavigate,
|
|
||||||
onClose,
|
|
||||||
onOpenAuth
|
|
||||||
}: {
|
|
||||||
open: boolean;
|
|
||||||
user: UserProfile | null;
|
|
||||||
onNavigate: (view: MenuView) => void;
|
|
||||||
onClose: () => void;
|
|
||||||
onOpenAuth: () => void;
|
|
||||||
}) {
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const previousOverflow = document.body.style.overflow;
|
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
|
||||||
if (event.key === 'Escape') {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
document.body.style.overflow = 'hidden';
|
|
||||||
window.addEventListener('keydown', handleKeyDown);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
document.body.style.overflow = previousOverflow;
|
|
||||||
window.removeEventListener('keydown', handleKeyDown);
|
|
||||||
};
|
|
||||||
}, [open, onClose]);
|
|
||||||
|
|
||||||
if (!open) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleItemClick = (item: MenuItem) => {
|
|
||||||
if (item.requiresAuth && !user) {
|
|
||||||
onClose();
|
|
||||||
onOpenAuth();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
onClose();
|
|
||||||
onNavigate(item.view);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
id="fullscreen-menu"
|
|
||||||
className="fixed inset-0 overflow-y-auto bg-[#f6f9fd] text-[#071426]"
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-labelledby="app-menu-title"
|
|
||||||
style={{ zIndex: 2147483647 }}
|
|
||||||
>
|
|
||||||
<div className="mx-auto grid min-h-screen w-[min(1180px,calc(100vw-32px))] gap-8 py-6 lg:grid-cols-[360px_1fr] lg:py-10">
|
|
||||||
<aside className="flex flex-col rounded-[2rem] bg-[#11519c] p-6 text-white shadow-[0_24px_80px_rgba(17,81,156,0.24)] lg:sticky lg:top-10 lg:h-[calc(100vh-5rem)]">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-black uppercase tracking-[0.28em] text-white/70">Меню платформы</p>
|
|
||||||
<h2 id="app-menu-title" className="mt-5 text-5xl font-black leading-none tracking-tight">
|
|
||||||
Fable
|
|
||||||
</h2>
|
|
||||||
<p className="mt-5 text-base leading-7 text-white/80">
|
|
||||||
Навигация только по разделам медиаплатформы: публичный контур, поиск, профиль и административные сценарии.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-8 rounded-[1.5rem] bg-white/15 p-4 text-sm leading-6 text-white/80">
|
|
||||||
{user ? `Вы вошли как ${user.name}. Роли: ${user.roles.join(', ')}.` : 'Сейчас открыт публичный доступ. Для профиля и администрирования нужен вход.'}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-auto flex flex-col gap-3 pt-8">
|
|
||||||
{!user ? (
|
|
||||||
<button className="min-h-12 rounded-full bg-white px-5 py-3 text-sm font-black text-[#11519c] transition hover:bg-[#eef7ff]" type="button" onClick={onOpenAuth}>
|
|
||||||
Войти в личный кабинет
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
<button className="min-h-12 rounded-full border border-white/30 px-5 py-3 text-sm font-black text-white transition hover:bg-white/10" type="button" onClick={onClose}>
|
|
||||||
Закрыть меню
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<main className="pb-10" aria-label="Разделы меню">
|
|
||||||
<div className="mb-5 flex flex-col justify-between gap-3 rounded-[1.5rem] border border-[#d7e3f1] bg-white p-5 shadow-sm md:flex-row md:items-center">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-black uppercase tracking-[0.22em] text-[#11519c]">Быстрый переход</p>
|
|
||||||
<p className="mt-2 text-sm leading-6 text-slate-600">Esc закрывает меню. Выберите раздел или вернитесь на страницу.</p>
|
|
||||||
</div>
|
|
||||||
<button className="min-h-11 rounded-full bg-[#071426] px-5 py-2.5 text-sm font-black text-white transition hover:bg-[#11519c]" type="button" onClick={onClose}>
|
|
||||||
На страницу
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
|
||||||
{menuItems.map((item, index) => {
|
|
||||||
const locked = item.requiresAuth && !user;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={`${item.label}-${index}`}
|
|
||||||
className="group min-h-44 rounded-[2rem] border border-[#d7e3f1] bg-white p-6 text-left shadow-sm transition hover:-translate-y-0.5 hover:border-[#11519c] hover:shadow-[0_18px_50px_rgba(17,81,156,0.16)]"
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleItemClick(item)}
|
|
||||||
>
|
|
||||||
<span className="text-sm font-black text-[#11519c]">{String(index + 1).padStart(2, '0')}</span>
|
|
||||||
<span className="mt-5 block text-2xl font-black leading-tight text-[#071426]">{item.label}</span>
|
|
||||||
<span className="mt-3 block text-sm leading-6 text-slate-600">{item.description}</span>
|
|
||||||
<span className="mt-5 inline-flex min-h-10 items-center rounded-full bg-[#eef7ff] px-4 text-sm font-black text-[#11519c] group-hover:bg-[#11519c] group-hover:text-white">
|
|
||||||
{locked ? 'Войти для доступа' : 'Открыть раздел'}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,216 +0,0 @@
|
|||||||
import type { AuditItem, CommentItem, ContentItem, NotificationItem, Speaker, UserProfile } from './types';
|
|
||||||
|
|
||||||
export const demoCategories = [
|
|
||||||
'Новости',
|
|
||||||
'Статьи',
|
|
||||||
'Видео',
|
|
||||||
'Аудио',
|
|
||||||
'Графика',
|
|
||||||
'Мероприятия'
|
|
||||||
];
|
|
||||||
|
|
||||||
export const demoTags = [
|
|
||||||
'медиапроизводство',
|
|
||||||
'интервью',
|
|
||||||
'анонс',
|
|
||||||
'образование',
|
|
||||||
'редакция',
|
|
||||||
'архив'
|
|
||||||
];
|
|
||||||
|
|
||||||
export const demoContent: ContentItem[] = [
|
|
||||||
{
|
|
||||||
id: 'demo-news-1',
|
|
||||||
title: 'Демо-новость о запуске медиаплатформы',
|
|
||||||
lead: 'Публичная карточка показывает, как новости и статьи будут выглядеть в едином каталоге.',
|
|
||||||
body:
|
|
||||||
'Это демонстрационный материал без реальных персональных данных, подразделений и брендовых материалов. Он нужен только для проверки интерфейса, поиска, фильтров и жизненного цикла публикации.',
|
|
||||||
type: 'news',
|
|
||||||
category: 'Новости',
|
|
||||||
tags: ['медиапроизводство', 'анонс'],
|
|
||||||
author: 'Демо-редакция',
|
|
||||||
publishedAt: '2026-06-04',
|
|
||||||
visibility: 'Публично',
|
|
||||||
status: 'Опубликовано',
|
|
||||||
views: 1240,
|
|
||||||
imageTone: 'from-university-700 via-university-500 to-sky-300'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'demo-video-1',
|
|
||||||
title: 'Демо-видео: открытая лекция',
|
|
||||||
lead: 'Видеоматериал с метаданными, статусом проверки, категорией и тегами.',
|
|
||||||
body:
|
|
||||||
'В реальной системе здесь будет предпросмотр видео, CDN-ссылка, история модерации и аналитика просмотров. В прототипе используется безопасная заглушка.',
|
|
||||||
type: 'video',
|
|
||||||
category: 'Видео',
|
|
||||||
tags: ['образование', 'архив'],
|
|
||||||
author: 'Демо-медиагруппа',
|
|
||||||
publishedAt: '2026-06-09',
|
|
||||||
duration: '18:40',
|
|
||||||
visibility: 'После входа',
|
|
||||||
status: 'На проверке',
|
|
||||||
views: 382,
|
|
||||||
imageTone: 'from-indigo-700 via-university-800 to-cyan-500'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'demo-audio-1',
|
|
||||||
title: 'Демо-аудио: выпуск университетского радио',
|
|
||||||
lead: 'Аудиоконтент хранится в медиатеке и связывается с публикациями, авторами и тегами.',
|
|
||||||
body:
|
|
||||||
'Этот пример показывает карточку аудио без использования реального названия передачи или записи. Права доступа и публикация управляются через роли.',
|
|
||||||
type: 'audio',
|
|
||||||
category: 'Аудио',
|
|
||||||
tags: ['интервью', 'редакция'],
|
|
||||||
author: 'Демо-редактор',
|
|
||||||
publishedAt: '2026-06-11',
|
|
||||||
duration: '32:10',
|
|
||||||
visibility: 'Публично',
|
|
||||||
status: 'Опубликовано',
|
|
||||||
views: 715,
|
|
||||||
imageTone: 'from-blue-950 via-blue-700 to-emerald-300'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'demo-graphic-1',
|
|
||||||
title: 'Демо-графика: афиша редакционного события',
|
|
||||||
lead: 'Графические материалы можно фильтровать по типу, дате, категории и тегам.',
|
|
||||||
body:
|
|
||||||
'Заглушка демонстрирует графический материал без копирования фотографий, логотипов или брендовых элементов сторонних сайтов.',
|
|
||||||
type: 'graphic',
|
|
||||||
category: 'Графика',
|
|
||||||
tags: ['анонс', 'редакция'],
|
|
||||||
author: 'Демо-дизайнер',
|
|
||||||
publishedAt: '2026-06-13',
|
|
||||||
visibility: 'По роли',
|
|
||||||
status: 'На модерации',
|
|
||||||
views: 96,
|
|
||||||
imageTone: 'from-sky-400 via-blue-600 to-slate-900'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'demo-event-1',
|
|
||||||
title: 'Демо-анонс медиавстречи со спикером',
|
|
||||||
lead: 'Мероприятия показаны как тип медиаконтента до подтверждения отдельной сущности Event.',
|
|
||||||
body:
|
|
||||||
'В ТЗ мероприятия указаны в пользовательских функциях, но не выделены в таблице информационных объектов. Поэтому в первом прототипе они представлены как анонсы контента.',
|
|
||||||
type: 'event',
|
|
||||||
category: 'Мероприятия',
|
|
||||||
tags: ['анонс', 'интервью'],
|
|
||||||
author: 'Демо-менеджер',
|
|
||||||
publishedAt: '2026-06-17',
|
|
||||||
visibility: 'Публично',
|
|
||||||
status: 'Черновик',
|
|
||||||
views: 45,
|
|
||||||
imageTone: 'from-university-900 via-violet-700 to-orange-300'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'demo-article-1',
|
|
||||||
title: 'Демо-статья о жизненном цикле материала',
|
|
||||||
lead: 'Публикация проходит этапы: черновик, модерация, проверка, публикация, архив.',
|
|
||||||
body:
|
|
||||||
'Материал показывает администраторские сценарии, очереди проверки, журнал действий и ограничения видимости.',
|
|
||||||
type: 'article',
|
|
||||||
category: 'Статьи',
|
|
||||||
tags: ['медиапроизводство', 'архив'],
|
|
||||||
author: 'Демо-автор',
|
|
||||||
publishedAt: '2026-06-20',
|
|
||||||
visibility: 'После входа',
|
|
||||||
status: 'Архив',
|
|
||||||
views: 531,
|
|
||||||
imageTone: 'from-slate-900 via-university-800 to-sky-200'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
export const demoSpeakers: Speaker[] = [
|
|
||||||
{
|
|
||||||
id: 'demo-speaker-1',
|
|
||||||
name: 'Демо-спикер 01',
|
|
||||||
role: 'Приглашенный эксперт',
|
|
||||||
topics: ['медиапроизводство', 'образование'],
|
|
||||||
materials: 8,
|
|
||||||
subscribers: 132
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'demo-speaker-2',
|
|
||||||
name: 'Демо-спикер 02',
|
|
||||||
role: 'Участник редакционного события',
|
|
||||||
topics: ['интервью', 'анонс'],
|
|
||||||
materials: 5,
|
|
||||||
subscribers: 74
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'demo-speaker-3',
|
|
||||||
name: 'Демо-спикер 03',
|
|
||||||
role: 'Автор образовательных материалов',
|
|
||||||
topics: ['архив', 'редакция'],
|
|
||||||
materials: 12,
|
|
||||||
subscribers: 205
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
export const demoUser: UserProfile = {
|
|
||||||
id: 'demo-user-1',
|
|
||||||
name: 'Демо-администратор',
|
|
||||||
login: 'demo_admin',
|
|
||||||
roles: ['администратор', 'редактор'],
|
|
||||||
subscriptions: ['Новости', 'Демо-спикер 01', 'медиапроизводство']
|
|
||||||
};
|
|
||||||
|
|
||||||
export const demoNotifications: NotificationItem[] = [
|
|
||||||
{
|
|
||||||
id: 'demo-notification-1',
|
|
||||||
title: 'Новый материал по подписке',
|
|
||||||
description: 'В категории «Новости» появился демонстрационный материал.',
|
|
||||||
read: false,
|
|
||||||
createdAt: '2026-06-13 10:20'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'demo-notification-2',
|
|
||||||
title: 'Материал ожидает проверки',
|
|
||||||
description: 'Демо-видео находится на этапе проверки перед публикацией.',
|
|
||||||
read: true,
|
|
||||||
createdAt: '2026-06-12 16:45'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
export const demoComments: CommentItem[] = [
|
|
||||||
{
|
|
||||||
id: 'demo-comment-1',
|
|
||||||
author: 'Демо-пользователь',
|
|
||||||
text: 'Комментарий доступен только авторизованным пользователям и может проходить модерацию.',
|
|
||||||
createdAt: '2026-06-13 12:00'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
export const demoAudit: AuditItem[] = [
|
|
||||||
{
|
|
||||||
id: 'demo-audit-1',
|
|
||||||
actor: 'Демо-администратор',
|
|
||||||
action: 'изменил статус',
|
|
||||||
target: 'Демо-видео: открытая лекция',
|
|
||||||
createdAt: '2026-06-13 11:10'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'demo-audit-2',
|
|
||||||
actor: 'Демо-редактор',
|
|
||||||
action: 'создал черновик',
|
|
||||||
target: 'Демо-анонс медиавстречи со спикером',
|
|
||||||
createdAt: '2026-06-12 15:35'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
export const demoUsers: UserProfile[] = [
|
|
||||||
demoUser,
|
|
||||||
{
|
|
||||||
id: 'demo-user-2',
|
|
||||||
name: 'Демо-редактор',
|
|
||||||
login: 'demo_editor',
|
|
||||||
roles: ['редактор'],
|
|
||||||
subscriptions: ['Видео']
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'demo-user-3',
|
|
||||||
name: 'Демо-пользователь',
|
|
||||||
login: 'demo_user',
|
|
||||||
roles: ['пользователь'],
|
|
||||||
subscriptions: ['Аудио', 'Демо-спикер 03']
|
|
||||||
}
|
|
||||||
];
|
|
||||||
@@ -1,140 +1,3 @@
|
|||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
:root {
|
|
||||||
color-scheme: light;
|
|
||||||
font-family: 'Golos Text', Inter, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
||||||
background: #f5f8fc;
|
|
||||||
color: #071426;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark {
|
|
||||||
color-scheme: dark;
|
|
||||||
background: #06101f;
|
|
||||||
color: #f6f9ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
html[data-font-scale='large'] {
|
|
||||||
font-size: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
html[data-font-scale='xlarge'] {
|
|
||||||
font-size: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
min-width: 320px;
|
|
||||||
min-height: 100vh;
|
|
||||||
margin: 0;
|
|
||||||
background:
|
|
||||||
radial-gradient(circle at top left, rgba(17, 81, 156, 0.13), transparent 34rem),
|
|
||||||
linear-gradient(180deg, #f7fbff 0%, #eef4fb 54%, #f8fafc 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark body {
|
|
||||||
background:
|
|
||||||
radial-gradient(circle at top left, rgba(51, 136, 243, 0.24), transparent 30rem),
|
|
||||||
linear-gradient(180deg, #06101f 0%, #0a1728 58%, #07111e 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.contrast body {
|
|
||||||
background: #ffffff;
|
|
||||||
color: #000000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark.contrast body {
|
|
||||||
background: #000000;
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
button,
|
|
||||||
input,
|
|
||||||
select,
|
|
||||||
textarea {
|
|
||||||
font: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
a,
|
|
||||||
button,
|
|
||||||
input,
|
|
||||||
select,
|
|
||||||
textarea {
|
|
||||||
touch-action: manipulation;
|
|
||||||
}
|
|
||||||
|
|
||||||
.skip-link {
|
|
||||||
position: fixed;
|
|
||||||
left: 1rem;
|
|
||||||
top: 1rem;
|
|
||||||
z-index: 1000;
|
|
||||||
transform: translateY(-150%);
|
|
||||||
border-radius: 999px;
|
|
||||||
background: #ffffff;
|
|
||||||
color: #11519c;
|
|
||||||
box-shadow: 0 18px 50px rgba(7, 20, 38, 0.2);
|
|
||||||
font-weight: 800;
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
transition: transform 160ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.skip-link:focus {
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
button:focus-visible,
|
|
||||||
a:focus-visible,
|
|
||||||
input:focus-visible,
|
|
||||||
select:focus-visible,
|
|
||||||
textarea:focus-visible {
|
|
||||||
outline: none;
|
|
||||||
box-shadow: 0 0 0 3px rgba(17, 81, 156, 0.45);
|
|
||||||
}
|
|
||||||
|
|
||||||
input:focus-visible {
|
|
||||||
border-radius: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-visual {
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-visual::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
inset: 16% 12%;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.34);
|
|
||||||
border-radius: 999px;
|
|
||||||
transform: rotate(-10deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.no-images .media-visual {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.safe-area {
|
|
||||||
width: min(1180px, calc(100vw - 32px));
|
|
||||||
margin-inline: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scrollbar-soft::-webkit-scrollbar {
|
|
||||||
width: 10px;
|
|
||||||
height: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scrollbar-soft::-webkit-scrollbar-thumb {
|
|
||||||
background: rgba(17, 81, 156, 0.35);
|
|
||||||
border-radius: 999px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
*,
|
|
||||||
*::before,
|
|
||||||
*::after {
|
|
||||||
scroll-behavior: auto !important;
|
|
||||||
transition-duration: 0.01ms !important;
|
|
||||||
animation-duration: 0.01ms !important;
|
|
||||||
animation-iteration-count: 1 !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
26
apps/web/src/main.jsx
Normal file
26
apps/web/src/main.jsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { StrictMode } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { BrowserRouter } from "react-router-dom";
|
||||||
|
import { App } from "./app/App";
|
||||||
|
import "./styles.css";
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 30_000,
|
||||||
|
retry: 1,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
createRoot(document.getElementById("root")).render(
|
||||||
|
<StrictMode>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import { StrictMode } from 'react';
|
|
||||||
import { createRoot } from 'react-dom/client';
|
|
||||||
import App from './App';
|
|
||||||
import './index.css';
|
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
|
||||||
<StrictMode>
|
|
||||||
<App />
|
|
||||||
</StrictMode>
|
|
||||||
);
|
|
||||||
34
apps/web/src/pages/AboutPage.jsx
Normal file
34
apps/web/src/pages/AboutPage.jsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { Building2, GraduationCap, Microscope } from "lucide-react";
|
||||||
|
|
||||||
|
export function AboutPage() {
|
||||||
|
return (
|
||||||
|
<div className="page-shell py-12">
|
||||||
|
<div className="rounded-xl bg-primary p-8 text-white sm:p-14">
|
||||||
|
<p className="text-xs font-bold uppercase tracking-widest text-white/55">О медиапортале</p>
|
||||||
|
<h1 className="mt-4 max-w-4xl font-serif text-4xl leading-tight sm:text-6xl">
|
||||||
|
Университет говорит голосами тех, кто его создаёт
|
||||||
|
</h1>
|
||||||
|
<p className="mt-6 max-w-2xl text-lg leading-8 text-white/70">
|
||||||
|
«ДГТУ МЕДИА» объединяет новости, исследования, события и истории сообщества в
|
||||||
|
одном открытом цифровом пространстве.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="mt-10 grid gap-6 md:grid-cols-3">
|
||||||
|
{[
|
||||||
|
[GraduationCap, "Образование", "Показываем, как меняются программы и учебные практики."],
|
||||||
|
[Microscope, "Исследования", "Объясняем сложные разработки ясным человеческим языком."],
|
||||||
|
[Building2, "Сообщество", "Сохраняем события и голоса университетской жизни."],
|
||||||
|
].map(([Icon, title, text]) => {
|
||||||
|
const ItemIcon = Icon;
|
||||||
|
return (
|
||||||
|
<div key={String(title)} className="rounded-lg border border-line bg-surface p-6">
|
||||||
|
<ItemIcon className="size-6 text-accent" />
|
||||||
|
<h2 className="mt-6 font-serif text-2xl">{String(title)}</h2>
|
||||||
|
<p className="mt-3 leading-7 text-muted">{String(text)}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
107
apps/web/src/pages/EventsPage.jsx
Normal file
107
apps/web/src/pages/EventsPage.jsx
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { CalendarDays, MapPin, MoveRight } from "lucide-react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { directoriesApi } from "../shared/api/endpoints";
|
||||||
|
import { queryKeys } from "../shared/api/queryKeys";
|
||||||
|
import { toList } from "../shared/api/normalize";
|
||||||
|
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).map(normalizeEvent);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-shell py-10">
|
||||||
|
<div className="grid gap-8 border-b border-line pb-10 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-bold uppercase tracking-widest text-accent">Афиша ДГТУ</p>
|
||||||
|
<h1 className="mt-3 font-serif text-5xl">События</h1>
|
||||||
|
</div>
|
||||||
|
<p className="self-end text-lg leading-8 text-muted">
|
||||||
|
Открытые лекции, фестивали, выставки и встречи. Всё, что помогает университету
|
||||||
|
разговаривать с городом.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8 flex gap-2 overflow-x-auto pb-2">
|
||||||
|
{["Все события", "Лекции", "Фестивали", "Экскурсии", "Выставки"].map(
|
||||||
|
(item, index) => (
|
||||||
|
<Button key={item} variant={index === 0 ? "primary" : "secondary"} size="sm">
|
||||||
|
{item}
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8 divide-y divide-line border-y border-line">
|
||||||
|
{isLoading ? (
|
||||||
|
[1, 2, 3].map((item) => <Skeleton key={item} className="my-6 h-28 w-full" />)
|
||||||
|
) : isError ? (
|
||||||
|
<div className="py-8">
|
||||||
|
<ErrorState retry={refetch} />
|
||||||
|
</div>
|
||||||
|
) : pageEvents.length ? (
|
||||||
|
pageEvents.map((event, index) => (
|
||||||
|
<article
|
||||||
|
key={event.id}
|
||||||
|
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.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>
|
||||||
|
<h2 className="mt-3 font-serif text-2xl transition group-hover:text-primary sm:text-3xl">
|
||||||
|
{event.title}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-3 max-w-2xl leading-7 text-muted">{event.description}</p>
|
||||||
|
<div className="mt-4 flex flex-wrap gap-4 text-sm text-muted">
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<CalendarDays className="size-4" /> {event.time}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<MapPin className="size-4" /> {event.place}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button className="md:justify-self-end" variant="secondary" icon={<MoveRight className="size-4" />}>
|
||||||
|
Подробнее
|
||||||
|
</Button>
|
||||||
|
</article>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="py-8">
|
||||||
|
<EmptyState title="Событий нет" text="Backend пока не вернул события." />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
apps/web/src/pages/ForbiddenPage.jsx
Normal file
24
apps/web/src/pages/ForbiddenPage.jsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { ArrowLeft, LockKeyhole } from "lucide-react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { Button } from "../shared/ui/Button";
|
||||||
|
|
||||||
|
export function ForbiddenPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
return (
|
||||||
|
<div className="grid min-h-screen place-items-center bg-paper px-4">
|
||||||
|
<div className="max-w-lg text-center">
|
||||||
|
<div className="mx-auto grid size-20 place-items-center rounded-full bg-accent-soft text-accent">
|
||||||
|
<LockKeyhole className="size-9" />
|
||||||
|
</div>
|
||||||
|
<p className="mt-6 text-xs font-bold uppercase tracking-widest text-accent">Ошибка 403</p>
|
||||||
|
<h1 className="mt-3 font-serif text-4xl">Недостаточно прав</h1>
|
||||||
|
<p className="mt-4 leading-7 text-muted">
|
||||||
|
Этот раздел доступен другой роли. В тестовом кабинете роль можно переключить в верхней панели.
|
||||||
|
</p>
|
||||||
|
<Button className="mt-7" icon={<ArrowLeft className="size-4" />} onClick={() => navigate("/cabinet")}>
|
||||||
|
Вернуться в кабинет
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
205
apps/web/src/pages/HomePage.jsx
Normal file
205
apps/web/src/pages/HomePage.jsx
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
import { ArrowRight, CalendarDays, ChevronRight } from "lucide-react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { contentApi, directoriesApi } from "../shared/api/endpoints";
|
||||||
|
import { normalizeContentList, toList } from "../shared/api/normalize";
|
||||||
|
import { queryKeys } from "../shared/api/queryKeys";
|
||||||
|
import { Badge } from "../shared/ui/Badge";
|
||||||
|
import { Button } from "../shared/ui/Button";
|
||||||
|
import { MaterialCard } from "../shared/ui/MaterialCard";
|
||||||
|
import { ResponsiveImage } from "../shared/ui/ResponsiveImage";
|
||||||
|
import { EmptyState, ErrorState, Skeleton } from "../shared/ui/States";
|
||||||
|
|
||||||
|
function eventDateParts(value = "") {
|
||||||
|
const date = new Date(value);
|
||||||
|
if (!Number.isNaN(date.getTime())) {
|
||||||
|
return [
|
||||||
|
date.toLocaleDateString("ru-RU", { day: "numeric" }),
|
||||||
|
date.toLocaleDateString("ru-RU", { month: "short" }),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
const [day = "", month = ""] = String(value).split(" ");
|
||||||
|
return [day, month];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HomePage() {
|
||||||
|
const { data: contentPayload, isLoading, isError, refetch } = useQuery({
|
||||||
|
queryKey: queryKeys.content({ page: "home" }),
|
||||||
|
queryFn: () => contentApi.list({ limit: 6 }),
|
||||||
|
});
|
||||||
|
const { data: eventsPayload } = useQuery({
|
||||||
|
queryKey: queryKeys.events({ page: "home" }),
|
||||||
|
queryFn: () => directoriesApi.events({ limit: 3 }),
|
||||||
|
});
|
||||||
|
const { data: categoriesPayload } = useQuery({
|
||||||
|
queryKey: queryKeys.categories,
|
||||||
|
queryFn: directoriesApi.categories,
|
||||||
|
});
|
||||||
|
|
||||||
|
const pageMaterials = normalizeContentList(contentPayload);
|
||||||
|
const pageEvents = toList(eventsPayload);
|
||||||
|
const pageCategories = toList(categoriesPayload);
|
||||||
|
const featured = pageMaterials.find((item) => item.featured) ?? pageMaterials[0];
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="page-shell grid gap-6 py-10">
|
||||||
|
<Skeleton className="h-96 w-full" />
|
||||||
|
<Skeleton className="h-44 w-full" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return (
|
||||||
|
<div className="page-shell py-16">
|
||||||
|
<ErrorState retry={refetch} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!featured) {
|
||||||
|
return (
|
||||||
|
<div className="page-shell py-16">
|
||||||
|
<EmptyState title="Материалы не найдены" text="Backend пока не вернул опубликованные записи." />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<section className="border-b border-line">
|
||||||
|
<div className="page-shell grid gap-8 py-8 md:py-12 lg:grid-cols-12">
|
||||||
|
<article className="relative overflow-hidden rounded-xl bg-primary lg:col-span-8">
|
||||||
|
<ResponsiveImage
|
||||||
|
src={featured.image}
|
||||||
|
alt=""
|
||||||
|
eager
|
||||||
|
className="absolute inset-0 h-full w-full object-cover opacity-45 mix-blend-luminosity"
|
||||||
|
/>
|
||||||
|
<div className="relative flex min-h-[clamp(26rem,60vh,36rem)] flex-col justify-end p-6 text-white sm:p-10 lg:p-12">
|
||||||
|
<Badge className="w-fit bg-white/90 text-primary">{featured.category}</Badge>
|
||||||
|
<h1 className="mt-5 max-w-4xl font-serif text-4xl leading-[1.04] tracking-[-0.03em] sm:text-5xl lg:text-6xl">
|
||||||
|
{featured.title}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-5 max-w-2xl text-base leading-7 text-white/80 sm:text-lg">{featured.excerpt}</p>
|
||||||
|
<Link className="mt-7 w-fit" to={`/materials/${featured.slug}`} viewTransition>
|
||||||
|
<Button variant="accent" size="lg" icon={<ArrowRight className="size-4" />}>
|
||||||
|
Читать материал
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<aside className="flex flex-col rounded-xl border border-line bg-surface p-6 lg:col-span-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<span className="text-xs font-bold uppercase tracking-widest text-accent">Сегодня</span>
|
||||||
|
<h2 className="mt-2 font-serif text-3xl">В университете</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-7 divide-y divide-line">
|
||||||
|
{pageMaterials.slice(1, 4).map((item) => (
|
||||||
|
<Link key={item.id} to={`/materials/${item.slug}`} viewTransition className="group block py-5 first:pt-0">
|
||||||
|
<span className="text-xs font-bold uppercase tracking-wider text-muted">
|
||||||
|
{item.type} · {item.publishedAt}
|
||||||
|
</span>
|
||||||
|
<h3 className="mt-2 font-serif text-xl leading-snug transition group-hover:text-primary">{item.title}</h3>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Link className="mt-auto flex items-center gap-2 border-t border-line pt-5 text-sm font-bold text-primary" to="/materials">
|
||||||
|
Все материалы <ChevronRight className="size-4" />
|
||||||
|
</Link>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="page-shell py-16">
|
||||||
|
<div className="flex items-end justify-between gap-6">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-bold uppercase tracking-widest text-accent">Новая лента</p>
|
||||||
|
<h2 className="mt-2 font-serif text-4xl">Последние публикации</h2>
|
||||||
|
</div>
|
||||||
|
<Link className="hidden text-sm font-bold text-primary sm:block" to="/materials">
|
||||||
|
Смотреть все →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="mt-8 grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{pageMaterials.slice(1, 4).map((material) => (
|
||||||
|
<MaterialCard key={material.id} material={material} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="bg-primary text-white">
|
||||||
|
<div className="page-shell grid gap-10 py-16 lg:grid-cols-[minmax(16rem,3fr)_minmax(0,5fr)]">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-bold uppercase tracking-widest text-white/55">Календарь</p>
|
||||||
|
<h2 className="mt-3 font-serif text-4xl">Ближайшие события</h2>
|
||||||
|
<p className="mt-4 max-w-md leading-7 text-white/65">
|
||||||
|
Лекции, выставки, защиты проектов и встречи университетского сообщества.
|
||||||
|
</p>
|
||||||
|
<Link to="/events">
|
||||||
|
<Button className="mt-7 border-white/25 text-white hover:bg-white/10" variant="secondary">
|
||||||
|
Открыть календарь
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-white/15 border-y border-white/15">
|
||||||
|
{pageEvents.length === 0 && (
|
||||||
|
<div className="py-8 text-white/65">Backend пока не вернул ближайшие события.</div>
|
||||||
|
)}
|
||||||
|
{pageEvents.map((event) => {
|
||||||
|
const [day, month] = eventDateParts(event.date ?? event.startsAt);
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
key={event.id}
|
||||||
|
className="grid gap-4 py-5 sm:grid-cols-[minmax(5rem,1fr)_minmax(0,5fr)_auto] sm:items-center"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<span className="block font-serif text-2xl">{day}</span>
|
||||||
|
<span className="text-xs uppercase tracking-wider text-white/50">{month}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-serif text-xl">{event.title}</h3>
|
||||||
|
<p className="mt-1 text-sm text-white/55">
|
||||||
|
{event.time ?? event.startsAt} · {event.place ?? event.location}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<CalendarDays className="hidden size-5 text-white/45 sm:block" />
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="page-shell py-16">
|
||||||
|
<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 = 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}
|
||||||
|
to={`/materials?category=${value}`}
|
||||||
|
className="group min-h-56 border-b border-r border-line bg-surface p-6 transition hover:bg-primary hover:text-white"
|
||||||
|
>
|
||||||
|
<span className="font-serif text-4xl text-line transition group-hover:text-white/25">
|
||||||
|
{String(index + 1).padStart(2, "0")}
|
||||||
|
</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">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
81
apps/web/src/pages/LoginPage.jsx
Normal file
81
apps/web/src/pages/LoginPage.jsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { ArrowLeft, KeyRound } from "lucide-react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { useSession } from "../app/store/session";
|
||||||
|
import { Button } from "../shared/ui/Button";
|
||||||
|
import { Input } from "../shared/ui/Field";
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
identifier: z.string().min(3, "Введите логин или почту"),
|
||||||
|
password: z.string().min(6, "Минимум 6 символов"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export function LoginPage() {
|
||||||
|
const login = useSession((state) => state.login);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors, isSubmitting },
|
||||||
|
} = useForm({
|
||||||
|
resolver: zodResolver(schema),
|
||||||
|
defaultValues: { identifier: "demo_admin", password: "demo_password" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async (data) => {
|
||||||
|
await login(data);
|
||||||
|
const state = location.state;
|
||||||
|
navigate(state?.from ?? "/cabinet", { replace: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid min-h-screen bg-paper lg:grid-cols-2">
|
||||||
|
<div className="hidden bg-primary p-12 text-white lg:flex lg:flex-col lg:justify-between">
|
||||||
|
<Link className="font-serif text-2xl" to="/">
|
||||||
|
ДГТУ МЕДИА
|
||||||
|
</Link>
|
||||||
|
<blockquote className="max-w-xl font-serif text-4xl leading-tight">
|
||||||
|
«Знание становится частью культуры, когда им можно делиться».
|
||||||
|
</blockquote>
|
||||||
|
<p className="text-sm text-white/50">Информационная система управления медиаконтентом</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-center p-5 sm:p-10">
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
<Link className="mb-10 inline-flex items-center gap-2 text-sm text-muted hover:text-primary" to="/">
|
||||||
|
<ArrowLeft className="size-4" /> Вернуться на портал
|
||||||
|
</Link>
|
||||||
|
<div className="grid size-12 place-items-center rounded-md bg-accent-soft text-accent">
|
||||||
|
<KeyRound className="size-6" />
|
||||||
|
</div>
|
||||||
|
<h1 className="mt-6 font-serif text-4xl">Вход в систему</h1>
|
||||||
|
<p className="mt-3 leading-7 text-muted">
|
||||||
|
Используйте демо-логин backend или тестовую почту роли.
|
||||||
|
</p>
|
||||||
|
<form className="mt-8 grid gap-5" noValidate onSubmit={handleSubmit(onSubmit)}>
|
||||||
|
<Input
|
||||||
|
label="Логин или почта"
|
||||||
|
error={errors.identifier?.message}
|
||||||
|
{...register("identifier")}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Пароль"
|
||||||
|
type="password"
|
||||||
|
error={errors.password?.message}
|
||||||
|
{...register("password")}
|
||||||
|
/>
|
||||||
|
<Button className="mt-2 w-full" type="submit" loading={isSubmitting}>
|
||||||
|
Войти
|
||||||
|
</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> demo_admin / demo_password. Также поддерживаются
|
||||||
|
user@dstu.ru, editor@dstu.ru, moderator@dstu.ru и admin@dstu.ru.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
apps/web/src/pages/LoginPage.test.jsx
Normal file
26
apps/web/src/pages/LoginPage.test.jsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { MemoryRouter } from "react-router-dom";
|
||||||
|
import { LoginPage } from "./LoginPage";
|
||||||
|
|
||||||
|
describe("LoginPage", () => {
|
||||||
|
it("показывает ошибки валидации для некорректных данных", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<LoginPage />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const email = screen.getByLabelText("Логин или почта");
|
||||||
|
const password = screen.getByLabelText("Пароль");
|
||||||
|
await user.clear(email);
|
||||||
|
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(screen.getByText("Минимум 6 символов")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
282
apps/web/src/pages/MaterialPage.jsx
Normal file
282
apps/web/src/pages/MaterialPage.jsx
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
import { Bookmark, Clock3, Eye, MessageCircle, Send, Share2, Star } from "lucide-react";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { Link, useParams } from "react-router-dom";
|
||||||
|
import { useSession } from "../app/store/session";
|
||||||
|
import { commentsApi, contentApi } from "../shared/api/endpoints";
|
||||||
|
import { normalizeContentItem, normalizeContentList, toEntity, toList } from "../shared/api/normalize";
|
||||||
|
import { queryKeys } from "../shared/api/queryKeys";
|
||||||
|
import { Badge } from "../shared/ui/Badge";
|
||||||
|
import { Button } from "../shared/ui/Button";
|
||||||
|
import { Textarea } from "../shared/ui/Field";
|
||||||
|
import { MaterialCard } from "../shared/ui/MaterialCard";
|
||||||
|
import { ResponsiveImage } from "../shared/ui/ResponsiveImage";
|
||||||
|
import { EmptyState, ErrorState, Skeleton } from "../shared/ui/States";
|
||||||
|
import { NotFoundPage } from "./NotFoundPage";
|
||||||
|
|
||||||
|
function normalizeComment(comment) {
|
||||||
|
return {
|
||||||
|
id: comment.id ?? `${comment.createdAt ?? Date.now()}-${comment.author?.id ?? comment.user?.id ?? "comment"}`,
|
||||||
|
author: comment.author?.name ?? comment.author ?? comment.user?.name ?? "Гость",
|
||||||
|
text: comment.text ?? comment.content ?? comment.body ?? "",
|
||||||
|
createdAt: comment.createdAt ?? comment.date ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value) {
|
||||||
|
if (!value) return "";
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return value;
|
||||||
|
return date.toLocaleString("ru-RU", { day: "numeric", month: "long", year: "numeric" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function RatingBlock({ material, detailKey }) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const initialAverage = Number(material.ratingAverage ?? material.rating ?? 0);
|
||||||
|
const initialCount = Number(material.ratingCount ?? material.ratingsCount ?? 0);
|
||||||
|
const currentRating = Number(material.myRating ?? 0);
|
||||||
|
const [selectedRating, setSelectedRating] = useState(currentRating);
|
||||||
|
|
||||||
|
const rateMutation = useMutation({
|
||||||
|
mutationFn: (rating) => contentApi.update(material.id, { rating }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.contentDetail(detailKey) });
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.contentDetail(material.id) });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const average = initialAverage.toFixed(1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="rounded-lg border border-line bg-surface p-5">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="font-serif text-xl">Оцените материал</h2>
|
||||||
|
<p className="mt-1 text-sm text-muted">Средняя оценка: {average} из 5, оценок: {initialCount}</p>
|
||||||
|
</div>
|
||||||
|
<span className="font-serif text-3xl text-primary">{average}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex gap-1" role="radiogroup" aria-label="Оценка материала">
|
||||||
|
{[1, 2, 3, 4, 5].map((value) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
className="rounded-md p-1 text-warning transition hover:bg-warning-soft focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedRating(value);
|
||||||
|
rateMutation.mutate(value);
|
||||||
|
}}
|
||||||
|
aria-label={`Поставить ${value}`}
|
||||||
|
aria-checked={selectedRating === value}
|
||||||
|
role="radio"
|
||||||
|
>
|
||||||
|
<Star
|
||||||
|
className="size-6"
|
||||||
|
fill={value <= (selectedRating || Math.round(initialAverage)) ? "currentColor" : "none"}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{rateMutation.isPending && <p className="mt-3 text-sm font-semibold text-muted">Сохраняем оценку...</p>}
|
||||||
|
{rateMutation.isSuccess && <p className="mt-3 text-sm font-semibold text-success">Спасибо, ваша оценка учтена.</p>}
|
||||||
|
{rateMutation.isError && <p className="mt-3 text-sm font-semibold text-danger">Не удалось сохранить оценку.</p>}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommentsBlock({ materialId }) {
|
||||||
|
const user = useSession((state) => state.user);
|
||||||
|
const [commentText, setCommentText] = useState("");
|
||||||
|
const [notice, setNotice] = useState("");
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const commentsQuery = useQuery({
|
||||||
|
queryKey: queryKeys.comments(materialId),
|
||||||
|
queryFn: () => commentsApi.list(materialId),
|
||||||
|
enabled: Boolean(materialId),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createComment = useMutation({
|
||||||
|
mutationFn: (text) => commentsApi.create(materialId, { text }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.comments(materialId) });
|
||||||
|
setCommentText("");
|
||||||
|
setNotice("Комментарий добавлен.");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const comments = useMemo(() => toList(commentsQuery.data).map(normalizeComment), [commentsQuery.data]);
|
||||||
|
|
||||||
|
const submitComment = (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const text = commentText.trim();
|
||||||
|
if (!text) return;
|
||||||
|
|
||||||
|
setNotice("");
|
||||||
|
createComment.mutate(text);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="page-shell border-t border-line py-14">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<MessageCircle className="size-6 text-primary" />
|
||||||
|
<h2 className="font-serif text-3xl">Комментарии</h2>
|
||||||
|
<Badge>{comments.length}</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form className="mt-6 rounded-lg border border-line bg-surface p-5" onSubmit={submitComment}>
|
||||||
|
<Textarea
|
||||||
|
label={user ? "Ваш комментарий" : "Комментарий гостя"}
|
||||||
|
value={commentText}
|
||||||
|
onChange={(event) => setCommentText(event.target.value)}
|
||||||
|
placeholder="Напишите, что думаете о материале..."
|
||||||
|
/>
|
||||||
|
<div className="mt-4 flex items-center gap-4">
|
||||||
|
<Button type="submit" loading={createComment.isPending} icon={<Send className="size-4" />}>
|
||||||
|
Отправить
|
||||||
|
</Button>
|
||||||
|
{notice && <span className="text-sm font-semibold text-success">{notice}</span>}
|
||||||
|
{createComment.isError && <span className="text-sm font-semibold text-danger">Не удалось отправить комментарий.</span>}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-7 grid gap-4">
|
||||||
|
{commentsQuery.isLoading ? (
|
||||||
|
[1, 2].map((item) => <Skeleton key={item} className="h-28 w-full" />)
|
||||||
|
) : commentsQuery.isError ? (
|
||||||
|
<ErrorState retry={commentsQuery.refetch} />
|
||||||
|
) : comments.length ? (
|
||||||
|
comments.map((comment) => (
|
||||||
|
<article key={comment.id} className="rounded-lg border border-line bg-surface p-5">
|
||||||
|
<div className="flex items-baseline justify-between gap-4">
|
||||||
|
<h3 className="font-semibold text-ink">{comment.author}</h3>
|
||||||
|
<span className="text-xs text-muted">{formatDate(comment.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 leading-7 text-muted">{comment.text}</p>
|
||||||
|
</article>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<EmptyState title="Комментариев пока нет" text="Оставленные через API комментарии появятся в этом блоке." />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MaterialPage() {
|
||||||
|
const { slug } = useParams();
|
||||||
|
const contentQuery = useQuery({
|
||||||
|
queryKey: queryKeys.contentDetail(slug),
|
||||||
|
queryFn: () => contentApi.get(slug),
|
||||||
|
enabled: Boolean(slug),
|
||||||
|
});
|
||||||
|
const material = normalizeContentItem(toEntity(contentQuery.data));
|
||||||
|
const materialId = material?.id ?? material?.slug;
|
||||||
|
const relatedQuery = useQuery({
|
||||||
|
queryKey: queryKeys.content({ relatedTo: materialId }),
|
||||||
|
queryFn: () => contentApi.list({ limit: 3, exclude: materialId }),
|
||||||
|
enabled: Boolean(materialId),
|
||||||
|
});
|
||||||
|
const relatedMaterials = normalizeContentList(relatedQuery.data).filter((item) => item.id !== materialId);
|
||||||
|
|
||||||
|
if (contentQuery.isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="page-shell grid gap-6 py-10">
|
||||||
|
<Skeleton className="h-80 w-full" />
|
||||||
|
<Skeleton className="h-96 w-full" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contentQuery.isError) {
|
||||||
|
return (
|
||||||
|
<div className="page-shell py-16">
|
||||||
|
<ErrorState retry={contentQuery.refetch} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!material?.id) return <NotFoundPage />;
|
||||||
|
|
||||||
|
const paragraphs = String(material.content ?? material.body ?? "").split(/\n{2,}/).filter(Boolean);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article>
|
||||||
|
<header className="border-b border-line">
|
||||||
|
<div className="mx-auto max-w-5xl px-4 py-10 sm:px-6 sm:py-16">
|
||||||
|
<div className="flex flex-wrap items-center gap-3 text-sm">
|
||||||
|
<Link className="text-muted hover:text-primary" to="/materials">
|
||||||
|
Материалы
|
||||||
|
</Link>
|
||||||
|
<span className="text-line">/</span>
|
||||||
|
<Badge tone="accent">{material.category}</Badge>
|
||||||
|
</div>
|
||||||
|
<h1 className="mt-6 max-w-4xl font-serif text-4xl leading-[1.08] tracking-[-0.025em] sm:text-6xl">
|
||||||
|
{material.title}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-6 max-w-3xl text-lg leading-8 text-muted">{material.excerpt}</p>
|
||||||
|
<div className="mt-8 flex flex-wrap items-center gap-x-6 gap-y-3 border-t border-line pt-6 text-sm text-muted">
|
||||||
|
<span className="font-semibold text-ink">{material.author}</span>
|
||||||
|
<span>{formatDate(material.publishedAt)}</span>
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<Clock3 className="size-4" /> {material.readingTime} мин
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<Eye className="size-4" /> {Number(material.views ?? 0).toLocaleString("ru-RU")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="page-shell py-8">
|
||||||
|
<ResponsiveImage src={material.image} alt="" eager className="aspect-[16/8] w-full rounded-xl object-cover" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="page-shell grid gap-10 py-8 lg:grid-cols-[minmax(0,4fr)_minmax(14rem,1fr)]">
|
||||||
|
<div className="max-w-[68ch]">
|
||||||
|
{paragraphs.map((paragraph) => (
|
||||||
|
<p key={paragraph} className="mb-6 font-serif text-xl leading-9 text-ink/90">
|
||||||
|
{paragraph}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
<div className="mt-10 flex flex-wrap gap-2 border-t border-line pt-6">
|
||||||
|
{(material.tags ?? []).map((tag) => (
|
||||||
|
<Badge key={tag}>#{tag}</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<aside className="order-first lg:order-last">
|
||||||
|
<div className="grid gap-3 lg:sticky lg:top-24">
|
||||||
|
<RatingBlock material={material} detailKey={slug} />
|
||||||
|
<div className="grid gap-2 rounded-lg border border-line bg-surface p-3">
|
||||||
|
<Button variant="secondary" icon={<Bookmark className="size-4" />}>Сохранить</Button>
|
||||||
|
<Button variant="ghost" icon={<Share2 className="size-4" />}>Поделиться</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CommentsBlock materialId={materialId} />
|
||||||
|
|
||||||
|
<section className="page-shell border-t border-line py-14">
|
||||||
|
<h2 className="font-serif text-3xl">Читайте также</h2>
|
||||||
|
<div className="mt-7 grid gap-6 md:grid-cols-3">
|
||||||
|
{relatedQuery.isLoading && [1, 2, 3].map((item) => <Skeleton key={item} className="h-72" />)}
|
||||||
|
{relatedQuery.isError && (
|
||||||
|
<div className="md:col-span-3">
|
||||||
|
<ErrorState retry={relatedQuery.refetch} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{relatedQuery.isSuccess && relatedMaterials.length > 0 && relatedMaterials.slice(0, 3).map((item) => (
|
||||||
|
<MaterialCard key={item.id} material={item} compact />
|
||||||
|
))}
|
||||||
|
{relatedQuery.isSuccess && relatedMaterials.length === 0 && (
|
||||||
|
<div className="md:col-span-3">
|
||||||
|
<EmptyState title="Похожих материалов нет" text="Backend пока не вернул дополнительные записи." />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
183
apps/web/src/pages/MaterialsPage.jsx
Normal file
183
apps/web/src/pages/MaterialsPage.jsx
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
import { Search, SlidersHorizontal, X } from "lucide-react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useSearchParams } from "react-router-dom";
|
||||||
|
import { contentApi, directoriesApi } from "../shared/api/endpoints";
|
||||||
|
import { normalizeContentList, toList } from "../shared/api/normalize";
|
||||||
|
import { queryKeys } from "../shared/api/queryKeys";
|
||||||
|
import { Button } from "../shared/ui/Button";
|
||||||
|
import { MaterialCard } from "../shared/ui/MaterialCard";
|
||||||
|
import { EmptyState, ErrorState, Skeleton } from "../shared/ui/States";
|
||||||
|
|
||||||
|
const materialTypes = [
|
||||||
|
["news", "Новость"],
|
||||||
|
["article", "Статья"],
|
||||||
|
["research", "Исследование"],
|
||||||
|
["announcement", "Объявление"],
|
||||||
|
["video", "Видео"],
|
||||||
|
];
|
||||||
|
|
||||||
|
function normalizeCategories(payload) {
|
||||||
|
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() {
|
||||||
|
const [params, setParams] = useSearchParams();
|
||||||
|
const [mobileFilters, setMobileFilters] = useState(false);
|
||||||
|
const query = params.get("q") ?? "";
|
||||||
|
const category = params.get("category") ?? "";
|
||||||
|
const type = params.get("type") ?? "";
|
||||||
|
const apiParams = { q: query, category, type };
|
||||||
|
const { data: contentPayload, isLoading, isError, refetch } = useQuery({
|
||||||
|
queryKey: queryKeys.content(apiParams),
|
||||||
|
queryFn: () => contentApi.list(apiParams),
|
||||||
|
});
|
||||||
|
const { data: categoriesPayload } = useQuery({
|
||||||
|
queryKey: queryKeys.categories,
|
||||||
|
queryFn: directoriesApi.categories,
|
||||||
|
});
|
||||||
|
const pageMaterials = normalizeContentList(contentPayload);
|
||||||
|
const pageCategories = normalizeCategories(categoriesPayload);
|
||||||
|
|
||||||
|
const filtered = useMemo(
|
||||||
|
() =>
|
||||||
|
pageMaterials.filter((material) => {
|
||||||
|
const tags = Array.isArray(material.tags) ? material.tags.join(" ") : "";
|
||||||
|
const matchesQuery = `${material.title} ${material.excerpt} ${tags}`
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(query.toLowerCase());
|
||||||
|
return matchesQuery;
|
||||||
|
}),
|
||||||
|
[pageMaterials, query],
|
||||||
|
);
|
||||||
|
|
||||||
|
const update = (key, value) => {
|
||||||
|
const next = new URLSearchParams(params);
|
||||||
|
value ? next.set(key, value) : next.delete(key);
|
||||||
|
setParams(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const filters = (
|
||||||
|
<div className="grid gap-5">
|
||||||
|
<label className="text-sm font-semibold">
|
||||||
|
Категория
|
||||||
|
<select
|
||||||
|
value={category}
|
||||||
|
onChange={(event) => update("category", event.target.value)}
|
||||||
|
className="mt-2 w-full rounded-md border border-line bg-white px-3 py-2.5 text-ink outline-none focus:border-primary dark:bg-paper dark:[color-scheme:dark]"
|
||||||
|
>
|
||||||
|
<option value="">Все категории</option>
|
||||||
|
{pageCategories.map((item) => (
|
||||||
|
<option key={item.id} value={item.value}>
|
||||||
|
{item.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="text-sm font-semibold">
|
||||||
|
Тип материала
|
||||||
|
<select
|
||||||
|
value={type}
|
||||||
|
onChange={(event) => update("type", event.target.value)}
|
||||||
|
className="mt-2 w-full rounded-md border border-line bg-white px-3 py-2.5 text-ink outline-none focus:border-primary dark:bg-paper dark:[color-scheme:dark]"
|
||||||
|
>
|
||||||
|
<option value="">Все типы</option>
|
||||||
|
{materialTypes.map(([value, label]) => (
|
||||||
|
<option key={value} value={value}>{label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<Button variant="ghost" onClick={() => setParams({})} icon={<X className="size-4" />}>
|
||||||
|
Сбросить фильтры
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-shell py-10">
|
||||||
|
<div className="border-b border-line pb-8">
|
||||||
|
<p className="text-xs font-bold uppercase tracking-widest text-accent">Архив редакции</p>
|
||||||
|
<h1 className="mt-3 font-serif text-4xl sm:text-5xl">Материалы</h1>
|
||||||
|
<p className="mt-4 max-w-2xl leading-7 text-muted">
|
||||||
|
Новости университета, исследования, истории людей и полезные объявления загружаются из backend.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
className="mt-8 flex gap-3"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const data = new FormData(event.currentTarget);
|
||||||
|
update("q", String(data.get("q") ?? ""));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label className="relative min-w-0 flex-1">
|
||||||
|
<Search className="absolute left-4 top-1/2 size-5 -translate-y-1/2 text-muted" />
|
||||||
|
<input
|
||||||
|
name="q"
|
||||||
|
defaultValue={query}
|
||||||
|
placeholder="Название, автор, тема или тег"
|
||||||
|
className="h-12 w-full rounded-md border border-line bg-surface pl-12 pr-4 outline-none focus:border-primary focus:ring-2 focus:ring-primary/15"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<Button type="submit">Найти</Button>
|
||||||
|
<Button
|
||||||
|
className="lg:hidden"
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
icon={<SlidersHorizontal className="size-4" />}
|
||||||
|
onClick={() => setMobileFilters((value) => !value)}
|
||||||
|
>
|
||||||
|
<span className="hidden sm:inline">Фильтры</span>
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{mobileFilters && <div className="mt-4 rounded-lg border border-line bg-surface p-5 lg:hidden">{filters}</div>}
|
||||||
|
|
||||||
|
<div className="mt-8 grid gap-8 lg:grid-cols-[minmax(13rem,1fr)_minmax(0,4fr)]">
|
||||||
|
<aside className="hidden lg:block">
|
||||||
|
<div className="sticky top-24 rounded-lg border border-line bg-surface p-5">
|
||||||
|
<h2 className="mb-5 font-serif text-xl">Фильтры</h2>
|
||||||
|
{filters}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<section>
|
||||||
|
<div className="mb-5 flex items-center justify-between">
|
||||||
|
<p className="text-sm text-muted">Найдено: {filtered.length}</p>
|
||||||
|
<select className="rounded-md border border-line bg-surface px-3 py-2 text-sm dark:[color-scheme:dark]">
|
||||||
|
<option>Сначала новые</option>
|
||||||
|
<option>По популярности</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{[1, 2, 3, 4, 5, 6].map((item) => (
|
||||||
|
<Skeleton key={item} className="h-80 w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : isError ? (
|
||||||
|
<ErrorState retry={refetch} />
|
||||||
|
) : filtered.length ? (
|
||||||
|
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{filtered.map((material) => (
|
||||||
|
<MaterialCard key={material.id} material={material} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<EmptyState title="Ничего не найдено" text="Попробуйте изменить запрос или убрать часть фильтров." />
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
apps/web/src/pages/MaterialsPage.test.jsx
Normal file
60
apps/web/src/pages/MaterialsPage.test.jsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { screen, waitFor } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { MemoryRouter } from "react-router-dom";
|
||||||
|
import { vi } from "vitest";
|
||||||
|
import { renderWithProviders } from "../test/renderWithProviders";
|
||||||
|
import { MaterialsPage } from "./MaterialsPage";
|
||||||
|
|
||||||
|
vi.mock("../shared/api/endpoints", () => ({
|
||||||
|
contentApi: {
|
||||||
|
list: vi.fn(async ({ q } = {}) => {
|
||||||
|
const items = [
|
||||||
|
{
|
||||||
|
id: "robot",
|
||||||
|
slug: "robot",
|
||||||
|
title: "Команда молодых инженеров представила робота-помощника",
|
||||||
|
excerpt: "Проект студентов ДГТУ",
|
||||||
|
tags: ["робот", "инженеры"],
|
||||||
|
type: "article",
|
||||||
|
category: "Наука",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "data-school",
|
||||||
|
slug: "data-school",
|
||||||
|
title: "Открыт набор в летнюю школу анализа данных",
|
||||||
|
excerpt: "Образовательная программа",
|
||||||
|
tags: ["данные"],
|
||||||
|
type: "announcement",
|
||||||
|
category: "Образование",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return q ? items.filter((item) => `${item.title} ${item.excerpt}`.toLowerCase().includes(q.toLowerCase())) : items;
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
directoriesApi: {
|
||||||
|
categories: vi.fn(async () => [{ id: "science", name: "Наука" }]),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("MaterialsPage", () => {
|
||||||
|
it("фильтрует материалы по поисковому запросу", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithProviders(
|
||||||
|
<MemoryRouter>
|
||||||
|
<MaterialsPage />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await screen.findByText("Команда молодых инженеров представила робота-помощника")).toBeInTheDocument();
|
||||||
|
|
||||||
|
const search = screen.getByPlaceholderText("Название, автор, тема или тег");
|
||||||
|
await user.type(search, "робота-помощника");
|
||||||
|
await user.click(screen.getByRole("button", { name: "Найти" }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Команда молодых инженеров представила робота-помощника")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.queryByText("Открыт набор в летнюю школу анализа данных")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
273
apps/web/src/pages/MediaChannelsPage.jsx
Normal file
273
apps/web/src/pages/MediaChannelsPage.jsx
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
import {
|
||||||
|
ArrowUpRight,
|
||||||
|
BookOpenText,
|
||||||
|
Headphones,
|
||||||
|
MessageCircle,
|
||||||
|
Music2,
|
||||||
|
Pause,
|
||||||
|
Play,
|
||||||
|
Radio,
|
||||||
|
Send,
|
||||||
|
UsersRound,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useSearchParams } from "react-router-dom";
|
||||||
|
import { directoriesApi } from "../shared/api/endpoints";
|
||||||
|
import { toList } from "../shared/api/normalize";
|
||||||
|
import { queryKeys } from "../shared/api/queryKeys";
|
||||||
|
import { Badge } from "../shared/ui/Badge";
|
||||||
|
import { Button } from "../shared/ui/Button";
|
||||||
|
import { EmptyState, ErrorState, Skeleton } from "../shared/ui/States";
|
||||||
|
import { cn } from "../shared/lib/cn";
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ id: "radio", label: "Радио", icon: Radio },
|
||||||
|
{ id: "magazines", label: "Журналы", icon: BookOpenText },
|
||||||
|
{ id: "social", label: "Соцсети", icon: UsersRound },
|
||||||
|
];
|
||||||
|
|
||||||
|
function normalizeMedia(payload) {
|
||||||
|
return toList(payload).map((item) => ({
|
||||||
|
...item,
|
||||||
|
id: item.id ?? item.slug ?? item.url ?? item.title ?? item.name,
|
||||||
|
type: String(item.type ?? item.kind ?? item.category ?? "").toLowerCase(),
|
||||||
|
title: item.title ?? item.name ?? "Медиа",
|
||||||
|
text: item.text ?? item.description ?? item.summary ?? "",
|
||||||
|
url: item.url ?? item.link ?? item.href,
|
||||||
|
handle: item.handle ?? item.username ?? item.slug,
|
||||||
|
audience: item.audience ?? item.followers ?? item.subscribers,
|
||||||
|
time: item.time ?? item.startsAt ?? item.startTime,
|
||||||
|
host: item.host ?? item.author ?? item.speaker,
|
||||||
|
issue: item.issue ?? item.number ?? item.publishedAt,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function filtered(items, aliases) {
|
||||||
|
return items.filter((item) => aliases.some((alias) => item.type.includes(alias)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function ExternalLink({ href, children }) {
|
||||||
|
if (!href) {
|
||||||
|
return (
|
||||||
|
<Button className="mt-5" variant="secondary" disabled icon={<ArrowUpRight className="size-4" />}>
|
||||||
|
{children}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
className="mt-5 inline-flex h-11 items-center justify-center gap-2 rounded-md border border-line bg-surface px-5 text-sm font-semibold text-ink transition hover:border-primary hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||||
|
href={href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
<ArrowUpRight className="size-4" />
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RadioTab({ items }) {
|
||||||
|
const [playing, setPlaying] = useState(false);
|
||||||
|
const [station, ...schedule] = items;
|
||||||
|
|
||||||
|
if (!station) {
|
||||||
|
return <EmptyState title="Радио пока не добавлено" text="Записи типа radio появятся здесь после ответа backend." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-6 lg:grid-cols-[minmax(0,3fr)_minmax(18rem,1fr)]">
|
||||||
|
<section className="overflow-hidden rounded-xl bg-primary text-white">
|
||||||
|
<div className="grid gap-8 p-6 sm:p-8 md:grid-cols-[minmax(0,3fr)_minmax(9rem,1fr)] md:items-end lg:p-10">
|
||||||
|
<div>
|
||||||
|
<Badge className="bg-white/12 text-white">{station.status ?? "Сейчас в эфире"}</Badge>
|
||||||
|
<h2 className="mt-5 font-serif text-3xl sm:text-4xl md:text-5xl">{station.title}</h2>
|
||||||
|
<p className="mt-4 max-w-xl text-sm leading-7 text-white/70 sm:text-base">{station.text}</p>
|
||||||
|
<div className="mt-7 flex flex-wrap items-center gap-3">
|
||||||
|
<Button
|
||||||
|
className="bg-white text-primary hover:bg-paper"
|
||||||
|
icon={playing ? <Pause className="size-4" /> : <Play className="size-4" />}
|
||||||
|
onClick={() => setPlaying((value) => !value)}
|
||||||
|
>
|
||||||
|
{playing ? "Пауза" : "Слушать эфир"}
|
||||||
|
</Button>
|
||||||
|
{station.audience && (
|
||||||
|
<span className="flex items-center gap-2 text-sm text-white/60">
|
||||||
|
<span className={cn("size-2 rounded-full bg-accent", playing && "animate-pulse")} />
|
||||||
|
{station.audience}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="relative mx-auto grid aspect-square w-40 place-items-center rounded-full border border-white/20 md:mx-0">
|
||||||
|
<div className={cn("absolute inset-4 rounded-full border border-white/15", playing && "animate-spin")} />
|
||||||
|
<Headphones className="size-16 text-white/85" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex h-16 items-end gap-1 border-t border-white/10 px-6 pb-4">
|
||||||
|
{Array.from({ length: 16 }, (_, index) => (
|
||||||
|
<span
|
||||||
|
key={index}
|
||||||
|
className="flex-1 rounded-t bg-white/25"
|
||||||
|
style={{ height: playing ? `${24 + ((index * 17) % 58)}%` : "18%" }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside className="rounded-xl border border-line bg-surface p-5 sm:p-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Music2 className="size-5 text-accent" />
|
||||||
|
<h2 className="font-serif text-2xl">Программа дня</h2>
|
||||||
|
</div>
|
||||||
|
{schedule.length === 0 ? (
|
||||||
|
<p className="mt-5 text-sm leading-6 text-muted">Расписание появится после добавления дополнительных записей radio.</p>
|
||||||
|
) : (
|
||||||
|
<div className="mt-5 divide-y divide-line">
|
||||||
|
{schedule.map((show) => (
|
||||||
|
<article key={show.id} className="py-4 first:pt-0">
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<span className="font-semibold text-accent">{show.time}</span>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold">{show.title}</h3>
|
||||||
|
<p className="mt-1 text-xs text-muted">{show.host}</p>
|
||||||
|
<p className="mt-2 text-sm leading-6 text-muted">{show.text}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MagazinesTab({ items }) {
|
||||||
|
if (items.length === 0) {
|
||||||
|
return <EmptyState title="Журналы пока не добавлены" text="Записи типа magazine появятся здесь после ответа backend." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-5 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{items.map((magazine, index) => (
|
||||||
|
<article key={magazine.id} className="group overflow-hidden rounded-xl border border-line bg-surface">
|
||||||
|
<div className={cn("relative flex aspect-[4/5] flex-col justify-between p-6 text-white sm:p-8", index % 2 ? "bg-accent" : "bg-primary")}>
|
||||||
|
<span className="text-xs font-bold uppercase tracking-[0.16em] text-white/60">Журнал ДГТУ Медиа</span>
|
||||||
|
<div>
|
||||||
|
<span className="font-serif text-7xl text-white/15">{String(index + 1).padStart(2, "0")}</span>
|
||||||
|
<h2 className="mt-2 font-serif text-4xl leading-tight">{magazine.title}</h2>
|
||||||
|
<p className="mt-3 text-sm text-white/60">{magazine.issue}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-5 sm:p-6">
|
||||||
|
<p className="leading-7 text-muted">{magazine.text}</p>
|
||||||
|
<ExternalLink href={magazine.url}>Открыть выпуск</ExternalLink>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SocialTab({ items }) {
|
||||||
|
if (items.length === 0) {
|
||||||
|
return <EmptyState title="Соцсети пока не добавлены" text="Записи типа social появятся здесь после ответа backend." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-5 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{items.map((channel, index) => {
|
||||||
|
const Icon = index % 2 ? Send : MessageCircle;
|
||||||
|
return (
|
||||||
|
<article key={channel.id} className="rounded-xl border border-line bg-surface p-5 sm:p-6">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="grid size-12 place-items-center rounded-lg bg-accent-soft text-accent">
|
||||||
|
<Icon className="size-6" />
|
||||||
|
</div>
|
||||||
|
{channel.audience && <Badge>{channel.audience}</Badge>}
|
||||||
|
</div>
|
||||||
|
<h2 className="mt-7 font-serif text-3xl">{channel.title}</h2>
|
||||||
|
{channel.handle && <p className="mt-1 text-sm font-semibold text-primary">{channel.handle}</p>}
|
||||||
|
<p className="mt-4 leading-7 text-muted">{channel.text}</p>
|
||||||
|
<ExternalLink href={channel.url}>Перейти</ExternalLink>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MediaChannelsPage() {
|
||||||
|
const [params, setParams] = useSearchParams();
|
||||||
|
const reduceMotion = useReducedMotion();
|
||||||
|
const mediaQuery = useQuery({
|
||||||
|
queryKey: queryKeys.media,
|
||||||
|
queryFn: directoriesApi.media,
|
||||||
|
});
|
||||||
|
const media = normalizeMedia(mediaQuery.data);
|
||||||
|
const active = tabs.some((tab) => tab.id === params.get("tab"))
|
||||||
|
? params.get("tab")
|
||||||
|
: "radio";
|
||||||
|
const radioItems = filtered(media, ["radio", "радио"]);
|
||||||
|
const magazineItems = filtered(media, ["magazine", "journal", "журнал"]);
|
||||||
|
const socialItems = filtered(media, ["social", "соц"]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-shell-wide py-9 md:py-12">
|
||||||
|
<header className="grid gap-6 border-b border-line pb-8 md:grid-cols-2 md:items-end">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-bold uppercase tracking-widest text-accent">Экосистема ДГТУ Медиа</p>
|
||||||
|
<h1 className="mt-3 font-serif text-4xl sm:text-5xl md:text-6xl">Медиаканалы</h1>
|
||||||
|
</div>
|
||||||
|
<p className="max-w-xl text-base leading-7 text-muted md:justify-self-end md:text-lg">
|
||||||
|
Радиоэфир, университетские журналы и официальные сообщества загружаются через `/api/media`.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="mt-7 flex gap-2 overflow-x-auto pb-2" role="tablist" aria-label="Медиаканалы">
|
||||||
|
{tabs.map(({ id, label, icon: Icon }) => (
|
||||||
|
<button
|
||||||
|
key={id}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={active === id}
|
||||||
|
onClick={() => setParams({ tab: id })}
|
||||||
|
className={cn(
|
||||||
|
"flex h-11 shrink-0 items-center gap-2 rounded-md border px-4 text-sm font-semibold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent",
|
||||||
|
active === id
|
||||||
|
? "border-primary bg-primary text-white"
|
||||||
|
: "border-line bg-surface text-ink hover:border-primary",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="size-4" />
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-7">
|
||||||
|
{mediaQuery.isLoading && <Skeleton className="h-96" />}
|
||||||
|
{mediaQuery.isError && <ErrorState retry={() => mediaQuery.refetch()} />}
|
||||||
|
{mediaQuery.isSuccess && (
|
||||||
|
<AnimatePresence mode="wait" initial={false}>
|
||||||
|
<motion.div
|
||||||
|
key={active}
|
||||||
|
initial={reduceMotion ? false : { opacity: 0, y: 12 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={reduceMotion ? { opacity: 1 } : { opacity: 0, y: -8 }}
|
||||||
|
transition={{ duration: reduceMotion ? 0 : 0.28, ease: [0.2, 0, 0, 1] }}
|
||||||
|
>
|
||||||
|
{active === "radio" && <RadioTab items={radioItems} />}
|
||||||
|
{active === "magazines" && <MagazinesTab items={magazineItems} />}
|
||||||
|
{active === "social" && <SocialTab items={socialItems} />}
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
53
apps/web/src/pages/NotFoundPage.jsx
Normal file
53
apps/web/src/pages/NotFoundPage.jsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { motion, useReducedMotion } from "framer-motion";
|
||||||
|
import { ArrowLeft, Home } from "lucide-react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { Button } from "../shared/ui/Button";
|
||||||
|
|
||||||
|
export function NotFoundPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const reduceMotion = useReducedMotion();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid min-h-[72vh] place-items-center px-4 py-16">
|
||||||
|
<div className="max-w-2xl text-center">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 520 260"
|
||||||
|
className="mx-auto w-full max-w-lg"
|
||||||
|
role="img"
|
||||||
|
aria-label="Потерянный документ в университетском архиве"
|
||||||
|
>
|
||||||
|
<rect x="40" y="50" width="440" height="170" rx="18" fill="#fffdf8" stroke="#d9d8d0" />
|
||||||
|
<path d="M72 92h130M72 120h90M72 178h120" stroke="#d9d8d0" strokeWidth="10" strokeLinecap="round" />
|
||||||
|
<motion.g
|
||||||
|
animate={reduceMotion ? undefined : { y: [0, -9, 0], rotate: [-1, 1, -1] }}
|
||||||
|
transition={{ duration: 3.2, repeat: Infinity, ease: "easeInOut" }}
|
||||||
|
>
|
||||||
|
<path d="M258 38h132l42 42v136H258z" fill="#f6ddd4" stroke="#123c36" strokeWidth="5" />
|
||||||
|
<path d="M390 38v44h42" fill="none" stroke="#123c36" strokeWidth="5" />
|
||||||
|
<path d="M288 112h104M288 142h74" stroke="#d65a3a" strokeWidth="9" strokeLinecap="round" />
|
||||||
|
<circle cx="389" cy="180" r="29" fill="#123c36" />
|
||||||
|
<path d="m409 201 22 22" stroke="#123c36" strokeWidth="10" strokeLinecap="round" />
|
||||||
|
<path d="M378 180h22M389 169v22" stroke="#fff" strokeWidth="5" strokeLinecap="round" />
|
||||||
|
</motion.g>
|
||||||
|
</svg>
|
||||||
|
<p className="mt-4 text-xs font-bold uppercase tracking-[0.18em] text-accent">Ошибка 404</p>
|
||||||
|
<h1 className="mt-3 font-serif text-4xl sm:text-5xl">Такой страницы нет в архиве</h1>
|
||||||
|
<p className="mx-auto mt-4 max-w-lg leading-7 text-muted">
|
||||||
|
Возможно, материал переместили, адрес изменился или ссылка устарела.
|
||||||
|
</p>
|
||||||
|
<div className="mt-7 flex flex-wrap justify-center gap-3">
|
||||||
|
<Button icon={<Home className="size-4" />} onClick={() => navigate("/")}>
|
||||||
|
На главную
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
icon={<ArrowLeft className="size-4" />}
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
>
|
||||||
|
Назад
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
187
apps/web/src/pages/cabinet/AdminPage.jsx
Normal file
187
apps/web/src/pages/cabinet/AdminPage.jsx
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
import { Activity, Eye, FileText, Users } from "lucide-react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { adminApi, contentApi } from "../../shared/api/endpoints";
|
||||||
|
import { normalizeContentList, toEntity, toList } from "../../shared/api/normalize";
|
||||||
|
import { queryKeys } from "../../shared/api/queryKeys";
|
||||||
|
import { Badge } from "../../shared/ui/Badge";
|
||||||
|
import { Button } from "../../shared/ui/Button";
|
||||||
|
import { EmptyState, ErrorState, Skeleton } from "../../shared/ui/States";
|
||||||
|
|
||||||
|
const popularParams = { limit: 4, sort: "popular" };
|
||||||
|
|
||||||
|
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 [];
|
||||||
|
return values.map((item) => (typeof item === "number" ? { value: item } : item));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminPage() {
|
||||||
|
const dashboardQuery = useQuery({
|
||||||
|
queryKey: queryKeys.adminDashboard,
|
||||||
|
queryFn: adminApi.dashboard,
|
||||||
|
});
|
||||||
|
const usersQuery = useQuery({
|
||||||
|
queryKey: queryKeys.adminUsers({ limit: 8 }),
|
||||||
|
queryFn: () => adminApi.users({ limit: 8 }),
|
||||||
|
});
|
||||||
|
const popularQuery = useQuery({
|
||||||
|
queryKey: queryKeys.content(popularParams),
|
||||||
|
queryFn: () => contentApi.list(popularParams),
|
||||||
|
});
|
||||||
|
|
||||||
|
const summary = toEntity(dashboardQuery.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);
|
||||||
|
const stats = [
|
||||||
|
[Users, "Пользователи", summary.users ?? summary.userCount],
|
||||||
|
[FileText, "Материалы", summary.materials ?? summary.contentCount],
|
||||||
|
[Eye, "Просмотры", summary.views ?? summary.viewCount],
|
||||||
|
[Activity, "На модерации", summary.pending ?? summary.moderationCount],
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-bold uppercase tracking-widest text-accent">Администрирование</p>
|
||||||
|
<h1 className="mt-2 font-serif text-4xl">Обзор системы</h1>
|
||||||
|
<div className="mt-8 grid gap-5 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
|
{stats.map(([Icon, label, value]) => {
|
||||||
|
const ItemIcon = Icon;
|
||||||
|
return (
|
||||||
|
<div key={String(label)} className="rounded-lg border border-line bg-surface p-5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<ItemIcon className="size-5 text-accent" />
|
||||||
|
<Badge tone="accent">API</Badge>
|
||||||
|
</div>
|
||||||
|
{dashboardQuery.isLoading ? (
|
||||||
|
<Skeleton className="mt-6 h-10 w-24" />
|
||||||
|
) : (
|
||||||
|
<p className="mt-6 font-serif text-4xl">{number(value)}</p>
|
||||||
|
)}
|
||||||
|
<p className="mt-1 text-sm text-muted">{String(label)}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{dashboardQuery.isError && (
|
||||||
|
<div className="mt-6">
|
||||||
|
<ErrorState retry={() => dashboardQuery.refetch()} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-6 grid gap-6 xl:grid-cols-[1.25fr_0.75fr]">
|
||||||
|
<section className="rounded-lg border border-line bg-surface p-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="font-serif text-2xl">Просмотры за неделю</h2>
|
||||||
|
<select className="rounded-md border border-line bg-white px-3 py-2 text-sm text-ink dark:bg-paper dark:[color-scheme:dark]">
|
||||||
|
<option>7 дней</option>
|
||||||
|
<option>30 дней</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{dashboardQuery.isLoading && <Skeleton className="mt-8 h-64" />}
|
||||||
|
{dashboardQuery.isSuccess && chart.length === 0 && (
|
||||||
|
<div className="mt-8">
|
||||||
|
<EmptyState title="Нет данных графика" text="Передайте массив просмотров в dashboard, чтобы заполнить диаграмму." />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{dashboardQuery.isSuccess && chart.length > 0 && (
|
||||||
|
<div className="mt-8 flex h-64 items-end gap-3 border-b border-l border-line px-3">
|
||||||
|
{chart.map((item, index) => {
|
||||||
|
const value = Number(item.value ?? item.views ?? 0);
|
||||||
|
return (
|
||||||
|
<div key={item.date ?? index} className="flex flex-1 flex-col items-center justify-end gap-2">
|
||||||
|
<div
|
||||||
|
className="w-full max-w-12 rounded-t-md bg-primary transition hover:bg-accent"
|
||||||
|
style={{ height: `${Math.max((value / maxChart) * 100, 4)}%` }}
|
||||||
|
/>
|
||||||
|
<span className="pb-2 text-xs text-muted">{item.label ?? item.day ?? index + 1}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
<section className="rounded-lg border border-line bg-surface p-6">
|
||||||
|
<h2 className="font-serif text-2xl">Популярное</h2>
|
||||||
|
<div className="mt-5">
|
||||||
|
{popularQuery.isLoading && <Skeleton className="h-56" />}
|
||||||
|
{popularQuery.isError && <ErrorState retry={() => popularQuery.refetch()} />}
|
||||||
|
{popularQuery.isSuccess && popular.length === 0 && (
|
||||||
|
<EmptyState title="Нет популярных материалов" text="Список заполнится после ответа `/api/content`." />
|
||||||
|
)}
|
||||||
|
{popularQuery.isSuccess && popular.length > 0 && (
|
||||||
|
<div className="divide-y divide-line">
|
||||||
|
{popular.map((material, index) => (
|
||||||
|
<div key={material.id} className="flex gap-3 py-4 first:pt-0">
|
||||||
|
<span className="font-serif text-2xl text-line">{String(index + 1).padStart(2, "0")}</span>
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold leading-snug">{material.title}</p>
|
||||||
|
<p className="mt-1 text-xs text-muted">{number(material.views)} просмотров</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="mt-6 overflow-hidden rounded-lg border border-line bg-surface">
|
||||||
|
<div className="flex items-center justify-between border-b border-line p-5">
|
||||||
|
<h2 className="font-serif text-2xl">Пользователи</h2>
|
||||||
|
<Button size="sm" variant="secondary">Все пользователи</Button>
|
||||||
|
</div>
|
||||||
|
{usersQuery.isLoading && <div className="p-5"><Skeleton className="h-44" /></div>}
|
||||||
|
{usersQuery.isError && <div className="p-5"><ErrorState retry={() => usersQuery.refetch()} /></div>}
|
||||||
|
{usersQuery.isSuccess && users.length === 0 && (
|
||||||
|
<div className="p-5">
|
||||||
|
<EmptyState title="Пользователи не найдены" text="Таблица заполнится данными из `/api/admin/users`." />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{usersQuery.isSuccess && users.length > 0 && (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full min-w-[42rem] text-left text-sm">
|
||||||
|
<thead className="bg-paper text-xs uppercase tracking-wider text-muted">
|
||||||
|
<tr>
|
||||||
|
<th className="px-5 py-3">Имя</th>
|
||||||
|
<th className="px-5 py-3">Почта</th>
|
||||||
|
<th className="px-5 py-3">Роль</th>
|
||||||
|
<th className="px-5 py-3">Статус</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-line">
|
||||||
|
{users.map((user) => (
|
||||||
|
<tr key={user.id ?? user.email}>
|
||||||
|
<td className="px-5 py-4 font-semibold">{user.name ?? user.fullName ?? "Без имени"}</td>
|
||||||
|
<td className="px-5 py-4 text-muted">{user.email}</td>
|
||||||
|
<td className="px-5 py-4"><Badge>{user.role ?? "user"}</Badge></td>
|
||||||
|
<td className="px-5 py-4"><Badge tone={user.active === false ? "danger" : "success"}>{user.active === false ? "Заблокирован" : "Активен"}</Badge></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
121
apps/web/src/pages/cabinet/CabinetHomePage.jsx
Normal file
121
apps/web/src/pages/cabinet/CabinetHomePage.jsx
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import { Bell, BookOpen, MessageSquareText, MoveRight } from "lucide-react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { useSession } from "../../app/store/session";
|
||||||
|
import { analyticsApi, notificationsApi } from "../../shared/api/endpoints";
|
||||||
|
import { queryKeys } from "../../shared/api/queryKeys";
|
||||||
|
import { toEntity, toList } from "../../shared/api/normalize";
|
||||||
|
import { Badge } from "../../shared/ui/Badge";
|
||||||
|
import { Button } from "../../shared/ui/Button";
|
||||||
|
import { EmptyState, ErrorState, Skeleton } from "../../shared/ui/States";
|
||||||
|
|
||||||
|
function firstName(name = "") {
|
||||||
|
return name.trim().split(/\s+/)[0] || "пользователь";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value) {
|
||||||
|
if (!value) return "";
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return value;
|
||||||
|
return date.toLocaleString("ru-RU", { day: "numeric", month: "long", hour: "2-digit", minute: "2-digit" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CabinetHomePage() {
|
||||||
|
const user = useSession((state) => state.user);
|
||||||
|
const notificationsQuery = useQuery({
|
||||||
|
queryKey: queryKeys.notifications,
|
||||||
|
queryFn: notificationsApi.list,
|
||||||
|
});
|
||||||
|
const summaryQuery = useQuery({
|
||||||
|
queryKey: queryKeys.analyticsSummary,
|
||||||
|
queryFn: analyticsApi.summary,
|
||||||
|
});
|
||||||
|
|
||||||
|
const notifications = toList(notificationsQuery.data);
|
||||||
|
const unreadCount = notifications.filter((item) => !item.read && !item.isRead).length;
|
||||||
|
const summary = toEntity(summaryQuery.data) ?? {};
|
||||||
|
const stats = [
|
||||||
|
[BookOpen, summary.savedMaterials ?? summary.materials ?? 0, "Материалов"],
|
||||||
|
[Bell, unreadCount, "Новых уведомлений"],
|
||||||
|
[MessageSquareText, summary.comments ?? summary.commentCount ?? 0, "Комментариев"],
|
||||||
|
];
|
||||||
|
const canCreate = user?.role === "editor" || user?.role === "admin";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-bold uppercase tracking-widest text-accent">Личный кабинет</p>
|
||||||
|
<h1 className="mt-2 font-serif text-4xl">Добрый день, {firstName(user?.name)}</h1>
|
||||||
|
<p className="mt-3 text-muted">Здесь собраны ваши материалы, уведомления и быстрые действия.</p>
|
||||||
|
|
||||||
|
<div className="mt-8 grid gap-5 md:grid-cols-3">
|
||||||
|
{stats.map(([Icon, value, label]) => {
|
||||||
|
const ItemIcon = Icon;
|
||||||
|
return (
|
||||||
|
<div key={String(label)} className="rounded-lg border border-line bg-surface p-5">
|
||||||
|
<ItemIcon className="size-5 text-accent" />
|
||||||
|
{summaryQuery.isLoading ? (
|
||||||
|
<Skeleton className="mt-6 h-10 w-20" />
|
||||||
|
) : (
|
||||||
|
<p className="mt-6 font-serif text-4xl">{Number(value).toLocaleString("ru-RU")}</p>
|
||||||
|
)}
|
||||||
|
<p className="mt-1 text-sm text-muted">{String(label)}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 grid gap-6 lg:grid-cols-[1.2fr_0.8fr]">
|
||||||
|
<section className="rounded-lg border border-line bg-surface p-6">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<h2 className="font-serif text-2xl">Последние уведомления</h2>
|
||||||
|
<Badge tone="accent">{unreadCount} новых</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="mt-5">
|
||||||
|
{notificationsQuery.isLoading && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Skeleton className="h-20" />
|
||||||
|
<Skeleton className="h-20" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{notificationsQuery.isError && <ErrorState retry={() => notificationsQuery.refetch()} />}
|
||||||
|
{notificationsQuery.isSuccess && notifications.length === 0 && (
|
||||||
|
<EmptyState title="Уведомлений пока нет" text="Когда backend пришлет новые события, они появятся здесь." />
|
||||||
|
)}
|
||||||
|
{notificationsQuery.isSuccess && notifications.length > 0 && (
|
||||||
|
<div className="divide-y divide-line">
|
||||||
|
{notifications.slice(0, 5).map((item) => (
|
||||||
|
<article key={item.id} className="py-4 first:pt-0">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<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.description ?? item.body}</p>
|
||||||
|
<p className="mt-2 text-xs text-muted">{formatDate(item.createdAt ?? item.date)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section className="rounded-lg bg-primary p-6 text-white">
|
||||||
|
<p className="text-xs font-bold uppercase tracking-widest text-white/50">Быстрое действие</p>
|
||||||
|
<h2 className="mt-4 font-serif text-3xl">
|
||||||
|
{canCreate ? "Подготовьте новый материал" : "Настройте свой профиль"}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-3 text-sm leading-6 text-white/65">
|
||||||
|
{canCreate
|
||||||
|
? "Создайте материал и отправьте его на обработку backend."
|
||||||
|
: "Проверьте данные профиля, которые пришли из учетной записи."}
|
||||||
|
</p>
|
||||||
|
<Link to={canCreate ? "/cabinet/editor/new" : "/cabinet/profile"}>
|
||||||
|
<Button className="mt-6 bg-white text-primary hover:bg-paper" icon={<MoveRight className="size-4" />}>
|
||||||
|
Продолжить
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
104
apps/web/src/pages/cabinet/EditorPage.jsx
Normal file
104
apps/web/src/pages/cabinet/EditorPage.jsx
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { FilePlus2, MoreHorizontal } from "lucide-react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { useSession } from "../../app/store/session";
|
||||||
|
import { contentApi } from "../../shared/api/endpoints";
|
||||||
|
import { normalizeContentList } from "../../shared/api/normalize";
|
||||||
|
import { queryKeys } from "../../shared/api/queryKeys";
|
||||||
|
import { Button } from "../../shared/ui/Button";
|
||||||
|
import { EmptyState, ErrorState, Skeleton } from "../../shared/ui/States";
|
||||||
|
import { StatusBadge } from "../../shared/ui/StatusBadge";
|
||||||
|
|
||||||
|
const statusFilters = [
|
||||||
|
["all", "Все"],
|
||||||
|
["draft", "Черновики"],
|
||||||
|
["moderation", "На модерации"],
|
||||||
|
["published", "Опубликованные"],
|
||||||
|
["returned", "Возвращенные"],
|
||||||
|
];
|
||||||
|
|
||||||
|
function formatDate(value) {
|
||||||
|
if (!value) return "Нет даты";
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return value;
|
||||||
|
return date.toLocaleString("ru-RU", { day: "numeric", month: "long", hour: "2-digit", minute: "2-digit" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditorPage() {
|
||||||
|
const user = useSession((state) => state.user);
|
||||||
|
const queryParams = { mine: true, authorId: user?.id };
|
||||||
|
const materialsQuery = useQuery({
|
||||||
|
queryKey: queryKeys.content(queryParams),
|
||||||
|
queryFn: () => contentApi.list(queryParams),
|
||||||
|
enabled: Boolean(user?.id),
|
||||||
|
});
|
||||||
|
const materials = normalizeContentList(materialsQuery.data);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex flex-wrap items-end justify-between gap-5">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-bold uppercase tracking-widest text-accent">Редакция</p>
|
||||||
|
<h1 className="mt-2 font-serif text-4xl">Мои материалы</h1>
|
||||||
|
<p className="mt-3 text-muted">Черновики, публикации и замечания модераторов загружаются из backend.</p>
|
||||||
|
</div>
|
||||||
|
<Link to="/cabinet/editor/new">
|
||||||
|
<Button icon={<FilePlus2 className="size-4" />}>Новый материал</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-7 flex gap-2 overflow-x-auto pb-2">
|
||||||
|
{statusFilters.map(([status, label], index) => (
|
||||||
|
<Button key={status} size="sm" variant={index === 0 ? "primary" : "secondary"}>
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 overflow-hidden rounded-lg border border-line bg-surface">
|
||||||
|
<div className="hidden grid-cols-[minmax(0,4fr)_minmax(8rem,1fr)_minmax(8rem,1fr)_auto] border-b border-line bg-paper px-5 py-3 text-xs font-bold uppercase tracking-wider text-muted md:grid">
|
||||||
|
<span>Материал</span>
|
||||||
|
<span>Статус</span>
|
||||||
|
<span>Обновлен</span>
|
||||||
|
<span />
|
||||||
|
</div>
|
||||||
|
{materialsQuery.isLoading && (
|
||||||
|
<div className="space-y-3 p-5">
|
||||||
|
<Skeleton className="h-24" />
|
||||||
|
<Skeleton className="h-24" />
|
||||||
|
<Skeleton className="h-24" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{materialsQuery.isError && (
|
||||||
|
<div className="p-5">
|
||||||
|
<ErrorState retry={() => materialsQuery.refetch()} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{materialsQuery.isSuccess && materials.length === 0 && (
|
||||||
|
<div className="p-5">
|
||||||
|
<EmptyState title="Материалов пока нет" text="Созданные через backend записи появятся в этом списке." />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{materialsQuery.isSuccess && materials.map((material) => (
|
||||||
|
<article
|
||||||
|
key={material.id}
|
||||||
|
className="grid gap-4 border-b border-line p-5 last:border-0 md:grid-cols-[minmax(0,4fr)_minmax(8rem,1fr)_minmax(8rem,1fr)_auto] md:items-center"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted">{material.type ?? "Материал"} · {material.category}</p>
|
||||||
|
<h2 className="mt-1 font-serif text-xl">{material.title}</h2>
|
||||||
|
{(material.moderatorComment || material.reviewComment) && (
|
||||||
|
<p className="mt-2 text-sm text-danger">{material.moderatorComment ?? material.reviewComment}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<StatusBadge status={material.status ?? "draft"} />
|
||||||
|
<span className="text-sm text-muted">{formatDate(material.updatedAt ?? material.publishedAt)}</span>
|
||||||
|
<button className="grid size-9 place-items-center rounded-md hover:bg-paper" aria-label="Действия">
|
||||||
|
<MoreHorizontal className="size-5" />
|
||||||
|
</button>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
271
apps/web/src/pages/cabinet/MaterialFormPage.jsx
Normal file
271
apps/web/src/pages/cabinet/MaterialFormPage.jsx
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { Eye, ImagePlus, Save, Send, Trash2, UploadCloud } from "lucide-react";
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { contentApi, directoriesApi } from "../../shared/api/endpoints";
|
||||||
|
import { toList } from "../../shared/api/normalize";
|
||||||
|
import { queryKeys } from "../../shared/api/queryKeys";
|
||||||
|
import { Button } from "../../shared/ui/Button";
|
||||||
|
import { Input, Select, Textarea } from "../../shared/ui/Field";
|
||||||
|
import { ErrorState, Skeleton } from "../../shared/ui/States";
|
||||||
|
import { cn } from "../../shared/lib/cn";
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
title: z.string().min(10, "Минимум 10 символов"),
|
||||||
|
excerpt: z.string().min(30, "Минимум 30 символов"),
|
||||||
|
content: z.string().min(80, "Добавьте более подробный текст"),
|
||||||
|
category: z.string().min(1, "Выберите категорию"),
|
||||||
|
type: z.string().min(1, "Выберите тип"),
|
||||||
|
tags: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const materialTypes = [
|
||||||
|
["news", "Новость"],
|
||||||
|
["article", "Статья"],
|
||||||
|
["research", "Исследование"],
|
||||||
|
["announcement", "Объявление"],
|
||||||
|
["video", "Видео"],
|
||||||
|
];
|
||||||
|
|
||||||
|
function formatFileSize(size) {
|
||||||
|
if (size < 1024 * 1024) return `${Math.round(size / 1024)} КБ`;
|
||||||
|
return `${(size / 1024 / 1024).toFixed(1)} МБ`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCategories(payload) {
|
||||||
|
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) {
|
||||||
|
const tags = data.tags
|
||||||
|
? data.tags.split(",").map((tag) => tag.trim()).filter(Boolean)
|
||||||
|
: [];
|
||||||
|
const payload = { ...data, tags, status };
|
||||||
|
|
||||||
|
if (photos.length === 0) return payload;
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
Object.entries(payload).forEach(([key, value]) => {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
value.forEach((item) => formData.append(`${key}[]`, item));
|
||||||
|
} else {
|
||||||
|
formData.append(key, value ?? "");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
photos.forEach((file) => formData.append("photos", file));
|
||||||
|
return formData;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MaterialFormPage() {
|
||||||
|
const [notice, setNotice] = useState("");
|
||||||
|
const [dragActive, setDragActive] = useState(false);
|
||||||
|
const [photos, setPhotos] = useState([]);
|
||||||
|
const [submitStatus, setSubmitStatus] = useState("moderation");
|
||||||
|
const inputRef = useRef(null);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const categoriesQuery = useQuery({
|
||||||
|
queryKey: queryKeys.categories,
|
||||||
|
queryFn: directoriesApi.categories,
|
||||||
|
});
|
||||||
|
const categories = normalizeCategories(categoriesQuery.data);
|
||||||
|
const createMaterial = useMutation({
|
||||||
|
mutationFn: ({ data, status }) => contentApi.create(makePayload(data, photos, status)),
|
||||||
|
onSuccess: (_, variables) => {
|
||||||
|
setNotice(variables.status === "draft" ? "Черновик сохранен." : "Материал отправлен на модерацию.");
|
||||||
|
navigate("/cabinet/editor");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm({
|
||||||
|
resolver: zodResolver(schema),
|
||||||
|
defaultValues: {
|
||||||
|
type: "article",
|
||||||
|
category: "",
|
||||||
|
title: "",
|
||||||
|
excerpt: "",
|
||||||
|
content: "",
|
||||||
|
tags: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const previews = useMemo(
|
||||||
|
() => photos.map((file) => ({ file, url: URL.createObjectURL(file) })),
|
||||||
|
[photos],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
previews.forEach(({ url }) => URL.revokeObjectURL(url));
|
||||||
|
},
|
||||||
|
[previews],
|
||||||
|
);
|
||||||
|
|
||||||
|
const addPhotos = (fileList) => {
|
||||||
|
const imageFiles = Array.from(fileList).filter((file) => file.type.startsWith("image/"));
|
||||||
|
setPhotos((current) => {
|
||||||
|
const known = new Set(current.map((file) => `${file.name}-${file.size}-${file.lastModified}`));
|
||||||
|
const next = imageFiles.filter((file) => !known.has(`${file.name}-${file.size}-${file.lastModified}`));
|
||||||
|
return [...current, ...next].slice(0, 8);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const removePhoto = (fileToRemove) => {
|
||||||
|
setPhotos((current) => current.filter((file) => file !== fileToRemove));
|
||||||
|
};
|
||||||
|
|
||||||
|
const submit = (data) => {
|
||||||
|
createMaterial.mutate({ data, status: submitStatus });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-5xl">
|
||||||
|
<p className="text-xs font-bold uppercase tracking-widest text-accent">Новая публикация</p>
|
||||||
|
<h1 className="mt-2 font-serif text-4xl">Создание материала</h1>
|
||||||
|
<form className="mt-8 grid gap-6 lg:grid-cols-[minmax(0,4fr)_minmax(14rem,1fr)]" onSubmit={handleSubmit(submit)}>
|
||||||
|
<div className="grid gap-5 rounded-lg border border-line bg-surface p-5 sm:p-7">
|
||||||
|
<div className="grid gap-5 sm:grid-cols-2">
|
||||||
|
<Select label="Тип материала" {...register("type")}>
|
||||||
|
{materialTypes.map(([value, label]) => (
|
||||||
|
<option key={value} value={value}>{label}</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
<div>
|
||||||
|
{categoriesQuery.isLoading ? (
|
||||||
|
<Skeleton className="h-16" />
|
||||||
|
) : categoriesQuery.isError ? (
|
||||||
|
<ErrorState retry={() => categoriesQuery.refetch()} />
|
||||||
|
) : (
|
||||||
|
<Select label="Категория" error={errors.category?.message} {...register("category")}>
|
||||||
|
<option value="">Выберите категорию</option>
|
||||||
|
{categories.map((item) => (
|
||||||
|
<option key={item.id} value={item.value}>{item.label}</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Input label="Заголовок" error={errors.title?.message} {...register("title")} />
|
||||||
|
<Textarea label="Краткое описание" error={errors.excerpt?.message} {...register("excerpt")} />
|
||||||
|
<Textarea
|
||||||
|
className="min-h-80 font-mono text-sm"
|
||||||
|
label="Содержание в Markdown"
|
||||||
|
error={errors.content?.message}
|
||||||
|
placeholder={"## Подзаголовок\n\nТекст материала..."}
|
||||||
|
{...register("content")}
|
||||||
|
/>
|
||||||
|
<Input label="Теги" placeholder="наука, кампус, студенты" {...register("tags")} />
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<p className="text-sm font-semibold text-ink">Фотографии и обложка</p>
|
||||||
|
<label
|
||||||
|
className={cn(
|
||||||
|
"mt-2 flex cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed bg-paper p-8 text-center transition",
|
||||||
|
dragActive
|
||||||
|
? "border-dstu-menu-accent bg-dstu-menu-accent/10 text-ink"
|
||||||
|
: "border-line text-muted hover:border-primary hover:text-ink",
|
||||||
|
)}
|
||||||
|
onDragEnter={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setDragActive(true);
|
||||||
|
}}
|
||||||
|
onDragOver={(event) => event.preventDefault()}
|
||||||
|
onDragLeave={(event) => {
|
||||||
|
if (!event.currentTarget.contains(event.relatedTarget)) setDragActive(false);
|
||||||
|
}}
|
||||||
|
onDrop={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setDragActive(false);
|
||||||
|
addPhotos(event.dataTransfer.files);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
className="sr-only"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
multiple
|
||||||
|
onChange={(event) => addPhotos(event.target.files ?? [])}
|
||||||
|
/>
|
||||||
|
<UploadCloud className="size-8 text-primary" />
|
||||||
|
<span className="mt-3 font-semibold text-ink">Перетащите фотографии сюда</span>
|
||||||
|
<span className="mt-1 text-sm">или нажмите, чтобы выбрать файлы</span>
|
||||||
|
<span className="mt-3 inline-flex items-center gap-2 text-xs text-muted">
|
||||||
|
<ImagePlus className="size-4" /> JPG, PNG, WEBP, до 8 файлов
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{previews.length > 0 && (
|
||||||
|
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
||||||
|
{previews.map(({ file, url }, index) => (
|
||||||
|
<div key={`${file.name}-${file.lastModified}`} className="flex gap-3 rounded-lg border border-line bg-white p-3 dark:bg-paper">
|
||||||
|
<img src={url} alt="" className="size-20 rounded-md object-cover" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm font-semibold text-ink">
|
||||||
|
{index === 0 ? "Обложка: " : ""}
|
||||||
|
{file.name}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-muted">{formatFileSize(file.size)}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="mt-3 inline-flex items-center gap-1 text-xs font-semibold text-danger hover:text-danger/80"
|
||||||
|
onClick={() => removePhoto(file)}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-3.5" /> Удалить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<aside>
|
||||||
|
<div className="sticky top-24 grid gap-3 rounded-lg border border-line bg-surface p-5">
|
||||||
|
<h2 className="font-serif text-xl">Публикация</h2>
|
||||||
|
<p className="text-sm leading-6 text-muted">
|
||||||
|
Материал будет сохранен через `/api/content`. При наличии фотографий запрос уйдет как FormData.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="secondary"
|
||||||
|
loading={createMaterial.isPending && submitStatus === "draft"}
|
||||||
|
icon={<Save className="size-4" />}
|
||||||
|
onClick={() => setSubmitStatus("draft")}
|
||||||
|
>
|
||||||
|
Сохранить черновик
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="ghost" icon={<Eye className="size-4" />}>
|
||||||
|
Предпросмотр
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
loading={createMaterial.isPending && submitStatus === "moderation"}
|
||||||
|
icon={<Send className="size-4" />}
|
||||||
|
onClick={() => setSubmitStatus("moderation")}
|
||||||
|
>
|
||||||
|
На модерацию
|
||||||
|
</Button>
|
||||||
|
{notice && <p className="text-sm font-semibold text-success">{notice}</p>}
|
||||||
|
{createMaterial.isError && <p className="text-sm font-semibold text-danger">Не удалось сохранить материал.</p>}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
138
apps/web/src/pages/cabinet/ModeratorPage.jsx
Normal file
138
apps/web/src/pages/cabinet/ModeratorPage.jsx
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import { Check, RotateCcw } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { contentApi } from "../../shared/api/endpoints";
|
||||||
|
import { normalizeContentList } from "../../shared/api/normalize";
|
||||||
|
import { queryKeys } from "../../shared/api/queryKeys";
|
||||||
|
import { Badge } from "../../shared/ui/Badge";
|
||||||
|
import { Button } from "../../shared/ui/Button";
|
||||||
|
import { Textarea } from "../../shared/ui/Field";
|
||||||
|
import { EmptyState, ErrorState, Skeleton } from "../../shared/ui/States";
|
||||||
|
|
||||||
|
const moderationParams = { status: "moderation" };
|
||||||
|
|
||||||
|
function paragraphs(content = "") {
|
||||||
|
if (Array.isArray(content)) return content;
|
||||||
|
return String(content).split(/\n{2,}/).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ModeratorPage() {
|
||||||
|
const [selectedId, setSelectedId] = useState(null);
|
||||||
|
const [message, setMessage] = useState("");
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const materialsQuery = useQuery({
|
||||||
|
queryKey: queryKeys.content(moderationParams),
|
||||||
|
queryFn: () => contentApi.list(moderationParams),
|
||||||
|
});
|
||||||
|
const materials = normalizeContentList(materialsQuery.data);
|
||||||
|
const selected = materials.find((item) => item.id === selectedId) ?? materials[0];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedId && materials[0]?.id) {
|
||||||
|
setSelectedId(materials[0].id);
|
||||||
|
}
|
||||||
|
}, [materials, selectedId]);
|
||||||
|
|
||||||
|
const reviewMutation = useMutation({
|
||||||
|
mutationFn: ({ id, payload }) => contentApi.update(id, payload),
|
||||||
|
onSuccess: () => {
|
||||||
|
setMessage("");
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.content(moderationParams) });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const approve = () => {
|
||||||
|
if (!selected?.id) return;
|
||||||
|
reviewMutation.mutate({ id: selected.id, payload: { status: "published", moderatorComment: message.trim() } });
|
||||||
|
};
|
||||||
|
|
||||||
|
const returnToAuthor = () => {
|
||||||
|
if (!selected?.id || !message.trim()) return;
|
||||||
|
reviewMutation.mutate({ id: selected.id, payload: { status: "returned", moderatorComment: message.trim() } });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-bold uppercase tracking-widest text-accent">Контроль качества</p>
|
||||||
|
<h1 className="mt-2 font-serif text-4xl">Очередь модерации</h1>
|
||||||
|
<div className="mt-8 grid gap-6 xl:grid-cols-[minmax(18rem,1fr)_minmax(0,3fr)]">
|
||||||
|
<aside className="overflow-hidden rounded-lg border border-line bg-surface">
|
||||||
|
<div className="border-b border-line p-4">
|
||||||
|
<b>Ожидают проверки</b>
|
||||||
|
<Badge className="ml-2" tone="warning">{materials.length}</Badge>
|
||||||
|
</div>
|
||||||
|
{materialsQuery.isLoading && (
|
||||||
|
<div className="space-y-3 p-4">
|
||||||
|
<Skeleton className="h-20" />
|
||||||
|
<Skeleton className="h-20" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{materialsQuery.isError && (
|
||||||
|
<div className="p-4">
|
||||||
|
<ErrorState retry={() => materialsQuery.refetch()} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{materialsQuery.isSuccess && materials.length === 0 && (
|
||||||
|
<div className="p-4">
|
||||||
|
<EmptyState title="Очередь пуста" text="Материалы со статусом модерации появятся здесь после ответа backend." />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{materials.map((material) => (
|
||||||
|
<button
|
||||||
|
key={material.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedId(material.id)}
|
||||||
|
className={`block w-full border-b border-line p-4 text-left last:border-0 ${
|
||||||
|
selected?.id === material.id ? "bg-accent-soft" : "hover:bg-paper"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="text-xs text-muted">{material.type ?? "Материал"} · {material.author}</span>
|
||||||
|
<span className="mt-2 block font-serif text-lg leading-snug">{material.title}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</aside>
|
||||||
|
<section className="rounded-lg border border-line bg-surface p-5 sm:p-7">
|
||||||
|
{!selected && materialsQuery.isSuccess && (
|
||||||
|
<EmptyState title="Материал не выбран" text="Выберите запись из очереди модерации." />
|
||||||
|
)}
|
||||||
|
{selected && (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<Badge tone="warning">На модерации</Badge>
|
||||||
|
<span className="text-sm text-muted">{selected.author}</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="mt-5 font-serif text-3xl">{selected.title}</h2>
|
||||||
|
<p className="mt-4 text-lg leading-8 text-muted">{selected.excerpt}</p>
|
||||||
|
<div className="mt-7 border-y border-line py-6">
|
||||||
|
{paragraphs(selected.content ?? selected.body).map((paragraph) => (
|
||||||
|
<p key={paragraph} className="mb-4 leading-7 last:mb-0">{paragraph}</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Textarea
|
||||||
|
className="mt-6"
|
||||||
|
label="Комментарий редактору"
|
||||||
|
value={message}
|
||||||
|
onChange={(event) => setMessage(event.target.value)}
|
||||||
|
placeholder="Обязателен при возврате на доработку"
|
||||||
|
/>
|
||||||
|
<div className="mt-6 flex flex-wrap gap-3">
|
||||||
|
<Button icon={<Check className="size-4" />} loading={reviewMutation.isPending} onClick={approve}>
|
||||||
|
Одобрить
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
icon={<RotateCcw className="size-4" />}
|
||||||
|
loading={reviewMutation.isPending}
|
||||||
|
disabled={!message.trim()}
|
||||||
|
onClick={returnToAuthor}
|
||||||
|
>
|
||||||
|
Вернуть на доработку
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
129
apps/web/src/pages/cabinet/ProfilePage.jsx
Normal file
129
apps/web/src/pages/cabinet/ProfilePage.jsx
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { Camera, Info, UploadCloud } from "lucide-react";
|
||||||
|
import { useSession } from "../../app/store/session";
|
||||||
|
import { Button } from "../../shared/ui/Button";
|
||||||
|
import { Input, Textarea } from "../../shared/ui/Field";
|
||||||
|
import { cn } from "../../shared/lib/cn";
|
||||||
|
|
||||||
|
function initials(name = "") {
|
||||||
|
return name
|
||||||
|
.split(" ")
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((part) => part[0])
|
||||||
|
.join("")
|
||||||
|
.slice(0, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readImage(file) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => resolve(reader.result);
|
||||||
|
reader.onerror = reject;
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProfilePage() {
|
||||||
|
const user = useSession((state) => state.user) ?? {};
|
||||||
|
const [dragActive, setDragActive] = useState(false);
|
||||||
|
const [avatarPreview, setAvatarPreview] = useState(user.avatarUrl ?? user.avatar ?? "");
|
||||||
|
const [avatarError, setAvatarError] = useState("");
|
||||||
|
const fileInputRef = useRef(null);
|
||||||
|
|
||||||
|
const handleAvatar = async (file) => {
|
||||||
|
setAvatarError("");
|
||||||
|
if (!file) return;
|
||||||
|
if (!file.type.startsWith("image/")) {
|
||||||
|
setAvatarError("Выберите изображение JPG, PNG или WEBP");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (file.size > 3 * 1024 * 1024) {
|
||||||
|
setAvatarError("Файл должен быть до 3 МБ");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const preview = await readImage(file);
|
||||||
|
setAvatarPreview(preview);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl">
|
||||||
|
<p className="text-xs font-bold uppercase tracking-widest text-accent">Настройки</p>
|
||||||
|
<h1 className="mt-2 font-serif text-4xl">Профиль</h1>
|
||||||
|
<div className="mt-8 rounded-lg border border-line bg-surface p-5 sm:p-7">
|
||||||
|
<div className="mb-7 flex gap-3 rounded-lg border border-warning/25 bg-warning-soft p-4 text-sm leading-6 text-warning">
|
||||||
|
<Info className="mt-0.5 size-5 shrink-0" />
|
||||||
|
<p>
|
||||||
|
Данные профиля загружаются из `/api/auth/me`. Endpoint для сохранения профиля в предоставленном API не указан,
|
||||||
|
поэтому форма не отправляет изменения в БД.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-5 border-b border-line pb-7 sm:flex-row sm:items-center">
|
||||||
|
<label
|
||||||
|
className={cn(
|
||||||
|
"group relative grid size-24 shrink-0 cursor-pointer place-items-center overflow-hidden rounded-full bg-primary font-serif text-3xl text-white ring-2 ring-transparent transition",
|
||||||
|
dragActive && "bg-dstu-menu-accent text-dstu-menu ring-dstu-menu-accent",
|
||||||
|
)}
|
||||||
|
onDragEnter={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setDragActive(true);
|
||||||
|
}}
|
||||||
|
onDragOver={(event) => event.preventDefault()}
|
||||||
|
onDragLeave={(event) => {
|
||||||
|
if (!event.currentTarget.contains(event.relatedTarget)) setDragActive(false);
|
||||||
|
}}
|
||||||
|
onDrop={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setDragActive(false);
|
||||||
|
handleAvatar(event.dataTransfer.files?.[0]);
|
||||||
|
}}
|
||||||
|
title="Предпросмотр фотографии"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
className="sr-only"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={(event) => handleAvatar(event.target.files?.[0])}
|
||||||
|
/>
|
||||||
|
{avatarPreview ? (
|
||||||
|
<img src={avatarPreview} alt="" className="size-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<span>{initials(user.name)}</span>
|
||||||
|
)}
|
||||||
|
<span className="absolute inset-0 grid place-items-center bg-ink/45 opacity-0 transition group-hover:opacity-100">
|
||||||
|
<UploadCloud className="size-6 text-white" />
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
icon={<Camera className="size-4" />}
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
Выбрать фотографию
|
||||||
|
</Button>
|
||||||
|
<p className="mt-2 text-xs text-muted">JPG, PNG или WEBP, до 3 МБ. Можно перетащить фото на аватар.</p>
|
||||||
|
{avatarError && <p className="mt-2 text-xs font-semibold text-danger">{avatarError}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-7 grid gap-5 sm:grid-cols-2">
|
||||||
|
<Input name="name" label="Имя и фамилия" defaultValue={user.name ?? ""} 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
|
||||||
|
name="bio"
|
||||||
|
className="sm:col-span-2"
|
||||||
|
label="О себе"
|
||||||
|
defaultValue={user.bio ?? ""}
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
apps/web/src/shared/api/client.js
Normal file
45
apps/web/src/shared/api/client.js
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
export const API_BASE_URL = import.meta.env.VITE_API_URL ?? "/api";
|
||||||
|
|
||||||
|
export const tokenStorage = {
|
||||||
|
get: () => sessionStorage.getItem("accessToken"),
|
||||||
|
set: (token) => {
|
||||||
|
if (token) sessionStorage.setItem("accessToken", token);
|
||||||
|
},
|
||||||
|
clear: () => sessionStorage.removeItem("accessToken"),
|
||||||
|
};
|
||||||
|
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: API_BASE_URL,
|
||||||
|
timeout: 12_000,
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
});
|
||||||
|
|
||||||
|
api.interceptors.request.use((config) => {
|
||||||
|
const token = tokenStorage.get();
|
||||||
|
if (token) config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
(error) => {
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
tokenStorage.clear();
|
||||||
|
window.dispatchEvent(new CustomEvent("auth:unauthorized"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject({
|
||||||
|
status: error.response?.status ?? 0,
|
||||||
|
message: error.response?.data?.error?.message ?? error.response?.data?.message ?? "Не удалось выполнить запрос",
|
||||||
|
fieldErrors: error.response?.data?.error?.errors ?? error.response?.data?.errors ?? error.response?.data?.fieldErrors,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export function unwrap(response) {
|
||||||
|
return response.data?.data ?? response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { api };
|
||||||
61
apps/web/src/shared/api/endpoints.js
Normal file
61
apps/web/src/shared/api/endpoints.js
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { api, unwrap } from "./client";
|
||||||
|
|
||||||
|
function cleanParams(params = {}) {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(params).filter(([, value]) => value !== undefined && value !== null && value !== ""),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const authApi = {
|
||||||
|
register: (payload) => api.post("/auth/register", payload).then(unwrap),
|
||||||
|
login: (payload) => api.post("/auth/login", payload).then(unwrap),
|
||||||
|
me: () => api.get("/auth/me").then(unwrap),
|
||||||
|
logout: () => api.post("/auth/logout").then(unwrap),
|
||||||
|
changePassword: (payload) => api.post("/auth/change-password", payload).then(unwrap),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const contentApi = {
|
||||||
|
list: (params) => api.get("/content", { params: cleanParams(params) }).then(unwrap),
|
||||||
|
get: (id) => api.get(`/content/${id}`).then(unwrap),
|
||||||
|
create: (payload) => api.post("/content", payload).then(unwrap),
|
||||||
|
update: (id, payload) => api.patch(`/content/${id}`, payload).then(unwrap),
|
||||||
|
remove: (id) => api.delete(`/content/${id}`).then(unwrap),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const directoriesApi = {
|
||||||
|
media: () => api.get("/media").then(unwrap),
|
||||||
|
events: (params) => api.get("/events", { params: cleanParams(params) }).then(unwrap),
|
||||||
|
categories: () => api.get("/categories").then(unwrap),
|
||||||
|
tags: () => api.get("/tags").then(unwrap),
|
||||||
|
speakers: () => api.get("/speakers").then(unwrap),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const searchApi = {
|
||||||
|
search: (q, params) => api.get("/search", { params: cleanParams({ ...params, q }) }).then(unwrap),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const subscriptionsApi = {
|
||||||
|
list: () => api.get("/subscriptions").then(unwrap),
|
||||||
|
create: (payload) => api.post("/subscriptions", payload).then(unwrap),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const notificationsApi = {
|
||||||
|
list: () => api.get("/notifications").then(unwrap),
|
||||||
|
markRead: (id) => api.patch(`/notifications/${id}/read`).then(unwrap),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const commentsApi = {
|
||||||
|
list: (contentId) => api.get(`/comments/${contentId}`).then(unwrap),
|
||||||
|
create: (contentId, payload) => api.post(`/comments/${contentId}`, payload).then(unwrap),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const analyticsApi = {
|
||||||
|
summary: () => api.get("/analytics/summary").then(unwrap),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const adminApi = {
|
||||||
|
dashboard: () => api.get("/admin/dashboard").then(unwrap),
|
||||||
|
users: (params) => api.get("/admin/users", { params: cleanParams(params) }).then(unwrap),
|
||||||
|
roles: () => api.get("/admin/roles").then(unwrap),
|
||||||
|
audit: (params) => api.get("/admin/audit", { params: cleanParams(params) }).then(unwrap),
|
||||||
|
};
|
||||||
33
apps/web/src/shared/api/normalize.js
Normal file
33
apps/web/src/shared/api/normalize.js
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
export function toList(payload) {
|
||||||
|
if (Array.isArray(payload)) return payload;
|
||||||
|
if (Array.isArray(payload?.items)) return payload.items;
|
||||||
|
if (Array.isArray(payload?.results)) return payload.results;
|
||||||
|
if (Array.isArray(payload?.content)) return payload.content;
|
||||||
|
if (Array.isArray(payload?.data)) return payload.data;
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toEntity(payload) {
|
||||||
|
return payload?.item ?? payload?.content ?? payload?.data ?? payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeContentItem(item) {
|
||||||
|
if (!item) return item;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
slug: item.slug ?? item.id,
|
||||||
|
excerpt: item.excerpt ?? item.summary ?? item.description ?? "",
|
||||||
|
image: item.image ?? item.coverUrl ?? item.cover ?? item.media?.[0]?.url,
|
||||||
|
publishedAt: item.publishedAt ?? item.createdAt ?? item.date ?? "",
|
||||||
|
readingTime: item.readingTime ?? item.readingMinutes ?? 1,
|
||||||
|
views: item.views ?? item.viewCount ?? 0,
|
||||||
|
tags: item.tags ?? [],
|
||||||
|
category: item.category?.name ?? item.category ?? "",
|
||||||
|
author: item.author?.name ?? item.author ?? item.user?.name ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeContentList(payload) {
|
||||||
|
return toList(payload).map(normalizeContentItem);
|
||||||
|
}
|
||||||
19
apps/web/src/shared/api/queryKeys.js
Normal file
19
apps/web/src/shared/api/queryKeys.js
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
export const queryKeys = {
|
||||||
|
me: ["auth", "me"],
|
||||||
|
content: (params = {}) => ["content", params],
|
||||||
|
contentDetail: (id) => ["content", id],
|
||||||
|
media: ["media"],
|
||||||
|
events: (params = {}) => ["events", params],
|
||||||
|
categories: ["categories"],
|
||||||
|
tags: ["tags"],
|
||||||
|
speakers: ["speakers"],
|
||||||
|
search: (q, params = {}) => ["search", q, params],
|
||||||
|
subscriptions: ["subscriptions"],
|
||||||
|
notifications: ["notifications"],
|
||||||
|
comments: (contentId) => ["comments", contentId],
|
||||||
|
analyticsSummary: ["analytics", "summary"],
|
||||||
|
adminDashboard: ["admin", "dashboard"],
|
||||||
|
adminUsers: (params = {}) => ["admin", "users", params],
|
||||||
|
adminRoles: ["admin", "roles"],
|
||||||
|
adminAudit: (params = {}) => ["admin", "audit", params],
|
||||||
|
};
|
||||||
6
apps/web/src/shared/lib/cn.js
Normal file
6
apps/web/src/shared/lib/cn.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function cn(...inputs) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
25
apps/web/src/shared/ui/Badge.jsx
Normal file
25
apps/web/src/shared/ui/Badge.jsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { cn } from "../lib/cn";
|
||||||
|
|
||||||
|
export function Badge({
|
||||||
|
children,
|
||||||
|
tone = "neutral",
|
||||||
|
className,
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center rounded-full px-2.5 py-1 text-xs font-semibold",
|
||||||
|
{
|
||||||
|
"bg-paper text-muted": tone === "neutral",
|
||||||
|
"bg-accent-soft text-accent-dark": tone === "accent",
|
||||||
|
"bg-success-soft text-success": tone === "success",
|
||||||
|
"bg-warning-soft text-warning": tone === "warning",
|
||||||
|
"bg-danger-soft text-danger": tone === "danger",
|
||||||
|
},
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
apps/web/src/shared/ui/Button.jsx
Normal file
38
apps/web/src/shared/ui/Button.jsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { LoaderCircle } from "lucide-react";
|
||||||
|
import { cn } from "../lib/cn";
|
||||||
|
|
||||||
|
export function Button({
|
||||||
|
className,
|
||||||
|
variant = "primary",
|
||||||
|
size = "md",
|
||||||
|
loading,
|
||||||
|
icon,
|
||||||
|
children,
|
||||||
|
disabled,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center justify-center gap-2 rounded-md font-semibold transition duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-55",
|
||||||
|
{
|
||||||
|
"bg-primary text-white hover:bg-primary-strong": variant === "primary",
|
||||||
|
"bg-accent text-white hover:bg-accent-dark": variant === "accent",
|
||||||
|
"border border-line bg-surface text-ink hover:border-primary hover:text-primary":
|
||||||
|
variant === "secondary",
|
||||||
|
"text-ink hover:bg-paper": variant === "ghost",
|
||||||
|
"bg-danger text-white hover:bg-danger/90": variant === "danger",
|
||||||
|
"h-9 px-3 text-sm": size === "sm",
|
||||||
|
"h-11 px-5 text-sm": size === "md",
|
||||||
|
"h-13 px-6 text-base": size === "lg",
|
||||||
|
},
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
disabled={disabled || loading}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{loading ? <LoaderCircle className="size-4 animate-spin" /> : icon}
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
apps/web/src/shared/ui/DstuLogo.jsx
Normal file
20
apps/web/src/shared/ui/DstuLogo.jsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import dstuLogo from "../../../IMG_4963.png";
|
||||||
|
import { cn } from "../lib/cn";
|
||||||
|
|
||||||
|
export function DstuLogo({ className, imageClassName }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"grid shrink-0 place-items-center overflow-hidden rounded-md bg-white shadow-sm ring-1 ring-black/10",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={dstuLogo}
|
||||||
|
alt=""
|
||||||
|
className={cn("size-full object-cover object-center", imageClassName)}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
54
apps/web/src/shared/ui/Field.jsx
Normal file
54
apps/web/src/shared/ui/Field.jsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { cn } from "../lib/cn";
|
||||||
|
|
||||||
|
const control =
|
||||||
|
"mt-2 w-full rounded-md border border-line bg-white px-3.5 py-2.5 text-sm text-ink outline-none transition placeholder:text-muted/70 focus:border-primary focus:ring-2 focus:ring-primary/15 aria-invalid:border-danger aria-invalid:ring-danger/15 dark:bg-paper dark:[color-scheme:dark]";
|
||||||
|
|
||||||
|
export function Input({
|
||||||
|
label,
|
||||||
|
error,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<label className="block text-sm font-semibold text-ink">
|
||||||
|
{label}
|
||||||
|
<input className={cn(control, className)} aria-invalid={!!error} {...props} />
|
||||||
|
{error && <span className="mt-1.5 block text-xs text-danger">{error}</span>}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Textarea({
|
||||||
|
label,
|
||||||
|
error,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<label className="block text-sm font-semibold text-ink">
|
||||||
|
{label}
|
||||||
|
<textarea
|
||||||
|
className={cn(control, "min-h-28 resize-y", className)}
|
||||||
|
aria-invalid={!!error}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
{error && <span className="mt-1.5 block text-xs text-danger">{error}</span>}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Select({
|
||||||
|
label,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<label className="block text-sm font-semibold text-ink">
|
||||||
|
{label}
|
||||||
|
<select className={cn(control, className)} {...props}>
|
||||||
|
{children}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
56
apps/web/src/shared/ui/MaterialCard.jsx
Normal file
56
apps/web/src/shared/ui/MaterialCard.jsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { ArrowUpRight, Clock3, Eye } from "lucide-react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { Badge } from "./Badge";
|
||||||
|
import { ResponsiveImage } from "./ResponsiveImage";
|
||||||
|
|
||||||
|
export function MaterialCard({
|
||||||
|
material,
|
||||||
|
compact = false,
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<article className="group overflow-hidden rounded-lg border border-line bg-surface transition duration-200 hover:-translate-y-0.5 hover:border-primary/40 hover:shadow-soft">
|
||||||
|
{!compact && (
|
||||||
|
<Link to={`/materials/${material.slug}`} viewTransition>
|
||||||
|
<ResponsiveImage
|
||||||
|
src={material.image}
|
||||||
|
alt=""
|
||||||
|
className="aspect-[16/10] w-full object-cover transition duration-300 group-hover:scale-[1.02]"
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
<div className={compact ? "p-4" : "p-5"}>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<Badge tone={material.type === "Исследование" ? "accent" : "neutral"}>
|
||||||
|
{material.type}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-xs text-muted">{material.category}</span>
|
||||||
|
</div>
|
||||||
|
<h3
|
||||||
|
className={`mt-4 font-serif leading-tight text-ink ${
|
||||||
|
compact ? "text-xl" : "text-2xl"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
className="transition hover:text-primary"
|
||||||
|
to={`/materials/${material.slug}`}
|
||||||
|
viewTransition
|
||||||
|
>
|
||||||
|
{material.title}
|
||||||
|
</Link>
|
||||||
|
</h3>
|
||||||
|
{!compact && <p className="mt-3 line-clamp-3 text-sm leading-6 text-muted">{material.excerpt}</p>}
|
||||||
|
<div className="mt-5 flex items-center justify-between gap-4 border-t border-line pt-4 text-xs text-muted">
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<Clock3 className="size-3.5" />
|
||||||
|
{material.readingTime} мин
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<Eye className="size-3.5" />
|
||||||
|
{material.views.toLocaleString("ru-RU")}
|
||||||
|
</span>
|
||||||
|
<ArrowUpRight className="size-4 text-primary transition group-hover:translate-x-0.5 group-hover:-translate-y-0.5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
49
apps/web/src/shared/ui/PageTransition.jsx
Normal file
49
apps/web/src/shared/ui/PageTransition.jsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { motion, useReducedMotion } from "framer-motion";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
export function PageTransition({ children, routeKey, className = "" }) {
|
||||||
|
const reduceMotion = useReducedMotion();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.scrollTo({ top: 0, behavior: "instant" });
|
||||||
|
}, [routeKey]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key={routeKey}
|
||||||
|
className={className}
|
||||||
|
initial={
|
||||||
|
reduceMotion
|
||||||
|
? false
|
||||||
|
: {
|
||||||
|
opacity: 0,
|
||||||
|
y: 22,
|
||||||
|
scale: 0.992,
|
||||||
|
filter: "blur(0.3125rem)",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
animate={{
|
||||||
|
opacity: 1,
|
||||||
|
y: 0,
|
||||||
|
scale: 1,
|
||||||
|
filter: "blur(0)",
|
||||||
|
}}
|
||||||
|
exit={
|
||||||
|
reduceMotion
|
||||||
|
? { opacity: 1 }
|
||||||
|
: {
|
||||||
|
opacity: 0,
|
||||||
|
y: -12,
|
||||||
|
scale: 0.996,
|
||||||
|
filter: "blur(0.1875rem)",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
transition={{
|
||||||
|
duration: reduceMotion ? 0 : 0.4,
|
||||||
|
ease: [0.2, 0, 0, 1],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
apps/web/src/shared/ui/ResponsiveImage.jsx
Normal file
35
apps/web/src/shared/ui/ResponsiveImage.jsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
export function ResponsiveImage({
|
||||||
|
src,
|
||||||
|
alt,
|
||||||
|
className,
|
||||||
|
eager = false,
|
||||||
|
}) {
|
||||||
|
if (!src) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="img"
|
||||||
|
aria-label={alt}
|
||||||
|
className={`grid place-items-center bg-paper text-sm text-muted ${className ?? ""}`}
|
||||||
|
>
|
||||||
|
Нет изображения
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sized = (width) => (src.includes("w=") ? src.replace(/w=\d+/, `w=${width}`) : src);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={sized(900)}
|
||||||
|
srcSet={`${sized(480)} 480w, ${sized(900)} 900w, ${sized(1400)} 1400w`}
|
||||||
|
sizes={eager ? "(max-width: 48rem) 100vw, 60vw" : "auto, (max-width: 48rem) 100vw, 40vw"}
|
||||||
|
loading={eager ? "eager" : "lazy"}
|
||||||
|
fetchPriority={eager ? "high" : "auto"}
|
||||||
|
decoding="async"
|
||||||
|
width="1400"
|
||||||
|
height="900"
|
||||||
|
alt={alt}
|
||||||
|
className={className}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
36
apps/web/src/shared/ui/States.jsx
Normal file
36
apps/web/src/shared/ui/States.jsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { AlertTriangle, Inbox, RefreshCw } from "lucide-react";
|
||||||
|
import { Button } from "./Button";
|
||||||
|
|
||||||
|
export function Skeleton({ className = "" }) {
|
||||||
|
return <div className={`animate-pulse rounded-md bg-line/60 ${className}`} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmptyState({ title, text }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-dashed border-line bg-surface p-10 text-center">
|
||||||
|
<Inbox className="mx-auto mb-4 size-8 text-muted" />
|
||||||
|
<h3 className="font-serif text-xl">{title}</h3>
|
||||||
|
<p className="mx-auto mt-2 max-w-md text-sm text-muted">{text}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ErrorState({ retry }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-danger/25 bg-danger-soft p-8 text-center">
|
||||||
|
<AlertTriangle className="mx-auto mb-3 size-7 text-danger" />
|
||||||
|
<h3 className="font-semibold">Не удалось загрузить данные</h3>
|
||||||
|
<p className="mt-1 text-sm text-muted">Проверьте соединение и попробуйте еще раз.</p>
|
||||||
|
{retry && (
|
||||||
|
<Button
|
||||||
|
className="mt-4"
|
||||||
|
variant="secondary"
|
||||||
|
icon={<RefreshCw className="size-4" />}
|
||||||
|
onClick={retry}
|
||||||
|
>
|
||||||
|
Повторить
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
36
apps/web/src/shared/ui/StatusBadge.jsx
Normal file
36
apps/web/src/shared/ui/StatusBadge.jsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { Badge } from "./Badge";
|
||||||
|
|
||||||
|
const labels = {
|
||||||
|
draft: "Черновик",
|
||||||
|
moderation: "На модерации",
|
||||||
|
pending: "На модерации",
|
||||||
|
approved: "Одобрен",
|
||||||
|
published: "Опубликован",
|
||||||
|
returned: "Возвращен",
|
||||||
|
archived: "В архиве",
|
||||||
|
Опубликовано: "Опубликовано",
|
||||||
|
Возвращен: "Возвращен",
|
||||||
|
Архив: "В архиве",
|
||||||
|
};
|
||||||
|
|
||||||
|
const tones = {
|
||||||
|
draft: "neutral",
|
||||||
|
moderation: "warning",
|
||||||
|
pending: "warning",
|
||||||
|
approved: "success",
|
||||||
|
published: "success",
|
||||||
|
returned: "danger",
|
||||||
|
archived: "neutral",
|
||||||
|
Черновик: "neutral",
|
||||||
|
"На модерации": "warning",
|
||||||
|
Одобрен: "success",
|
||||||
|
Опубликован: "success",
|
||||||
|
Опубликовано: "success",
|
||||||
|
Возвращен: "danger",
|
||||||
|
Архив: "neutral",
|
||||||
|
"В архиве": "neutral",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function StatusBadge({ status = "draft" }) {
|
||||||
|
return <Badge tone={tones[status]}>{labels[status] ?? status}</Badge>;
|
||||||
|
}
|
||||||
54
apps/web/src/shared/ui/ThemeToggle.jsx
Normal file
54
apps/web/src/shared/ui/ThemeToggle.jsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { Moon, Sun } from "lucide-react";
|
||||||
|
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
function getInitialTheme() {
|
||||||
|
if (typeof document === "undefined") return false;
|
||||||
|
return document.documentElement.classList.contains("dark");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ThemeToggle({ className = "" }) {
|
||||||
|
const [dark, setDark] = useState(getInitialTheme);
|
||||||
|
const reduceMotion = useReducedMotion();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.classList.toggle("dark", dark);
|
||||||
|
localStorage.setItem("theme", dark ? "dark" : "light");
|
||||||
|
|
||||||
|
const metaTheme = document.querySelector('meta[name="theme-color"]');
|
||||||
|
metaTheme?.setAttribute("content", dark ? "#101614" : "#f5f2ea");
|
||||||
|
}, [dark]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.button
|
||||||
|
type="button"
|
||||||
|
className={`relative grid size-10 place-items-center overflow-hidden rounded-md text-muted transition hover:bg-surface hover:text-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent dark:hover:bg-surface ${className}`}
|
||||||
|
onClick={() => setDark((value) => !value)}
|
||||||
|
aria-label={dark ? "Включить светлую тему" : "Включить темную тему"}
|
||||||
|
title={dark ? "Светлая тема" : "Темная тема"}
|
||||||
|
whileTap={reduceMotion ? undefined : { scale: 0.92 }}
|
||||||
|
>
|
||||||
|
<motion.span
|
||||||
|
className="absolute inset-1 rounded-full bg-accent-soft"
|
||||||
|
initial={false}
|
||||||
|
animate={{
|
||||||
|
scale: dark ? 1 : 0,
|
||||||
|
opacity: dark ? 1 : 0,
|
||||||
|
}}
|
||||||
|
transition={{ duration: reduceMotion ? 0 : 0.2 }}
|
||||||
|
/>
|
||||||
|
<AnimatePresence initial={false} mode="wait">
|
||||||
|
<motion.span
|
||||||
|
key={dark ? "sun" : "moon"}
|
||||||
|
className="relative z-10"
|
||||||
|
initial={reduceMotion ? false : { opacity: 0, rotate: -35, scale: 0.65 }}
|
||||||
|
animate={{ opacity: 1, rotate: 0, scale: 1 }}
|
||||||
|
exit={reduceMotion ? undefined : { opacity: 0, rotate: 35, scale: 0.65 }}
|
||||||
|
transition={{ duration: reduceMotion ? 0 : 0.18 }}
|
||||||
|
>
|
||||||
|
{dark ? <Sun className="size-5 text-accent" /> : <Moon className="size-5" />}
|
||||||
|
</motion.span>
|
||||||
|
</AnimatePresence>
|
||||||
|
</motion.button>
|
||||||
|
);
|
||||||
|
}
|
||||||
143
apps/web/src/styles.css
Normal file
143
apps/web/src/styles.css
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@custom-variant dark (&:where(.dark, .dark *));
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--color-primary: #123c36;
|
||||||
|
--color-primary-strong: #082b27;
|
||||||
|
--color-dstu-menu: #2d323e;
|
||||||
|
--color-dstu-menu-accent: #1ee2e7;
|
||||||
|
--color-dstu-menu-line: #e67e22;
|
||||||
|
--color-accent: #d65a3a;
|
||||||
|
--color-accent-dark: #a84028;
|
||||||
|
--color-accent-soft: #f6ddd4;
|
||||||
|
--color-paper: #f5f2ea;
|
||||||
|
--color-surface: #fffdf8;
|
||||||
|
--color-ink: #17201e;
|
||||||
|
--color-muted: #66706d;
|
||||||
|
--color-line: #d9d8d0;
|
||||||
|
--color-success: #247a5a;
|
||||||
|
--color-success-soft: #dceee7;
|
||||||
|
--color-warning: #a96616;
|
||||||
|
--color-warning-soft: #f5e8d0;
|
||||||
|
--color-danger: #b43d3d;
|
||||||
|
--color-danger-soft: #f6dddd;
|
||||||
|
--font-sans: Inter, Arial, sans-serif;
|
||||||
|
--font-serif: Georgia, Cambria, serif;
|
||||||
|
--radius-md: 0.375rem;
|
||||||
|
--radius-lg: 0.75rem;
|
||||||
|
--radius-xl: 1.25rem;
|
||||||
|
--shadow-soft: 0 1.125rem 3.75rem rgb(18 60 54 / 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root.dark {
|
||||||
|
--color-primary: #28685e;
|
||||||
|
--color-primary-strong: #071f1c;
|
||||||
|
--color-accent: #ef7858;
|
||||||
|
--color-accent-dark: #ff987d;
|
||||||
|
--color-accent-soft: #47251f;
|
||||||
|
--color-paper: #101614;
|
||||||
|
--color-surface: #18201e;
|
||||||
|
--color-ink: #edf2ef;
|
||||||
|
--color-muted: #a4afab;
|
||||||
|
--color-line: #34403c;
|
||||||
|
--color-success: #6fc29f;
|
||||||
|
--color-success-soft: #19372c;
|
||||||
|
--color-warning: #e9ad5c;
|
||||||
|
--color-warning-soft: #3c2c17;
|
||||||
|
--color-danger: #f08282;
|
||||||
|
--color-danger-soft: #422222;
|
||||||
|
--shadow-soft: 0 1.125rem 3.75rem rgb(0 0 0 / 0.28);
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
color: var(--color-ink);
|
||||||
|
background: var(--color-paper);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-synthesis: none;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-width: 20rem;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--color-paper);
|
||||||
|
transition:
|
||||||
|
color 180ms ease,
|
||||||
|
background-color 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
body,
|
||||||
|
header,
|
||||||
|
main,
|
||||||
|
footer,
|
||||||
|
aside,
|
||||||
|
section,
|
||||||
|
article,
|
||||||
|
div,
|
||||||
|
input,
|
||||||
|
textarea,
|
||||||
|
select,
|
||||||
|
button {
|
||||||
|
transition-property: color, background-color, border-color;
|
||||||
|
transition-duration: 180ms;
|
||||||
|
transition-timing-function: cubic-bezier(0.2, 0, 0, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
textarea,
|
||||||
|
select {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-shell {
|
||||||
|
width: min(100% - clamp(2rem, 6vw, 6rem), 80rem);
|
||||||
|
margin-inline: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-shell-wide {
|
||||||
|
width: min(100% - clamp(2rem, 5vw, 5rem), 90rem);
|
||||||
|
margin-inline: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
color: white;
|
||||||
|
background: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
@view-transition {
|
||||||
|
navigation: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
::view-transition-old(root),
|
||||||
|
::view-transition-new(root) {
|
||||||
|
animation-duration: 180ms;
|
||||||
|
animation-timing-function: cubic-bezier(0.2, 0, 0, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
15
apps/web/src/test/renderWithProviders.jsx
Normal file
15
apps/web/src/test/renderWithProviders.jsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { render } from "@testing-library/react";
|
||||||
|
|
||||||
|
export function renderWithProviders(ui) {
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
retry: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);
|
||||||
|
}
|
||||||
15
apps/web/src/test/setup.js
Normal file
15
apps/web/src/test/setup.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
|
||||||
|
Object.defineProperty(window, "matchMedia", {
|
||||||
|
writable: true,
|
||||||
|
value: (query) => ({
|
||||||
|
matches: false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addListener: () => {},
|
||||||
|
removeListener: () => {},
|
||||||
|
addEventListener: () => {},
|
||||||
|
removeEventListener: () => {},
|
||||||
|
dispatchEvent: () => false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
export type ContentType = 'news' | 'article' | 'video' | 'audio' | 'graphic' | 'event';
|
|
||||||
|
|
||||||
export type MediaKind = 'image' | 'video' | 'audio' | 'document' | 'other';
|
|
||||||
|
|
||||||
export type ContentStatus = 'Черновик' | 'На модерации' | 'На проверке' | 'Опубликовано' | 'Архив';
|
|
||||||
|
|
||||||
export type Visibility = 'Публично' | 'После входа' | 'По роли';
|
|
||||||
|
|
||||||
export type RoleCode = 'администратор' | 'редактор' | 'менеджер' | 'пользователь';
|
|
||||||
|
|
||||||
export type ContentItem = {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
lead: string;
|
|
||||||
body: string;
|
|
||||||
type: ContentType;
|
|
||||||
category: string;
|
|
||||||
tags: string[];
|
|
||||||
author: string;
|
|
||||||
publishedAt: string;
|
|
||||||
duration?: string;
|
|
||||||
visibility: Visibility;
|
|
||||||
status: ContentStatus;
|
|
||||||
views: number;
|
|
||||||
imageTone: string;
|
|
||||||
mediaUrl?: string;
|
|
||||||
mediaKind?: MediaKind;
|
|
||||||
mimeType?: string;
|
|
||||||
fileName?: string;
|
|
||||||
fileSize?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type Speaker = {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
role: string;
|
|
||||||
topics: string[];
|
|
||||||
materials: number;
|
|
||||||
subscribers: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type UserProfile = {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
login: string;
|
|
||||||
roles: RoleCode[];
|
|
||||||
subscriptions: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type NotificationItem = {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
read: boolean;
|
|
||||||
createdAt: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type CommentItem = {
|
|
||||||
id: string;
|
|
||||||
author: string;
|
|
||||||
text: string;
|
|
||||||
createdAt: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type AuditItem = {
|
|
||||||
id: string;
|
|
||||||
actor: string;
|
|
||||||
action: string;
|
|
||||||
target: string;
|
|
||||||
createdAt: string;
|
|
||||||
};
|
|
||||||
1
apps/web/src/vite-env.d.ts
vendored
1
apps/web/src/vite-env.d.ts
vendored
@@ -1 +0,0 @@
|
|||||||
/// <reference types="vite/client" />
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
|
||||||
module.exports = {
|
|
||||||
darkMode: 'class',
|
|
||||||
content: ['./index.html', './src/**/*.{ts,tsx}'],
|
|
||||||
theme: {
|
|
||||||
extend: {
|
|
||||||
colors: {
|
|
||||||
university: {
|
|
||||||
50: '#eef7ff',
|
|
||||||
100: '#d9edff',
|
|
||||||
200: '#bce0ff',
|
|
||||||
300: '#8ecaff',
|
|
||||||
400: '#59a9ff',
|
|
||||||
500: '#3388f3',
|
|
||||||
600: '#1d6fd8',
|
|
||||||
700: '#1558b0',
|
|
||||||
800: '#11519c',
|
|
||||||
900: '#123f73'
|
|
||||||
},
|
|
||||||
ink: '#071426'
|
|
||||||
},
|
|
||||||
fontFamily: {
|
|
||||||
sans: [
|
|
||||||
'Golos Text',
|
|
||||||
'Inter',
|
|
||||||
'system-ui',
|
|
||||||
'-apple-system',
|
|
||||||
'BlinkMacSystemFont',
|
|
||||||
'Segoe UI',
|
|
||||||
'sans-serif'
|
|
||||||
]
|
|
||||||
},
|
|
||||||
boxShadow: {
|
|
||||||
soft: '0 24px 80px rgba(7, 20, 38, 0.12)',
|
|
||||||
card: '0 14px 40px rgba(17, 81, 156, 0.12)'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
plugins: []
|
|
||||||
};
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ES2022",
|
|
||||||
"useDefineForClassFields": true,
|
|
||||||
"lib": ["DOM", "DOM.Iterable", "ES2022"],
|
|
||||||
"allowJs": false,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"allowSyntheticDefaultImports": true,
|
|
||||||
"strict": true,
|
|
||||||
"forceConsistentCasingInFileNames": true,
|
|
||||||
"module": "ESNext",
|
|
||||||
"moduleResolution": "Node",
|
|
||||||
"resolveJsonModule": true,
|
|
||||||
"isolatedModules": true,
|
|
||||||
"noEmit": true,
|
|
||||||
"jsx": "react-jsx"
|
|
||||||
},
|
|
||||||
"include": ["src"],
|
|
||||||
"references": []
|
|
||||||
}
|
|
||||||
26
apps/web/vite.config.js
Normal file
26
apps/web/vite.config.js
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
|
||||||
|
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,
|
||||||
|
environment: "jsdom",
|
||||||
|
setupFiles: "./src/test/setup.js",
|
||||||
|
css: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import { defineConfig } from 'vite';
|
|
||||||
import react from '@vitejs/plugin-react';
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [react()],
|
|
||||||
server: {
|
|
||||||
port: 5173,
|
|
||||||
proxy: {
|
|
||||||
'/api': {
|
|
||||||
target: process.env.VITE_GATEWAY_URL ?? 'http://localhost:3000',
|
|
||||||
changeOrigin: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -2,13 +2,13 @@ CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
|||||||
|
|
||||||
DO $$
|
DO $$
|
||||||
BEGIN
|
BEGIN
|
||||||
CREATE TYPE content_type AS ENUM ('news', 'article', 'video', 'audio', 'graphic', 'event_announcement');
|
CREATE TYPE content_type AS ENUM ('news', 'article', 'video', 'audio', 'graphic', 'event');
|
||||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||||
END $$;
|
END $$;
|
||||||
|
|
||||||
DO $$
|
DO $$
|
||||||
BEGIN
|
BEGIN
|
||||||
CREATE TYPE content_status AS ENUM ('draft', 'moderation', 'review', 'published', 'archived');
|
CREATE TYPE content_status AS ENUM ('draft', 'moderation', 'review', 'published', 'returned', 'archived');
|
||||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||||
END $$;
|
END $$;
|
||||||
|
|
||||||
@@ -76,6 +76,9 @@ CREATE TABLE IF NOT EXISTS speakers (
|
|||||||
display_name TEXT NOT NULL,
|
display_name TEXT NOT NULL,
|
||||||
role_description TEXT,
|
role_description TEXT,
|
||||||
biography TEXT,
|
biography TEXT,
|
||||||
|
topics_csv TEXT NOT NULL DEFAULT '',
|
||||||
|
materials_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
subscribers_count INTEGER NOT NULL DEFAULT 0,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
);
|
);
|
||||||
@@ -107,6 +110,12 @@ CREATE TABLE IF NOT EXISTS content_items (
|
|||||||
author_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
author_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
author_label TEXT,
|
author_label TEXT,
|
||||||
speaker_id UUID REFERENCES speakers(id) ON DELETE SET NULL,
|
speaker_id UUID REFERENCES speakers(id) ON DELETE SET NULL,
|
||||||
|
duration TEXT,
|
||||||
|
image_tone TEXT NOT NULL DEFAULT '',
|
||||||
|
moderator_comment TEXT,
|
||||||
|
review_comment TEXT,
|
||||||
|
rating_average DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||||
|
rating_count INTEGER NOT NULL DEFAULT 0,
|
||||||
published_at TIMESTAMPTZ,
|
published_at TIMESTAMPTZ,
|
||||||
archived_at TIMESTAMPTZ,
|
archived_at TIMESTAMPTZ,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
@@ -132,6 +141,15 @@ CREATE TABLE IF NOT EXISTS media_files (
|
|||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS stored_file_blobs (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
mime_type TEXT NOT NULL,
|
||||||
|
size_bytes BIGINT NOT NULL CHECK (size_bytes >= 0),
|
||||||
|
data BYTEA NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS subscriptions (
|
CREATE TABLE IF NOT EXISTS subscriptions (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
x-go-service-env: &go-service-env
|
||||||
|
PORT: 8080
|
||||||
|
DATABASE_URL: postgres://${POSTGRES_USER:-fable}:${POSTGRES_PASSWORD:-fable_dev_password}@postgres:5432/${POSTGRES_DB:-fable}?sslmode=disable
|
||||||
|
TOKEN_SECRET: ${TOKEN_SECRET:-local-dev-secret-change-me}
|
||||||
|
TOKEN_TTL: ${TOKEN_TTL:-24h}
|
||||||
|
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:17-alpine
|
image: postgres:17-alpine
|
||||||
@@ -61,8 +67,7 @@ services:
|
|||||||
dockerfile: infra/docker/go-service.Dockerfile
|
dockerfile: infra/docker/go-service.Dockerfile
|
||||||
args:
|
args:
|
||||||
SERVICE: auth
|
SERVICE: auth
|
||||||
environment:
|
environment: *go-service-env
|
||||||
PORT: 8080
|
|
||||||
|
|
||||||
user-service:
|
user-service:
|
||||||
build:
|
build:
|
||||||
@@ -70,8 +75,7 @@ services:
|
|||||||
dockerfile: infra/docker/go-service.Dockerfile
|
dockerfile: infra/docker/go-service.Dockerfile
|
||||||
args:
|
args:
|
||||||
SERVICE: user
|
SERVICE: user
|
||||||
environment:
|
environment: *go-service-env
|
||||||
PORT: 8080
|
|
||||||
|
|
||||||
content-service:
|
content-service:
|
||||||
build:
|
build:
|
||||||
@@ -79,8 +83,7 @@ services:
|
|||||||
dockerfile: infra/docker/go-service.Dockerfile
|
dockerfile: infra/docker/go-service.Dockerfile
|
||||||
args:
|
args:
|
||||||
SERVICE: content
|
SERVICE: content
|
||||||
environment:
|
environment: *go-service-env
|
||||||
PORT: 8080
|
|
||||||
|
|
||||||
taxonomy-service:
|
taxonomy-service:
|
||||||
build:
|
build:
|
||||||
@@ -88,8 +91,7 @@ services:
|
|||||||
dockerfile: infra/docker/go-service.Dockerfile
|
dockerfile: infra/docker/go-service.Dockerfile
|
||||||
args:
|
args:
|
||||||
SERVICE: taxonomy
|
SERVICE: taxonomy
|
||||||
environment:
|
environment: *go-service-env
|
||||||
PORT: 8080
|
|
||||||
|
|
||||||
speaker-service:
|
speaker-service:
|
||||||
build:
|
build:
|
||||||
@@ -97,8 +99,7 @@ services:
|
|||||||
dockerfile: infra/docker/go-service.Dockerfile
|
dockerfile: infra/docker/go-service.Dockerfile
|
||||||
args:
|
args:
|
||||||
SERVICE: speaker
|
SERVICE: speaker
|
||||||
environment:
|
environment: *go-service-env
|
||||||
PORT: 8080
|
|
||||||
|
|
||||||
subscription-service:
|
subscription-service:
|
||||||
build:
|
build:
|
||||||
@@ -106,8 +107,7 @@ services:
|
|||||||
dockerfile: infra/docker/go-service.Dockerfile
|
dockerfile: infra/docker/go-service.Dockerfile
|
||||||
args:
|
args:
|
||||||
SERVICE: subscription
|
SERVICE: subscription
|
||||||
environment:
|
environment: *go-service-env
|
||||||
PORT: 8080
|
|
||||||
|
|
||||||
notification-service:
|
notification-service:
|
||||||
build:
|
build:
|
||||||
@@ -115,8 +115,7 @@ services:
|
|||||||
dockerfile: infra/docker/go-service.Dockerfile
|
dockerfile: infra/docker/go-service.Dockerfile
|
||||||
args:
|
args:
|
||||||
SERVICE: notification
|
SERVICE: notification
|
||||||
environment:
|
environment: *go-service-env
|
||||||
PORT: 8080
|
|
||||||
|
|
||||||
comment-service:
|
comment-service:
|
||||||
build:
|
build:
|
||||||
@@ -124,8 +123,7 @@ services:
|
|||||||
dockerfile: infra/docker/go-service.Dockerfile
|
dockerfile: infra/docker/go-service.Dockerfile
|
||||||
args:
|
args:
|
||||||
SERVICE: comment
|
SERVICE: comment
|
||||||
environment:
|
environment: *go-service-env
|
||||||
PORT: 8080
|
|
||||||
|
|
||||||
search-service:
|
search-service:
|
||||||
build:
|
build:
|
||||||
@@ -133,8 +131,7 @@ services:
|
|||||||
dockerfile: infra/docker/go-service.Dockerfile
|
dockerfile: infra/docker/go-service.Dockerfile
|
||||||
args:
|
args:
|
||||||
SERVICE: search
|
SERVICE: search
|
||||||
environment:
|
environment: *go-service-env
|
||||||
PORT: 8080
|
|
||||||
|
|
||||||
analytics-service:
|
analytics-service:
|
||||||
build:
|
build:
|
||||||
@@ -142,8 +139,7 @@ services:
|
|||||||
dockerfile: infra/docker/go-service.Dockerfile
|
dockerfile: infra/docker/go-service.Dockerfile
|
||||||
args:
|
args:
|
||||||
SERVICE: analytics
|
SERVICE: analytics
|
||||||
environment:
|
environment: *go-service-env
|
||||||
PORT: 8080
|
|
||||||
|
|
||||||
audit-service:
|
audit-service:
|
||||||
build:
|
build:
|
||||||
@@ -151,8 +147,7 @@ services:
|
|||||||
dockerfile: infra/docker/go-service.Dockerfile
|
dockerfile: infra/docker/go-service.Dockerfile
|
||||||
args:
|
args:
|
||||||
SERVICE: audit
|
SERVICE: audit
|
||||||
environment:
|
environment: *go-service-env
|
||||||
PORT: 8080
|
|
||||||
|
|
||||||
media-service:
|
media-service:
|
||||||
build:
|
build:
|
||||||
@@ -160,8 +155,7 @@ services:
|
|||||||
dockerfile: infra/docker/go-service.Dockerfile
|
dockerfile: infra/docker/go-service.Dockerfile
|
||||||
args:
|
args:
|
||||||
SERVICE: media
|
SERVICE: media
|
||||||
environment:
|
environment: *go-service-env
|
||||||
PORT: 8080
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
|
|||||||
497
docs/compose/plans/2026-06-22-backend-stabilization.md
Normal file
497
docs/compose/plans/2026-06-22-backend-stabilization.md
Normal file
@@ -0,0 +1,497 @@
|
|||||||
|
# Backend Stabilization Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use compose:subagent (recommended) or compose:execute to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Turn the current scaffold into a repeatable backend-first almost-production demo: real PostgreSQL persistence, real auth, real gateway-backed API responses, and passing root verification.
|
||||||
|
|
||||||
|
**Architecture:** Keep the current three-layer shape (`apps/web` -> `apps/gateway` -> `services/cmd/*`) and replace the shared Go `demoStore` with a PostgreSQL-backed store built on `GORM`. Preserve existing HTTP contracts where possible, add only the minimal new plumbing needed for config, auth, health checks, and smoke verification.
|
||||||
|
|
||||||
|
**Tech Stack:** npm workspaces, React 19, Vite, Axios, Zustand, Node.js + Elysia gateway, Go HTTP services, GORM, PostgreSQL 17, SQL migrations, Docker Compose.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Map
|
||||||
|
|
||||||
|
- `package.json`: root verification commands and workspace entrypoints.
|
||||||
|
- `README.md`: authoritative local run and acceptance instructions.
|
||||||
|
- `.env.example`: example backend DSN, token secret, token TTL and API base URL.
|
||||||
|
- `docker-compose.yml`: consistent local runtime for postgres, gateway, web and services.
|
||||||
|
- `scripts/dev-backend.sh`: local backend boot with PostgreSQL-aware env.
|
||||||
|
- `scripts/smoke-backend.sh`: curl-based critical-path smoke verification.
|
||||||
|
- `apps/gateway/src/index.ts`: gateway proxying, auth fallback removal, unified error/health behavior.
|
||||||
|
- `apps/web/src/shared/api/client.js`: correct default API base URL and error normalization.
|
||||||
|
- `apps/web/src/shared/api/endpoints.js`: API contract used by the UI.
|
||||||
|
- `apps/web/src/app/store/session.js`: login payload compatibility, `me`, logout, session reset.
|
||||||
|
- `apps/web/src/pages/*.jsx`: key pages that currently assume mock-only data shapes.
|
||||||
|
- `apps/web/src/**/*.test.*`: frontend regression tests for login and key data pages.
|
||||||
|
- `services/go.mod`: PostgreSQL driver and password-hash dependency declarations.
|
||||||
|
- `services/internal/service/types.go`: backend DTOs and enum/value mapping.
|
||||||
|
- `services/internal/service/service.go`: service bootstrap, DB-aware health/readiness.
|
||||||
|
- `services/internal/service/api.go`: HTTP handlers for auth, content, media, search, comments, subscriptions, notifications, admin and analytics.
|
||||||
|
- `services/internal/service/store.go`: store interface and bootstrap hook.
|
||||||
|
- `services/internal/service/db.go`: PostgreSQL connection, GORM bootstrap and migration helpers.
|
||||||
|
- `services/internal/service/store_gorm.go`: GORM-backed implementation of the store.
|
||||||
|
- `services/internal/service/auth.go`: password hashing and signed token helpers.
|
||||||
|
- `services/internal/service/seed.go`: idempotent bootstrap data for roles, demo users and demo content.
|
||||||
|
- `services/internal/service/*_test.go`: Go unit/integration coverage for critical backend flows.
|
||||||
|
- `database/migrations/001_init.sql`: schema source of truth; must match current Go enum values and required tables.
|
||||||
|
|
||||||
|
### Task 1: Reproduce and lock the runtime baseline
|
||||||
|
|
||||||
|
**Covers:** [S1, S2, S7]
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `README.md`
|
||||||
|
- Modify: `.env.example`
|
||||||
|
- Modify: `package.json`
|
||||||
|
- Modify: `scripts/dev-backend.sh`
|
||||||
|
- Create: `scripts/smoke-backend.sh`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Capture the failing baseline in notes and a smoke script stub**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run check
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: failure is reproduced before code changes. Right now the first expected failure is the missing frontend toolchain (`vite: command not found`) or another dependency/runtime error from a clean install.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write the failing smoke script skeleton**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
API_BASE_URL="${API_BASE_URL:-http://127.0.0.1:3000/api}"
|
||||||
|
|
||||||
|
echo "[1/4] login"
|
||||||
|
echo "[2/4] fetch current user"
|
||||||
|
echo "[3/4] create draft content"
|
||||||
|
echo "[4/4] read admin dashboard"
|
||||||
|
|
||||||
|
exit 1
|
||||||
|
```
|
||||||
|
|
||||||
|
Save as `scripts/smoke-backend.sh` and mark it executable after the rest of the plan fills the real requests.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Make the local runtime explicit and consistent**
|
||||||
|
|
||||||
|
Add backend env examples to `.env.example`:
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
DATABASE_URL=postgres://fable:fable_dev_password@127.0.0.1:5432/fable?sslmode=disable
|
||||||
|
TOKEN_SECRET=local-dev-secret-change-me
|
||||||
|
TOKEN_TTL=24h
|
||||||
|
VITE_API_URL=http://127.0.0.1:3000/api
|
||||||
|
```
|
||||||
|
|
||||||
|
Update `scripts/dev-backend.sh` so every Go service receives `DATABASE_URL`, `TOKEN_SECRET` and `TOKEN_TTL` instead of relying on demo-only state.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Update root docs and verification contract**
|
||||||
|
|
||||||
|
Document the intended acceptance flow in `README.md`:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
1. `npm install`
|
||||||
|
2. `docker compose up -d postgres`
|
||||||
|
3. `npm run dev:backend`
|
||||||
|
4. `npm run dev:web`
|
||||||
|
5. `npm run check`
|
||||||
|
6. `bash scripts/smoke-backend.sh`
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Re-run the root verification command**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run check
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: it may still fail, but only on real application gaps rather than missing setup instructions.
|
||||||
|
|
||||||
|
### Task 2: Replace the shared demo store with PostgreSQL-backed GORM bootstrap
|
||||||
|
|
||||||
|
**Covers:** [S2, S4, S5, S6]
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `services/go.mod`
|
||||||
|
- Modify: `services/internal/service/store.go`
|
||||||
|
- Modify: `services/internal/service/service.go`
|
||||||
|
- Create: `services/internal/service/db.go`
|
||||||
|
- Create: `services/internal/service/store_gorm.go`
|
||||||
|
- Create: `services/internal/service/seed.go`
|
||||||
|
- Create: `services/internal/service/store_gorm_test.go`
|
||||||
|
- Modify: `database/migrations/001_init.sql`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing DB bootstrap test**
|
||||||
|
|
||||||
|
```go
|
||||||
|
func TestBootstrapStoreLoadsSeededUsersAndContent(t *testing.T) {
|
||||||
|
store := newTestGORMStore(t)
|
||||||
|
|
||||||
|
user, ok := store.UserByLogin(context.Background(), "demo_admin")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected seeded admin user")
|
||||||
|
}
|
||||||
|
|
||||||
|
items, err := store.ListContent(context.Background(), ContentFilter{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list content: %v", err)
|
||||||
|
}
|
||||||
|
if len(items) == 0 {
|
||||||
|
t.Fatal("expected seeded content")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the failing Go test**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd services && go test ./internal/service -run TestBootstrapStoreLoadsSeededUsersAndContent -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL because there is no GORM bootstrap/store yet.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Introduce DB config, connection and store interface**
|
||||||
|
|
||||||
|
In `services/internal/service/store.go`, replace the global concrete store with an interface:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Store interface {
|
||||||
|
UserByLogin(ctx context.Context, login string) (UserProfile, bool, error)
|
||||||
|
ListContent(ctx context.Context, filter ContentFilter) ([]ContentItem, error)
|
||||||
|
// add the remaining methods currently hanging off demoStore
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
In `services/internal/service/db.go`, add a PostgreSQL opener using `DATABASE_URL` and return a shared `*gorm.DB`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Implement GORM store and idempotent seed bootstrap**
|
||||||
|
|
||||||
|
Key shape for `services/internal/service/store_gorm.go`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type gormStore struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *gormStore) UserByLogin(ctx context.Context, login string) (UserProfile, bool, error) {
|
||||||
|
var user userRecord
|
||||||
|
err := s.db.WithContext(ctx).Preload("Roles").Where("login = ?", login).First(&user).Error
|
||||||
|
// map record -> API DTO
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
In `seed.go`, upsert roles, demo users, speakers, categories, tags, content, notifications and comments.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Align the migration with Go values before wiring queries**
|
||||||
|
|
||||||
|
Fix the enum mismatch in `database/migrations/001_init.sql` so the stored content type matches Go (`event`, not `event_announcement`) or update Go and frontend in one consistent direction. Pick one representation and use it everywhere.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Run the new store test and the existing Go service test**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd services && go test ./internal/service -run 'TestBootstrapStoreLoadsSeededUsersAndContent|TestHealthEndpoint' -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
### Task 3: Implement real auth, session lookup and readiness behavior
|
||||||
|
|
||||||
|
**Covers:** [S2, S3, S5, S6, S7]
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `services/internal/service/api.go`
|
||||||
|
- Modify: `services/internal/service/types.go`
|
||||||
|
- Create: `services/internal/service/auth.go`
|
||||||
|
- Create: `services/internal/service/auth_test.go`
|
||||||
|
- Modify: `services/internal/service/service.go`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing auth tests for register, login and me**
|
||||||
|
|
||||||
|
```go
|
||||||
|
func TestAuthFlowRegisterLoginAndMe(t *testing.T) {
|
||||||
|
h := newTestHandler(t, Config{Name: "auth", Domain: "auth"})
|
||||||
|
|
||||||
|
registerBody := `{"login":"new_user","password":"verysecret","name":"Новый пользователь"}`
|
||||||
|
registerRec := performJSONRequest(t, h, http.MethodPost, "/api/auth/register", registerBody, "")
|
||||||
|
if registerRec.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("register status = %d", registerRec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
token := readTokenFromResponse(t, registerRec.Body.Bytes())
|
||||||
|
meRec := performJSONRequest(t, h, http.MethodGet, "/api/auth/me", "", token)
|
||||||
|
if meRec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("me status = %d", meRec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the failing auth test**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd services && go test ./internal/service -run TestAuthFlowRegisterLoginAndMe -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL because login currently accepts any password and returns a demo token.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add password hashing and signed tokens**
|
||||||
|
|
||||||
|
In `services/internal/service/auth.go` add minimal helpers:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func HashPassword(password string) (string, error) { /* bcrypt */ }
|
||||||
|
func ComparePassword(hash, password string) error { /* bcrypt */ }
|
||||||
|
func SignToken(secret string, claims TokenClaims, ttl time.Duration) (string, error) { /* HMAC-signed token */ }
|
||||||
|
func ParseToken(secret, token string) (TokenClaims, error) { /* validate signature + expiry */ }
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep the token format simple and self-contained; do not preserve `demo-token-*` compatibility.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Update auth handlers to use the SQL store**
|
||||||
|
|
||||||
|
Replace the login fallback in `api.go` with explicit credential checks:
|
||||||
|
|
||||||
|
```go
|
||||||
|
user, ok, err := backendStore.UserByLogin(ctx, payload.Login)
|
||||||
|
if err != nil { /* 500 */ }
|
||||||
|
if !ok || ComparePassword(user.PasswordHash, payload.Password) != nil {
|
||||||
|
writeAPIError(w, http.StatusUnauthorized, "INVALID_CREDENTIALS", "Неверный логин или пароль")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Also make `/auth/change-password` update the stored hash and make `/ready` fail when DB connectivity is broken.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run focused auth/readiness verification**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd services && go test ./internal/service -run 'TestAuthFlowRegisterLoginAndMe|TestHealthEndpoint' -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
### Task 4: Persist content, taxonomy, subscriptions, comments, admin and analytics endpoints
|
||||||
|
|
||||||
|
**Covers:** [S3, S4, S5, S6, S7]
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `services/internal/service/api.go`
|
||||||
|
- Modify: `services/internal/service/types.go`
|
||||||
|
- Modify: `services/internal/service/store_gorm.go`
|
||||||
|
- Create: `services/internal/service/content_api_test.go`
|
||||||
|
- Create: `services/internal/service/admin_api_test.go`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing tests for the critical domain path**
|
||||||
|
|
||||||
|
```go
|
||||||
|
func TestContentCommentSubscriptionAndAdminFlow(t *testing.T) {
|
||||||
|
h := newTestHandlerAsAdmin(t)
|
||||||
|
|
||||||
|
created := performJSONRequest(t, h, http.MethodPost, "/api/content", `{"title":"Новый материал","type":"article","category":"Статьи"}`, adminToken(t, h))
|
||||||
|
if created.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("create content status = %d", created.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
contentID := readItemID(t, created.Body.Bytes())
|
||||||
|
comment := performJSONRequest(t, h, http.MethodPost, "/api/comments/"+contentID, `{"text":"Полезный материал"}`, userToken(t, h))
|
||||||
|
if comment.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("create comment status = %d", comment.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the failing domain test**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd services && go test ./internal/service -run TestContentCommentSubscriptionAndAdminFlow -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL because handlers still depend on the in-memory methods and non-persistent side effects.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Port each handler branch to GORM-backed methods without changing the external contract**
|
||||||
|
|
||||||
|
Implement GORM methods for:
|
||||||
|
- content list/detail/create/update/delete;
|
||||||
|
- events/media derived listings;
|
||||||
|
- category/tag reads;
|
||||||
|
- speaker reads;
|
||||||
|
- comment list/create;
|
||||||
|
- subscription list/create;
|
||||||
|
- notification list/mark-read;
|
||||||
|
- admin users/roles/audit/dashboard;
|
||||||
|
- analytics summary;
|
||||||
|
- search using PostgreSQL text search.
|
||||||
|
|
||||||
|
Represent the filter in Go explicitly:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type ContentFilter struct {
|
||||||
|
Query string
|
||||||
|
Category string
|
||||||
|
Type string
|
||||||
|
Sort string
|
||||||
|
Limit int
|
||||||
|
Exclude string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Keep API payloads stable for the current frontend**
|
||||||
|
|
||||||
|
Return the same envelope keys the UI already expects:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "user": { ... } }
|
||||||
|
{ "items": [ ... ] }
|
||||||
|
{ "item": { ... } }
|
||||||
|
{ "ok": true }
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not introduce a second incompatible schema while stabilizing the backend.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run the focused Go verification for the critical path**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd services && go test ./internal/service -run 'TestContentCommentSubscriptionAndAdminFlow|TestAuthFlowRegisterLoginAndMe' -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
### Task 5: Remove gateway/frontend mock-only assumptions and align contracts
|
||||||
|
|
||||||
|
**Covers:** [S2, S3, S5, S7, S8]
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `apps/gateway/src/index.ts`
|
||||||
|
- Modify: `apps/web/src/shared/api/client.js`
|
||||||
|
- Modify: `apps/web/src/shared/api/endpoints.js`
|
||||||
|
- Modify: `apps/web/src/app/store/session.js`
|
||||||
|
- Modify: `apps/web/src/pages/LoginPage.test.jsx`
|
||||||
|
- Modify: `apps/web/src/pages/MaterialsPage.test.jsx`
|
||||||
|
- Create: `apps/web/src/app/store/session.test.js`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing frontend session test**
|
||||||
|
|
||||||
|
```js
|
||||||
|
it("stores token after login and loads current user from /auth/me", async () => {
|
||||||
|
authApi.login = vi.fn().mockResolvedValue({ accessToken: "real-token" });
|
||||||
|
authApi.me = vi.fn().mockResolvedValue({ id: "u1", login: "demo_admin", roles: ["администратор"] });
|
||||||
|
|
||||||
|
const user = await useSession.getState().login({ email: "demo_admin", password: "secret123" });
|
||||||
|
|
||||||
|
expect(tokenStorage.get()).toBe("real-token");
|
||||||
|
expect(user.login).toBe("demo_admin");
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the failing frontend tests**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm --workspace @fable/web run test -- --runInBand
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL because the current store sends `{ email, password }` while the backend expects `login`, and the default API URL points to `:8000/api` instead of the gateway on `:3000/api`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Fix the frontend contract with minimal surface area**
|
||||||
|
|
||||||
|
Make these concrete adjustments:
|
||||||
|
- `client.js`: default `VITE_API_URL` to `http://localhost:3000/api`;
|
||||||
|
- `client.js`: normalize backend errors from `{ error: { message } }` as well as `{ message }`;
|
||||||
|
- `session.js`: submit `login` instead of `email`, while still reading the same form field from UI;
|
||||||
|
- `session.js`: persist the `me` response shape without assuming mock-only fields.
|
||||||
|
|
||||||
|
Target login change:
|
||||||
|
|
||||||
|
```js
|
||||||
|
login: async ({ email, password }) => {
|
||||||
|
const response = await authApi.login({ login: email, password });
|
||||||
|
const token = getToken(response);
|
||||||
|
tokenStorage.set(token);
|
||||||
|
const user = response.user ?? (await authApi.me());
|
||||||
|
set({ user });
|
||||||
|
persistUser(user);
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Remove gateway-local demo fallback behavior**
|
||||||
|
|
||||||
|
In `apps/gateway/src/index.ts`, delete or bypass the hardcoded `demoUser`, `tokens` and local content arrays once real service URLs are configured. Gateway behavior should become: proxy or fail explicitly, but never silently serve a shadow backend.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Re-run frontend verification**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm --workspace @fable/web run test
|
||||||
|
npm --workspace @fable/web run build
|
||||||
|
npm --workspace @fable/gateway run check
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
### Task 6: Final smoke verification for local and Docker flows
|
||||||
|
|
||||||
|
**Covers:** [S2, S6, S7]
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `docker-compose.yml`
|
||||||
|
- Modify: `scripts/smoke-backend.sh`
|
||||||
|
- Modify: `README.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Fill the smoke script with real requests**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
TOKEN="$({
|
||||||
|
curl -sS -X POST "$API_BASE_URL/auth/login" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"login":"demo_admin","password":"demo_password"}'
|
||||||
|
} | jq -r '.token // .accessToken')"
|
||||||
|
|
||||||
|
curl -sS "$API_BASE_URL/auth/me" -H "Authorization: Bearer $TOKEN"
|
||||||
|
curl -sS -X POST "$API_BASE_URL/content" -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -d '{"title":"Smoke draft","type":"article","category":"Статьи"}'
|
||||||
|
curl -sS "$API_BASE_URL/admin/users" -H "Authorization: Bearer $TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify the non-Docker local path**
|
||||||
|
|
||||||
|
Run in separate shells:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d postgres
|
||||||
|
npm run dev:backend
|
||||||
|
npm run dev:web
|
||||||
|
bash scripts/smoke-backend.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify the Docker path**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up --build -d
|
||||||
|
bash scripts/smoke-backend.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS against the same API base URL.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run the final root verification command**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run check
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Update the README acceptance section with the final proven commands**
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
Проверка готовности:
|
||||||
|
- `npm run check`
|
||||||
|
- `bash scripts/smoke-backend.sh`
|
||||||
|
- `docker compose up --build`
|
||||||
|
```
|
||||||
|
|
||||||
|
## Self-Review
|
||||||
|
|
||||||
|
- [x] Every spec section `[S1]`..`[S8]` is covered by at least one task.
|
||||||
|
- [x] The migration/schema mismatch (`event_announcement` vs `event`) is explicitly addressed.
|
||||||
|
- [x] No `TODO`/`TBD` placeholders remain in tasks.
|
||||||
|
- [x] Frontend/gateway/backend contract mismatches are called out explicitly (`VITE_API_URL`, `login` payload, demo fallback removal).
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# Backend Stabilization Design
|
||||||
|
|
||||||
|
## [S1] Контекст
|
||||||
|
|
||||||
|
Репозиторий уже содержит три основных слоя: `apps/web`, `apps/gateway` и Go-сервисы в `services/`. Сейчас проект не доведен до почти production-состояния по трем причинам: установка и сборка из корня не подтверждены, frontend частично опирается на mock-данные, а backend хранит состояние в `demo`/in-memory store.
|
||||||
|
|
||||||
|
Цель этой спецификации: определить первый под-проект, который переведет приложение из состояния scaffold/demo в состояние почти production-демо с реальным backend-контуром.
|
||||||
|
|
||||||
|
## [S2] Цель первого под-проекта
|
||||||
|
|
||||||
|
Первый под-проект называется `backend stabilization`.
|
||||||
|
|
||||||
|
Его результат:
|
||||||
|
- проект воспроизводимо устанавливается и собирается из корня репозитория;
|
||||||
|
- gateway и Go-сервисы работают на PostgreSQL, а не на in-memory store;
|
||||||
|
- frontend перестает зависеть от mock-only ядра в основных сценариях;
|
||||||
|
- аутентификация, авторизация и сохранение данных становятся реальными и повторяемыми;
|
||||||
|
- локальный запуск и Docker-окружение дают одинаково рабочий backend-контур.
|
||||||
|
|
||||||
|
## [S3] Объем и границы
|
||||||
|
|
||||||
|
В объем первой фазы входят:
|
||||||
|
- реальные данные для `auth`, `users/me`, `content`, `categories`, `tags`, `speakers`, `subscriptions`, `notifications`, `comments`, `search`, `admin`, `analytics summary`, `media metadata/upload`;
|
||||||
|
- password hashing, access token, проверка текущего пользователя, logout, защита маршрутов;
|
||||||
|
- миграции и seed-данные для стартовых ролей, аккаунтов и базовых сущностей;
|
||||||
|
- smoke/integration verification для критического пути.
|
||||||
|
|
||||||
|
В первую фазу не входят:
|
||||||
|
- внешняя production-инфраструктура вне репозитория;
|
||||||
|
- CDN или объектное хранилище;
|
||||||
|
- полный UI-polish всех экранов;
|
||||||
|
- расширенная аналитика сверх уже существующих экранов и summary-метрик.
|
||||||
|
|
||||||
|
## [S4] Архитектурный подход
|
||||||
|
|
||||||
|
Архитектура сохраняется существующая:
|
||||||
|
- `apps/web` остается клиентом;
|
||||||
|
- `apps/gateway` остается единой внешней точкой входа;
|
||||||
|
- `services/cmd/*` остаются отдельными Go-процессами;
|
||||||
|
- `database/` и миграции становятся источником истины для схемы данных.
|
||||||
|
|
||||||
|
Ключевой принцип этой фазы: не переписывать систему заново и не вводить тяжелую новую абстракцию без необходимости. Минимальный путь — заменить текущее общее `demoStore` на PostgreSQL-backed хранилище, сохранив текущие HTTP-контракты везде, где это возможно.
|
||||||
|
|
||||||
|
Новые или уточненные backend-компоненты:
|
||||||
|
- конфигурация для DSN, токен-секретов, TTL и внутренних URL;
|
||||||
|
- GORM-backed persistence слой поверх PostgreSQL;
|
||||||
|
- auth-логика для хэша паролей и проверки токенов;
|
||||||
|
- seed/bootstrap слой для стартовых данных;
|
||||||
|
- реальные health checks для gateway и сервисов.
|
||||||
|
|
||||||
|
## [S5] Потоки данных
|
||||||
|
|
||||||
|
Целевой поток запроса:
|
||||||
|
1. frontend отправляет запрос только в gateway;
|
||||||
|
2. gateway валидирует запрос, извлекает токен, определяет пользователя и применяет базовые RBAC-проверки;
|
||||||
|
3. gateway проксирует запрос в нужный Go-сервис;
|
||||||
|
4. Go-сервис читает и записывает состояние в PostgreSQL;
|
||||||
|
5. ответ возвращается через gateway в единообразном JSON-формате.
|
||||||
|
|
||||||
|
Скрытый local fallback и `demo-token` перестают быть основным режимом работы. Для разработки допускаются только явные seed-данные, а не подмена настоящих ответов моками при ошибках backend-контура.
|
||||||
|
|
||||||
|
## [S6] Ошибки, безопасность и наблюдаемость
|
||||||
|
|
||||||
|
Требования этой фазы:
|
||||||
|
- единый error response через gateway: код, сообщение, request ID;
|
||||||
|
- корректные `401` и `403` для защищенных endpoint'ов;
|
||||||
|
- health endpoints должны отражать реальное состояние зависимостей, включая БД;
|
||||||
|
- request ID должен проходить через gateway и сервисы для трассировки;
|
||||||
|
- пароли хранятся только в хэшированном виде;
|
||||||
|
- защищенные маршруты не должны обходиться demo-механикой.
|
||||||
|
|
||||||
|
## [S7] Проверка готовности
|
||||||
|
|
||||||
|
Работа считается завершенной, когда подтверждены все пункты:
|
||||||
|
- чистая установка зависимостей и сборка проекта из корня;
|
||||||
|
- успешный `npm run check`;
|
||||||
|
- успешный локальный запуск backend и frontend;
|
||||||
|
- критические API-сценарии проходят в smoke/integration verification;
|
||||||
|
- данные сохраняются в PostgreSQL и переживают перезапуск сервисов.
|
||||||
|
|
||||||
|
Минимальный критический путь для проверки:
|
||||||
|
- login;
|
||||||
|
- `GET /api/auth/me`;
|
||||||
|
- список и изменение контента;
|
||||||
|
- создание комментария;
|
||||||
|
- создание подписки;
|
||||||
|
- admin users;
|
||||||
|
- audit log;
|
||||||
|
- analytics summary.
|
||||||
|
|
||||||
|
## [S8] Решения по объему
|
||||||
|
|
||||||
|
Путь выполнения для этой спецификации: `backend-first`.
|
||||||
|
|
||||||
|
Это означает, что в первой фазе приоритет отдается реальности backend-контура, API, БД, auth и repeatable verification. Frontend должен быть совместим с результатом, но крупные визуальные доработки не входят в критический путь этой спецификации.
|
||||||
5522
package-lock.json
generated
5522
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
11
package.json
11
package.json
@@ -3,16 +3,13 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"description": "Университетская платформа управления медиаконтентом по plan.md",
|
"description": "Университетская платформа управления медиаконтентом по plan.md",
|
||||||
"workspaces": [
|
|
||||||
"apps/*"
|
|
||||||
],
|
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "bash scripts/dev.sh",
|
"dev": "bash scripts/dev.sh",
|
||||||
"dev:web": "npm --workspace @fable/web run dev",
|
"dev:web": "cd apps/web && bash node_modules/.bin/vite --host 0.0.0.0",
|
||||||
"build:web": "npm --workspace @fable/web run build",
|
"build:web": "cd apps/web && bash node_modules/.bin/vite build",
|
||||||
"dev:backend": "bash scripts/dev-backend.sh",
|
"dev:backend": "bash scripts/dev-backend.sh",
|
||||||
"dev:gateway": "npm --workspace @fable/gateway run dev",
|
"dev:gateway": "cd apps/gateway && bash node_modules/.bin/tsc && node --watch dist/index.js",
|
||||||
"check:gateway": "npm --workspace @fable/gateway run check",
|
"check:gateway": "cd apps/gateway && bash node_modules/.bin/tsc --noEmit",
|
||||||
"test:go": "cd services && go test ./...",
|
"test:go": "cd services && go test ./...",
|
||||||
"check": "npm run build:web && npm run check:gateway && npm run test:go"
|
"check": "npm run build:web && npm run check:gateway && npm run test:go"
|
||||||
}
|
}
|
||||||
|
|||||||
3620
pnpm-lock.yaml
generated
Normal file
3620
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
10
pnpm-workspace.yaml
Normal file
10
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
packages:
|
||||||
|
- "apps/*"
|
||||||
|
|
||||||
|
allowBuilds:
|
||||||
|
'@tailwindcss/oxide': set this to true or false
|
||||||
|
esbuild: set this to true or false
|
||||||
|
|
||||||
|
onlyBuiltDependencies:
|
||||||
|
- "@tailwindcss/oxide"
|
||||||
|
- "esbuild"
|
||||||
@@ -3,6 +3,9 @@ set -euo pipefail
|
|||||||
|
|
||||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
LOG_DIR="$ROOT_DIR/.tmp/backend-logs"
|
LOG_DIR="$ROOT_DIR/.tmp/backend-logs"
|
||||||
|
DATABASE_URL="${DATABASE_URL:-postgres://fable:fable_dev_password@127.0.0.1:5432/fable?sslmode=disable}"
|
||||||
|
TOKEN_SECRET="${TOKEN_SECRET:-local-dev-secret-change-me}"
|
||||||
|
TOKEN_TTL="${TOKEN_TTL:-24h}"
|
||||||
mkdir -p "$LOG_DIR"
|
mkdir -p "$LOG_DIR"
|
||||||
|
|
||||||
pids=()
|
pids=()
|
||||||
@@ -21,7 +24,7 @@ start_service() {
|
|||||||
local name="$1"
|
local name="$1"
|
||||||
local port="$2"
|
local port="$2"
|
||||||
echo "Starting $name service on :$port"
|
echo "Starting $name service on :$port"
|
||||||
(cd "$ROOT_DIR/services" && PORT="$port" go run "./cmd/$name") >"$LOG_DIR/$name.log" 2>&1 &
|
(cd "$ROOT_DIR/services" && PORT="$port" DATABASE_URL="$DATABASE_URL" TOKEN_SECRET="$TOKEN_SECRET" TOKEN_TTL="$TOKEN_TTL" go run "./cmd/$name") >"$LOG_DIR/$name.log" 2>&1 &
|
||||||
pids+=("$!")
|
pids+=("$!")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,23 @@
|
|||||||
module fable/services
|
module fable/services
|
||||||
|
|
||||||
go 1.26
|
go 1.26
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/google/uuid v1.6.0
|
||||||
|
gorm.io/driver/postgres v1.6.0
|
||||||
|
gorm.io/driver/sqlite v1.6.0
|
||||||
|
gorm.io/gorm v1.31.1
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
|
github.com/jackc/pgx/v5 v5.6.0 // indirect
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.22 // indirect
|
||||||
|
golang.org/x/crypto v0.31.0 // indirect
|
||||||
|
golang.org/x/sync v0.10.0 // indirect
|
||||||
|
golang.org/x/text v0.21.0 // indirect
|
||||||
|
)
|
||||||
|
|||||||
42
services/go.sum
Normal file
42
services/go.sum
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
|
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
|
||||||
|
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
|
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||||
|
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||||
|
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||||
|
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
||||||
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
|
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||||
|
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||||
|
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
|
||||||
|
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
|
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||||
|
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
|
||||||
|
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
|
||||||
|
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
|
||||||
|
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
|
||||||
|
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
||||||
|
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||||
@@ -2,7 +2,6 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -12,10 +11,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type tokenPayload struct {
|
type tokenPayload struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Login string `json:"login"`
|
Login string `json:"login"`
|
||||||
Roles []RoleCode `json:"roles"`
|
Roles []RoleCode `json:"roles"`
|
||||||
|
ExpiresAt int64 `json:"exp"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type apiError struct {
|
type apiError struct {
|
||||||
@@ -25,6 +25,36 @@ type apiError struct {
|
|||||||
} `json:"error"`
|
} `json:"error"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeContentType(value string) ContentType {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||||
|
case "research":
|
||||||
|
return ContentTypeArticle
|
||||||
|
case "announcement":
|
||||||
|
return ContentTypeNews
|
||||||
|
default:
|
||||||
|
return ContentType(strings.TrimSpace(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeStatus(value string) ContentStatus {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||||
|
case "draft", "черновик":
|
||||||
|
return ContentStatusDraft
|
||||||
|
case "moderation", "pending", "на модерации":
|
||||||
|
return ContentStatusModeration
|
||||||
|
case "review", "на проверке":
|
||||||
|
return ContentStatusReview
|
||||||
|
case "published", "опубликовано":
|
||||||
|
return ContentStatusPublished
|
||||||
|
case "returned", "возвращен":
|
||||||
|
return ContentStatusReturned
|
||||||
|
case "archived", "архив":
|
||||||
|
return ContentStatusArchived
|
||||||
|
default:
|
||||||
|
return ContentStatus(strings.TrimSpace(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func routeAPI(w http.ResponseWriter, r *http.Request, cfg Config) bool {
|
func routeAPI(w http.ResponseWriter, r *http.Request, cfg Config) bool {
|
||||||
path := strings.TrimPrefix(r.URL.Path, "/api")
|
path := strings.TrimPrefix(r.URL.Path, "/api")
|
||||||
if path == "" {
|
if path == "" {
|
||||||
@@ -66,31 +96,49 @@ func handleAuth(w http.ResponseWriter, r *http.Request, path string) bool {
|
|||||||
case r.Method == http.MethodPost && path == "/auth/login":
|
case r.Method == http.MethodPost && path == "/auth/login":
|
||||||
var payload struct {
|
var payload struct {
|
||||||
Login string `json:"login"`
|
Login string `json:"login"`
|
||||||
|
Email string `json:"email"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
}
|
}
|
||||||
if !decodeJSON(w, r, &payload) {
|
if !decodeJSON(w, r, &payload) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(payload.Login) == "" || strings.TrimSpace(payload.Password) == "" {
|
login := firstNonEmpty(payload.Login, payload.Email)
|
||||||
|
if strings.TrimSpace(login) == "" || strings.TrimSpace(payload.Password) == "" {
|
||||||
writeAPIError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Укажите логин и пароль")
|
writeAPIError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Укажите логин и пароль")
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
user, ok := backendStore.userByLogin(payload.Login)
|
user, ok, err := backendStore.UserByLogin(r.Context(), login)
|
||||||
if !ok {
|
if err != nil {
|
||||||
user, _ = backendStore.userByLogin("demo_admin")
|
writeStoreError(w, err)
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"token": makeToken(user), "user": user})
|
if !ok {
|
||||||
|
writeAPIError(w, http.StatusUnauthorized, "INVALID_CREDENTIALS", "Неверный логин или пароль")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if err := checkPassword(user.PasswordHash, payload.Password); err != nil {
|
||||||
|
writeAPIError(w, http.StatusUnauthorized, "INVALID_CREDENTIALS", "Неверный логин или пароль")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
token, err := makeToken(user)
|
||||||
|
if err != nil {
|
||||||
|
writeAPIError(w, http.StatusInternalServerError, "TOKEN_ERROR", "Не удалось выпустить токен")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"token": token, "user": user})
|
||||||
return true
|
return true
|
||||||
case r.Method == http.MethodPost && path == "/auth/register":
|
case r.Method == http.MethodPost && path == "/auth/register":
|
||||||
var payload struct {
|
var payload struct {
|
||||||
Login string `json:"login"`
|
Login string `json:"login"`
|
||||||
|
Email string `json:"email"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
}
|
}
|
||||||
if !decodeJSON(w, r, &payload) {
|
if !decodeJSON(w, r, &payload) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(payload.Login) == "" || len(payload.Password) < 8 {
|
login := firstNonEmpty(payload.Login, payload.Email)
|
||||||
|
if strings.TrimSpace(login) == "" || len(payload.Password) < 8 {
|
||||||
writeAPIError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Укажите логин и пароль не короче 8 символов")
|
writeAPIError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Укажите логин и пароль не короче 8 символов")
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -98,8 +146,17 @@ func handleAuth(w http.ResponseWriter, r *http.Request, path string) bool {
|
|||||||
if name == "" {
|
if name == "" {
|
||||||
name = "Демо-пользователь"
|
name = "Демо-пользователь"
|
||||||
}
|
}
|
||||||
user := backendStore.addUser(payload.Login, name)
|
user, err := backendStore.AddUser(r.Context(), login, name, payload.Password)
|
||||||
writeJSON(w, http.StatusCreated, map[string]any{"token": makeToken(user), "user": user})
|
if err != nil {
|
||||||
|
writeStoreError(w, err)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
token, err := makeToken(user)
|
||||||
|
if err != nil {
|
||||||
|
writeAPIError(w, http.StatusInternalServerError, "TOKEN_ERROR", "Не удалось выпустить токен")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusCreated, map[string]any{"token": token, "user": user})
|
||||||
return true
|
return true
|
||||||
case r.Method == http.MethodGet && path == "/auth/me":
|
case r.Method == http.MethodGet && path == "/auth/me":
|
||||||
user, ok := requireAuth(w, r)
|
user, ok := requireAuth(w, r)
|
||||||
@@ -112,7 +169,8 @@ func handleAuth(w http.ResponseWriter, r *http.Request, path string) bool {
|
|||||||
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
||||||
return true
|
return true
|
||||||
case r.Method == http.MethodPost && path == "/auth/change-password":
|
case r.Method == http.MethodPost && path == "/auth/change-password":
|
||||||
if _, ok := requireAuth(w, r); !ok {
|
user, ok := requireAuth(w, r)
|
||||||
|
if !ok {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
var payload struct {
|
var payload struct {
|
||||||
@@ -125,6 +183,10 @@ func handleAuth(w http.ResponseWriter, r *http.Request, path string) bool {
|
|||||||
writeAPIError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Новый пароль должен быть не короче 8 символов")
|
writeAPIError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Новый пароль должен быть не короче 8 символов")
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
if err := backendStore.UpdatePassword(r.Context(), user.ID, payload.NextPassword); err != nil {
|
||||||
|
writeStoreError(w, err)
|
||||||
|
return true
|
||||||
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
@@ -138,9 +200,11 @@ func handleUser(w http.ResponseWriter, r *http.Request, path string) bool {
|
|||||||
if _, ok := requireRole(w, r, RoleAdministrator); !ok {
|
if _, ok := requireRole(w, r, RoleAdministrator); !ok {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
backendStore.mu.RLock()
|
items, err := backendStore.ListUsers(r.Context())
|
||||||
items := append([]UserProfile(nil), backendStore.Users...)
|
if err != nil {
|
||||||
backendStore.mu.RUnlock()
|
writeStoreError(w, err)
|
||||||
|
return true
|
||||||
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||||
return true
|
return true
|
||||||
case r.Method == http.MethodGet && (path == "/roles" || path == "/admin/roles"):
|
case r.Method == http.MethodGet && (path == "/roles" || path == "/admin/roles"):
|
||||||
@@ -162,25 +226,48 @@ func handleUser(w http.ResponseWriter, r *http.Request, path string) bool {
|
|||||||
func handleContent(w http.ResponseWriter, r *http.Request, path string) bool {
|
func handleContent(w http.ResponseWriter, r *http.Request, path string) bool {
|
||||||
switch {
|
switch {
|
||||||
case r.Method == http.MethodGet && path == "/content":
|
case r.Method == http.MethodGet && path == "/content":
|
||||||
backendStore.mu.RLock()
|
query := r.URL.Query()
|
||||||
items := append([]ContentItem(nil), backendStore.Content...)
|
user, _ := userFromRequest(r)
|
||||||
backendStore.mu.RUnlock()
|
items, err := backendStore.ListContent(r.Context(), ContentFilter{
|
||||||
|
Term: query.Get("q"),
|
||||||
|
Category: query.Get("category"),
|
||||||
|
ContentType: string(normalizeContentType(query.Get("type"))),
|
||||||
|
SortMode: query.Get("sort"),
|
||||||
|
Status: string(normalizeStatus(query.Get("status"))),
|
||||||
|
AuthorID: query.Get("authorId"),
|
||||||
|
Mine: query.Get("mine") == "true",
|
||||||
|
User: user,
|
||||||
|
Exclude: query.Get("exclude"),
|
||||||
|
Limit: func() int {
|
||||||
|
if query.Get("limit") == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
var n int
|
||||||
|
fmt.Sscanf(query.Get("limit"), "%d", &n)
|
||||||
|
return n
|
||||||
|
}(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
writeStoreError(w, err)
|
||||||
|
return true
|
||||||
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||||
return true
|
return true
|
||||||
case r.Method == http.MethodGet && path == "/events":
|
case r.Method == http.MethodGet && path == "/events":
|
||||||
backendStore.mu.RLock()
|
items, err := backendStore.ListContent(r.Context(), ContentFilter{OnlyEvents: true, SortMode: "newest"})
|
||||||
items := make([]ContentItem, 0)
|
if err != nil {
|
||||||
for _, item := range backendStore.Content {
|
writeStoreError(w, err)
|
||||||
if item.Type == ContentTypeEvent {
|
return true
|
||||||
items = append(items, item)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
backendStore.mu.RUnlock()
|
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"items": items, "note": "Event представлен как тип контента до подтверждения отдельной сущности."})
|
writeJSON(w, http.StatusOK, map[string]any{"items": items, "note": "Event представлен как тип контента до подтверждения отдельной сущности."})
|
||||||
return true
|
return true
|
||||||
case r.Method == http.MethodGet && strings.HasPrefix(path, "/content/"):
|
case r.Method == http.MethodGet && strings.HasPrefix(path, "/content/"):
|
||||||
id := strings.TrimPrefix(path, "/content/")
|
id := strings.TrimPrefix(path, "/content/")
|
||||||
item, ok := backendStore.contentByID(id)
|
item, ok, err := backendStore.ContentByID(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
writeStoreError(w, err)
|
||||||
|
return true
|
||||||
|
}
|
||||||
if !ok {
|
if !ok {
|
||||||
writeAPIError(w, http.StatusNotFound, "NOT_FOUND", "Материал не найден")
|
writeAPIError(w, http.StatusNotFound, "NOT_FOUND", "Материал не найден")
|
||||||
return true
|
return true
|
||||||
@@ -196,30 +283,41 @@ func handleContent(w http.ResponseWriter, r *http.Request, path string) bool {
|
|||||||
if !decodeJSON(w, r, &payload) {
|
if !decodeJSON(w, r, &payload) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
payload.Type = normalizeContentType(string(payload.Type))
|
||||||
|
payload.Status = normalizeStatus(string(payload.Status))
|
||||||
|
payload.Lead = firstNonEmpty(payload.Lead, payload.Excerpt)
|
||||||
|
payload.Body = firstNonEmpty(payload.Body, payload.Content)
|
||||||
if strings.TrimSpace(payload.Title) == "" || payload.Type == "" || strings.TrimSpace(payload.Category) == "" {
|
if strings.TrimSpace(payload.Title) == "" || payload.Type == "" || strings.TrimSpace(payload.Category) == "" {
|
||||||
writeAPIError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Укажите название, категорию и тип материала")
|
writeAPIError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Укажите название, категорию и тип материала")
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
item := ContentItem{
|
item := ContentItem{
|
||||||
ID: fmt.Sprintf("demo-content-%d", time.Now().UnixNano()),
|
ID: fmt.Sprintf("demo-content-%d", time.Now().UnixNano()),
|
||||||
Title: payload.Title,
|
Title: payload.Title,
|
||||||
Lead: firstNonEmpty(payload.Lead, "Демонстрационный черновик"),
|
Lead: firstNonEmpty(payload.Lead, "Демонстрационный черновик"),
|
||||||
Body: firstNonEmpty(payload.Body, "Демо-описание материала."),
|
Body: firstNonEmpty(payload.Body, "Демо-описание материала."),
|
||||||
Type: payload.Type,
|
Type: payload.Type,
|
||||||
Category: payload.Category,
|
Category: payload.Category,
|
||||||
Tags: payload.Tags,
|
Tags: payload.Tags,
|
||||||
Author: user.Name,
|
Author: user.Name,
|
||||||
PublishedAt: time.Now().Format("2006-01-02"),
|
PublishedAt: time.Now().Format("2006-01-02"),
|
||||||
Visibility: VisibilityAuthenticated,
|
Visibility: VisibilityAuthenticated,
|
||||||
Status: ContentStatusDraft,
|
Status: firstStatus(payload.Status, ContentStatusDraft),
|
||||||
ImageTone: firstNonEmpty(payload.ImageTone, "from-university-800 via-slate-700 to-sky-300"),
|
ImageTone: firstNonEmpty(payload.ImageTone, "from-university-800 via-slate-700 to-sky-300"),
|
||||||
MediaURL: payload.MediaURL,
|
MediaURL: payload.MediaURL,
|
||||||
MediaKind: payload.MediaKind,
|
MediaKind: payload.MediaKind,
|
||||||
MimeType: payload.MimeType,
|
MimeType: payload.MimeType,
|
||||||
FileName: payload.FileName,
|
FileName: payload.FileName,
|
||||||
FileSize: payload.FileSize,
|
FileSize: payload.FileSize,
|
||||||
|
ModeratorComment: payload.ModeratorComment,
|
||||||
|
ReviewComment: payload.ReviewComment,
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusCreated, map[string]any{"item": backendStore.addContent(item)})
|
stored, err := backendStore.AddContent(r.Context(), item)
|
||||||
|
if err != nil {
|
||||||
|
writeStoreError(w, err)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusCreated, map[string]any{"item": stored})
|
||||||
return true
|
return true
|
||||||
case r.Method == http.MethodPatch && strings.HasPrefix(path, "/content/"):
|
case r.Method == http.MethodPatch && strings.HasPrefix(path, "/content/"):
|
||||||
if _, ok := requireAnyRole(w, r, RoleAdministrator, RoleEditor, RoleManager); !ok {
|
if _, ok := requireAnyRole(w, r, RoleAdministrator, RoleEditor, RoleManager); !ok {
|
||||||
@@ -229,7 +327,15 @@ func handleContent(w http.ResponseWriter, r *http.Request, path string) bool {
|
|||||||
if !decodeJSON(w, r, &payload) {
|
if !decodeJSON(w, r, &payload) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
item, ok := backendStore.patchContent(strings.TrimPrefix(path, "/content/"), payload)
|
payload.Type = normalizeContentType(string(payload.Type))
|
||||||
|
payload.Status = normalizeStatus(string(payload.Status))
|
||||||
|
payload.Lead = firstNonEmpty(payload.Lead, payload.Excerpt)
|
||||||
|
payload.Body = firstNonEmpty(payload.Body, payload.Content)
|
||||||
|
item, ok, err := backendStore.PatchContent(r.Context(), strings.TrimPrefix(path, "/content/"), payload)
|
||||||
|
if err != nil {
|
||||||
|
writeStoreError(w, err)
|
||||||
|
return true
|
||||||
|
}
|
||||||
if !ok {
|
if !ok {
|
||||||
writeAPIError(w, http.StatusNotFound, "NOT_FOUND", "Материал не найден")
|
writeAPIError(w, http.StatusNotFound, "NOT_FOUND", "Материал не найден")
|
||||||
return true
|
return true
|
||||||
@@ -240,7 +346,12 @@ func handleContent(w http.ResponseWriter, r *http.Request, path string) bool {
|
|||||||
if _, ok := requireRole(w, r, RoleAdministrator); !ok {
|
if _, ok := requireRole(w, r, RoleAdministrator); !ok {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if !backendStore.deleteContent(strings.TrimPrefix(path, "/content/")) {
|
deleted, err := backendStore.DeleteContent(r.Context(), strings.TrimPrefix(path, "/content/"))
|
||||||
|
if err != nil {
|
||||||
|
writeStoreError(w, err)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if !deleted {
|
||||||
writeAPIError(w, http.StatusNotFound, "NOT_FOUND", "Материал не найден")
|
writeAPIError(w, http.StatusNotFound, "NOT_FOUND", "Материал не найден")
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -255,14 +366,22 @@ func handleTaxonomy(w http.ResponseWriter, r *http.Request, path string) bool {
|
|||||||
if r.Method != http.MethodGet {
|
if r.Method != http.MethodGet {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
backendStore.mu.RLock()
|
|
||||||
defer backendStore.mu.RUnlock()
|
|
||||||
switch path {
|
switch path {
|
||||||
case "/categories":
|
case "/categories":
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"items": append([]string(nil), backendStore.Categories...)})
|
items, err := backendStore.ListCategories(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeStoreError(w, err)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||||
return true
|
return true
|
||||||
case "/tags":
|
case "/tags":
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"items": append([]string(nil), backendStore.Tags...)})
|
items, err := backendStore.ListTags(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeStoreError(w, err)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
@@ -271,9 +390,11 @@ func handleTaxonomy(w http.ResponseWriter, r *http.Request, path string) bool {
|
|||||||
|
|
||||||
func handleSpeaker(w http.ResponseWriter, r *http.Request, path string) bool {
|
func handleSpeaker(w http.ResponseWriter, r *http.Request, path string) bool {
|
||||||
if r.Method == http.MethodGet && path == "/speakers" {
|
if r.Method == http.MethodGet && path == "/speakers" {
|
||||||
backendStore.mu.RLock()
|
items, err := backendStore.ListSpeakers(r.Context())
|
||||||
items := append([]Speaker(nil), backendStore.Speakers...)
|
if err != nil {
|
||||||
backendStore.mu.RUnlock()
|
writeStoreError(w, err)
|
||||||
|
return true
|
||||||
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -283,18 +404,19 @@ func handleSpeaker(w http.ResponseWriter, r *http.Request, path string) bool {
|
|||||||
func handleMedia(w http.ResponseWriter, r *http.Request, path string) bool {
|
func handleMedia(w http.ResponseWriter, r *http.Request, path string) bool {
|
||||||
switch {
|
switch {
|
||||||
case r.Method == http.MethodGet && path == "/media":
|
case r.Method == http.MethodGet && path == "/media":
|
||||||
backendStore.mu.RLock()
|
items, err := backendStore.ListContent(r.Context(), ContentFilter{OnlyMedia: true, SortMode: "newest"})
|
||||||
items := make([]ContentItem, 0)
|
if err != nil {
|
||||||
for _, item := range backendStore.Content {
|
writeStoreError(w, err)
|
||||||
if item.Type == ContentTypeVideo || item.Type == ContentTypeAudio || item.Type == ContentTypeGraphic {
|
return true
|
||||||
items = append(items, item)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
backendStore.mu.RUnlock()
|
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||||
return true
|
return true
|
||||||
case r.Method == http.MethodGet && strings.HasPrefix(path, "/media/files/"):
|
case r.Method == http.MethodGet && strings.HasPrefix(path, "/media/files/"):
|
||||||
file, ok := backendStore.fileByID(strings.TrimPrefix(path, "/media/files/"))
|
file, ok, err := backendStore.FileByID(r.Context(), strings.TrimPrefix(path, "/media/files/"))
|
||||||
|
if err != nil {
|
||||||
|
writeStoreError(w, err)
|
||||||
|
return true
|
||||||
|
}
|
||||||
if !ok {
|
if !ok {
|
||||||
writeAPIError(w, http.StatusNotFound, "NOT_FOUND", "Файл не найден")
|
writeAPIError(w, http.StatusNotFound, "NOT_FOUND", "Файл не найден")
|
||||||
return true
|
return true
|
||||||
@@ -347,13 +469,17 @@ func handleMedia(w http.ResponseWriter, r *http.Request, path string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
stored := backendStore.addFile(StoredFile{
|
stored, err := backendStore.AddFile(r.Context(), StoredFile{
|
||||||
ID: fmt.Sprintf("demo-file-%d", time.Now().UnixNano()),
|
ID: fmt.Sprintf("demo-file-%d", time.Now().UnixNano()),
|
||||||
Name: firstNonEmpty(header.Filename, "uploaded-file"),
|
Name: firstNonEmpty(header.Filename, "uploaded-file"),
|
||||||
MimeType: mimeType,
|
MimeType: mimeType,
|
||||||
Size: int64(len(data)),
|
Size: int64(len(data)),
|
||||||
Data: data,
|
Data: data,
|
||||||
})
|
})
|
||||||
|
if err != nil {
|
||||||
|
writeStoreError(w, err)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
category := strings.TrimSpace(r.FormValue("category"))
|
category := strings.TrimSpace(r.FormValue("category"))
|
||||||
if category == "" {
|
if category == "" {
|
||||||
@@ -379,7 +505,12 @@ func handleMedia(w http.ResponseWriter, r *http.Request, path string) bool {
|
|||||||
FileName: stored.Name,
|
FileName: stored.Name,
|
||||||
FileSize: stored.Size,
|
FileSize: stored.Size,
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusCreated, map[string]any{"item": backendStore.addContent(item)})
|
created, err := backendStore.AddContent(r.Context(), item)
|
||||||
|
if err != nil {
|
||||||
|
writeStoreError(w, err)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusCreated, map[string]any{"item": created})
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
@@ -388,7 +519,12 @@ func handleMedia(w http.ResponseWriter, r *http.Request, path string) bool {
|
|||||||
func handleSearch(w http.ResponseWriter, r *http.Request, path string) bool {
|
func handleSearch(w http.ResponseWriter, r *http.Request, path string) bool {
|
||||||
if r.Method == http.MethodGet && path == "/search" {
|
if r.Method == http.MethodGet && path == "/search" {
|
||||||
query := r.URL.Query()
|
query := r.URL.Query()
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"items": backendStore.searchContent(query.Get("q"), query.Get("category"), query.Get("type"), query.Get("sort"))})
|
items, err := backendStore.ListContent(r.Context(), ContentFilter{Term: query.Get("q"), Category: query.Get("category"), ContentType: query.Get("type"), SortMode: query.Get("sort")})
|
||||||
|
if err != nil {
|
||||||
|
writeStoreError(w, err)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
@@ -417,7 +553,12 @@ func handleSubscription(w http.ResponseWriter, r *http.Request, path string) boo
|
|||||||
writeAPIError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Укажите объект подписки")
|
writeAPIError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Укажите объект подписки")
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"items": backendStore.upsertSubscription(user.ID, payload.Target)})
|
items, err := backendStore.UpsertSubscription(r.Context(), user.ID, payload.Target)
|
||||||
|
if err != nil {
|
||||||
|
writeStoreError(w, err)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
@@ -427,9 +568,11 @@ func handleSubscription(w http.ResponseWriter, r *http.Request, path string) boo
|
|||||||
func handleNotification(w http.ResponseWriter, r *http.Request, path string) bool {
|
func handleNotification(w http.ResponseWriter, r *http.Request, path string) bool {
|
||||||
switch {
|
switch {
|
||||||
case r.Method == http.MethodGet && path == "/notifications":
|
case r.Method == http.MethodGet && path == "/notifications":
|
||||||
backendStore.mu.RLock()
|
items, err := backendStore.ListNotifications(r.Context())
|
||||||
items := append([]NotificationItem(nil), backendStore.Notifications...)
|
if err != nil {
|
||||||
backendStore.mu.RUnlock()
|
writeStoreError(w, err)
|
||||||
|
return true
|
||||||
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||||
return true
|
return true
|
||||||
case r.Method == http.MethodPatch && strings.HasPrefix(path, "/notifications/") && strings.HasSuffix(path, "/read"):
|
case r.Method == http.MethodPatch && strings.HasPrefix(path, "/notifications/") && strings.HasSuffix(path, "/read"):
|
||||||
@@ -437,16 +580,11 @@ func handleNotification(w http.ResponseWriter, r *http.Request, path string) boo
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
id := strings.TrimSuffix(strings.TrimPrefix(path, "/notifications/"), "/read")
|
id := strings.TrimSuffix(strings.TrimPrefix(path, "/notifications/"), "/read")
|
||||||
backendStore.mu.Lock()
|
updated, err := backendStore.MarkNotificationRead(r.Context(), id)
|
||||||
var updated *NotificationItem
|
if err != nil {
|
||||||
for index := range backendStore.Notifications {
|
writeStoreError(w, err)
|
||||||
if backendStore.Notifications[index].ID == id {
|
return true
|
||||||
backendStore.Notifications[index].Read = true
|
|
||||||
updated = &backendStore.Notifications[index]
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
backendStore.mu.Unlock()
|
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"item": updated})
|
writeJSON(w, http.StatusOK, map[string]any{"item": updated})
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
@@ -461,14 +599,11 @@ func handleComment(w http.ResponseWriter, r *http.Request, path string) bool {
|
|||||||
contentID := strings.TrimPrefix(path, "/comments/")
|
contentID := strings.TrimPrefix(path, "/comments/")
|
||||||
switch r.Method {
|
switch r.Method {
|
||||||
case http.MethodGet:
|
case http.MethodGet:
|
||||||
backendStore.mu.RLock()
|
items, err := backendStore.ListComments(r.Context(), contentID)
|
||||||
items := make([]CommentItem, 0)
|
if err != nil {
|
||||||
for _, comment := range backendStore.Comments {
|
writeStoreError(w, err)
|
||||||
if comment.ContentID == contentID {
|
return true
|
||||||
items = append(items, comment)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
backendStore.mu.RUnlock()
|
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||||
return true
|
return true
|
||||||
case http.MethodPost:
|
case http.MethodPost:
|
||||||
@@ -486,7 +621,12 @@ func handleComment(w http.ResponseWriter, r *http.Request, path string) bool {
|
|||||||
writeAPIError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Комментарий не может быть пустым")
|
writeAPIError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Комментарий не может быть пустым")
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusCreated, map[string]any{"item": backendStore.addComment(contentID, user, strings.TrimSpace(payload.Text))})
|
item, err := backendStore.AddComment(r.Context(), contentID, user, strings.TrimSpace(payload.Text))
|
||||||
|
if err != nil {
|
||||||
|
writeStoreError(w, err)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusCreated, map[string]any{"item": item})
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
@@ -497,29 +637,69 @@ func handleAnalytics(w http.ResponseWriter, r *http.Request, path string) bool {
|
|||||||
if r.Method != http.MethodGet || (path != "/analytics/summary" && path != "/admin/dashboard") {
|
if r.Method != http.MethodGet || (path != "/analytics/summary" && path != "/admin/dashboard") {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if _, ok := requireRole(w, r, RoleAdministrator); !ok {
|
user, ok := requireAuth(w, r)
|
||||||
|
if !ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
items, err := backendStore.ListContent(r.Context(), ContentFilter{})
|
||||||
|
if err != nil {
|
||||||
|
writeStoreError(w, err)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
speakers, err := backendStore.ListSpeakers(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeStoreError(w, err)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
users, err := backendStore.ListUsers(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeStoreError(w, err)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
comments, err := backendStore.CountComments(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeStoreError(w, err)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
backendStore.mu.RLock()
|
|
||||||
defer backendStore.mu.RUnlock()
|
|
||||||
totalViews := 0
|
totalViews := 0
|
||||||
moderationQueue := 0
|
moderationQueue := 0
|
||||||
for _, item := range backendStore.Content {
|
for _, item := range items {
|
||||||
totalViews += item.Views
|
totalViews += item.Views
|
||||||
if item.Status != ContentStatusPublished {
|
if item.Status != ContentStatusPublished {
|
||||||
moderationQueue++
|
moderationQueue++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
subscribers := 0
|
subscribers := 0
|
||||||
for _, speaker := range backendStore.Speakers {
|
for _, speaker := range speakers {
|
||||||
subscribers += speaker.Subscribers
|
subscribers += speaker.Subscribers
|
||||||
}
|
}
|
||||||
if path == "/admin/dashboard" {
|
if path == "/admin/dashboard" {
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"users": len(backendStore.Users), "content": len(backendStore.Content), "moderationQueue": moderationQueue, "roles": []RoleCode{RoleAdministrator, RoleEditor, RoleManager, RoleUser}})
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"users": len(users),
|
||||||
|
"materials": len(items),
|
||||||
|
"views": totalViews,
|
||||||
|
"pending": moderationQueue,
|
||||||
|
"viewsByDay": []map[string]any{
|
||||||
|
{"label": "Пн", "value": 120},
|
||||||
|
{"label": "Вт", "value": 180},
|
||||||
|
{"label": "Ср", "value": 160},
|
||||||
|
{"label": "Чт", "value": 220},
|
||||||
|
{"label": "Пт", "value": 260},
|
||||||
|
{"label": "Сб", "value": 140},
|
||||||
|
{"label": "Вс", "value": 110},
|
||||||
|
},
|
||||||
|
"roles": []RoleCode{RoleAdministrator, RoleEditor, RoleManager, RoleUser},
|
||||||
|
})
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
popular := append([]ContentItem(nil), backendStore.Content...)
|
popular := append([]ContentItem(nil), items...)
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"totalViews": totalViews, "subscribers": subscribers, "activeUsers": len(backendStore.Users), "popular": popular})
|
materials := 0
|
||||||
|
for _, item := range items {
|
||||||
|
if item.Author == user.Name {
|
||||||
|
materials++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"materials": materials, "comments": comments, "totalViews": totalViews, "subscribers": subscribers, "activeUsers": len(users), "popular": popular})
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -528,9 +708,11 @@ func handleAudit(w http.ResponseWriter, r *http.Request, path string) bool {
|
|||||||
if _, ok := requireRole(w, r, RoleAdministrator); !ok {
|
if _, ok := requireRole(w, r, RoleAdministrator); !ok {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
backendStore.mu.RLock()
|
items, err := backendStore.ListAudit(r.Context())
|
||||||
items := append([]AuditItem(nil), backendStore.Audit...)
|
if err != nil {
|
||||||
backendStore.mu.RUnlock()
|
writeStoreError(w, err)
|
||||||
|
return true
|
||||||
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -555,11 +737,13 @@ func writeAPIError(w http.ResponseWriter, status int, code, message string) {
|
|||||||
writeJSON(w, status, payload)
|
writeJSON(w, status, payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeToken(user UserProfile) string {
|
func writeStoreError(w http.ResponseWriter, err error) {
|
||||||
payload := tokenPayload{ID: user.ID, Name: user.Name, Login: user.Login, Roles: user.Roles}
|
writeAPIError(w, http.StatusServiceUnavailable, "STORE_UNAVAILABLE", err.Error())
|
||||||
bytes, _ := json.Marshal(payload)
|
}
|
||||||
// Demo token only. Production must use signed JWT or another verified token format.
|
|
||||||
return "demo-token-" + base64.RawURLEncoding.EncodeToString(bytes)
|
func makeToken(user UserProfile) (string, error) {
|
||||||
|
payload := tokenPayload{ID: user.ID, Name: user.Name, Login: user.Login, Roles: user.Roles, ExpiresAt: time.Now().UTC().Add(tokenTTL()).Unix()}
|
||||||
|
return signToken(payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
func userFromRequest(r *http.Request) (UserProfile, bool) {
|
func userFromRequest(r *http.Request) (UserProfile, bool) {
|
||||||
@@ -568,30 +752,16 @@ func userFromRequest(r *http.Request) (UserProfile, bool) {
|
|||||||
return UserProfile{}, false
|
return UserProfile{}, false
|
||||||
}
|
}
|
||||||
token := strings.TrimPrefix(header, "Bearer ")
|
token := strings.TrimPrefix(header, "Bearer ")
|
||||||
if token == "demo-token-local-fallback" {
|
payload, err := parseToken(token)
|
||||||
user, ok := backendStore.userByLogin("demo_admin")
|
|
||||||
return user, ok
|
|
||||||
}
|
|
||||||
if !strings.HasPrefix(token, "demo-token-") {
|
|
||||||
return UserProfile{}, false
|
|
||||||
}
|
|
||||||
encoded := strings.TrimPrefix(token, "demo-token-")
|
|
||||||
bytes, err := base64.RawURLEncoding.DecodeString(encoded)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return UserProfile{}, false
|
return UserProfile{}, false
|
||||||
}
|
}
|
||||||
var payload tokenPayload
|
user, ok, err := backendStore.UserByID(r.Context(), payload.ID)
|
||||||
if err := json.Unmarshal(bytes, &payload); err != nil {
|
if err != nil {
|
||||||
return UserProfile{}, false
|
return UserProfile{}, false
|
||||||
}
|
}
|
||||||
user := UserProfile{ID: payload.ID, Name: payload.Name, Login: payload.Login, Roles: payload.Roles}
|
if !ok {
|
||||||
backendStore.mu.RLock()
|
return UserProfile{}, false
|
||||||
defer backendStore.mu.RUnlock()
|
|
||||||
for _, stored := range backendStore.Users {
|
|
||||||
if stored.ID == user.ID {
|
|
||||||
user.Subscriptions = append([]string(nil), stored.Subscriptions...)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return user, true
|
return user, true
|
||||||
}
|
}
|
||||||
@@ -632,6 +802,13 @@ func firstNonEmpty(value, fallback string) string {
|
|||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func firstStatus(value, fallback ContentStatus) ContentStatus {
|
||||||
|
if strings.TrimSpace(string(value)) == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
func inferMediaKind(mimeType, fileName string) string {
|
func inferMediaKind(mimeType, fileName string) string {
|
||||||
lowerName := strings.ToLower(fileName)
|
lowerName := strings.ToLower(fileName)
|
||||||
switch {
|
switch {
|
||||||
|
|||||||
89
services/internal/service/auth.go
Normal file
89
services/internal/service/auth.go
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
const defaultTokenTTL = 24 * time.Hour
|
||||||
|
|
||||||
|
func hashPassword(password string) (string, error) {
|
||||||
|
bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(bytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkPassword(hash, password string) error {
|
||||||
|
if strings.TrimSpace(hash) == "" {
|
||||||
|
return errors.New("password hash is empty")
|
||||||
|
}
|
||||||
|
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
|
||||||
|
}
|
||||||
|
|
||||||
|
func tokenSecret() string {
|
||||||
|
secret := strings.TrimSpace(os.Getenv("TOKEN_SECRET"))
|
||||||
|
if secret == "" {
|
||||||
|
return "local-dev-secret-change-me"
|
||||||
|
}
|
||||||
|
return secret
|
||||||
|
}
|
||||||
|
|
||||||
|
func tokenTTL() time.Duration {
|
||||||
|
raw := strings.TrimSpace(os.Getenv("TOKEN_TTL"))
|
||||||
|
if raw == "" {
|
||||||
|
return defaultTokenTTL
|
||||||
|
}
|
||||||
|
ttl, err := time.ParseDuration(raw)
|
||||||
|
if err != nil || ttl <= 0 {
|
||||||
|
return defaultTokenTTL
|
||||||
|
}
|
||||||
|
return ttl
|
||||||
|
}
|
||||||
|
|
||||||
|
func signToken(payload tokenPayload) (string, error) {
|
||||||
|
bytes, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
body := base64.RawURLEncoding.EncodeToString(bytes)
|
||||||
|
signature := computeTokenSignature(body)
|
||||||
|
return "fable-token." + body + "." + signature, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseToken(token string) (tokenPayload, error) {
|
||||||
|
parts := strings.Split(token, ".")
|
||||||
|
if len(parts) != 3 || parts[0] != "fable-token" {
|
||||||
|
return tokenPayload{}, errors.New("invalid token format")
|
||||||
|
}
|
||||||
|
if !hmac.Equal([]byte(parts[2]), []byte(computeTokenSignature(parts[1]))) {
|
||||||
|
return tokenPayload{}, errors.New("invalid token signature")
|
||||||
|
}
|
||||||
|
bytes, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||||
|
if err != nil {
|
||||||
|
return tokenPayload{}, err
|
||||||
|
}
|
||||||
|
var payload tokenPayload
|
||||||
|
if err := json.Unmarshal(bytes, &payload); err != nil {
|
||||||
|
return tokenPayload{}, err
|
||||||
|
}
|
||||||
|
if payload.ExpiresAt > 0 && time.Now().UTC().Unix() > payload.ExpiresAt {
|
||||||
|
return tokenPayload{}, errors.New("token expired")
|
||||||
|
}
|
||||||
|
return payload, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func computeTokenSignature(body string) string {
|
||||||
|
mac := hmac.New(sha256.New, []byte(tokenSecret()))
|
||||||
|
_, _ = mac.Write([]byte(body))
|
||||||
|
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||||
|
}
|
||||||
127
services/internal/service/auth_test.go
Normal file
127
services/internal/service/auth_test.go
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAuthFlowRegisterLoginAndMe(t *testing.T) {
|
||||||
|
previous := backendStore
|
||||||
|
backendStore = newTestGORMStore(t)
|
||||||
|
defer func() { backendStore = previous }()
|
||||||
|
|
||||||
|
handler := NewHandler(Config{Name: "Auth Service", Domain: "auth"})
|
||||||
|
|
||||||
|
register := performJSONRequest(t, handler, http.MethodPost, "/api/auth/register", map[string]string{
|
||||||
|
"login": "qa_user",
|
||||||
|
"password": "verysecret",
|
||||||
|
"name": "QA User",
|
||||||
|
}, "")
|
||||||
|
if register.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("register status = %d body=%s", register.Code, register.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
login := performJSONRequest(t, handler, http.MethodPost, "/api/auth/login", map[string]string{
|
||||||
|
"login": "qa_user",
|
||||||
|
"password": "verysecret",
|
||||||
|
}, "")
|
||||||
|
if login.Code != http.StatusOK {
|
||||||
|
t.Fatalf("login status = %d body=%s", login.Code, login.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
token := readTokenFromResponse(t, login.Body.Bytes())
|
||||||
|
me := performJSONRequest(t, handler, http.MethodGet, "/api/auth/me", nil, token)
|
||||||
|
if me.Code != http.StatusOK {
|
||||||
|
t.Fatalf("me status = %d body=%s", me.Code, me.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload map[string]map[string]any
|
||||||
|
if err := json.Unmarshal(me.Body.Bytes(), &payload); err != nil {
|
||||||
|
t.Fatalf("decode me response: %v", err)
|
||||||
|
}
|
||||||
|
if payload["user"]["login"] != "qa_user" {
|
||||||
|
t.Fatalf("expected qa_user, got %#v", payload["user"]["login"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthChangePassword(t *testing.T) {
|
||||||
|
previous := backendStore
|
||||||
|
backendStore = newTestGORMStore(t)
|
||||||
|
defer func() { backendStore = previous }()
|
||||||
|
|
||||||
|
handler := NewHandler(Config{Name: "Auth Service", Domain: "auth"})
|
||||||
|
|
||||||
|
login := performJSONRequest(t, handler, http.MethodPost, "/api/auth/login", map[string]string{
|
||||||
|
"login": "demo_admin",
|
||||||
|
"password": "demo_password",
|
||||||
|
}, "")
|
||||||
|
if login.Code != http.StatusOK {
|
||||||
|
t.Fatalf("initial login status = %d body=%s", login.Code, login.Body.String())
|
||||||
|
}
|
||||||
|
token := readTokenFromResponse(t, login.Body.Bytes())
|
||||||
|
|
||||||
|
change := performJSONRequest(t, handler, http.MethodPost, "/api/auth/change-password", map[string]string{
|
||||||
|
"nextPassword": "demo_password_new",
|
||||||
|
}, token)
|
||||||
|
if change.Code != http.StatusOK {
|
||||||
|
t.Fatalf("change password status = %d body=%s", change.Code, change.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
oldLogin := performJSONRequest(t, handler, http.MethodPost, "/api/auth/login", map[string]string{
|
||||||
|
"login": "demo_admin",
|
||||||
|
"password": "demo_password",
|
||||||
|
}, "")
|
||||||
|
if oldLogin.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("old password login status = %d body=%s", oldLogin.Code, oldLogin.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
newLogin := performJSONRequest(t, handler, http.MethodPost, "/api/auth/login", map[string]string{
|
||||||
|
"login": "demo_admin",
|
||||||
|
"password": "demo_password_new",
|
||||||
|
}, "")
|
||||||
|
if newLogin.Code != http.StatusOK {
|
||||||
|
t.Fatalf("new password login status = %d body=%s", newLogin.Code, newLogin.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func performJSONRequest(t *testing.T, handler http.Handler, method, path string, body any, token string) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var reader *bytes.Reader
|
||||||
|
if body == nil {
|
||||||
|
reader = bytes.NewReader(nil)
|
||||||
|
} else {
|
||||||
|
payload, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal request: %v", err)
|
||||||
|
}
|
||||||
|
reader = bytes.NewReader(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
request := httptest.NewRequest(method, path, reader)
|
||||||
|
if body != nil {
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
if token != "" {
|
||||||
|
request.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
}
|
||||||
|
handler.ServeHTTP(recorder, request)
|
||||||
|
return recorder
|
||||||
|
}
|
||||||
|
|
||||||
|
func readTokenFromResponse(t *testing.T, body []byte) string {
|
||||||
|
t.Helper()
|
||||||
|
var payload map[string]any
|
||||||
|
if err := json.Unmarshal(body, &payload); err != nil {
|
||||||
|
t.Fatalf("decode token response: %v", err)
|
||||||
|
}
|
||||||
|
token, _ := payload["token"].(string)
|
||||||
|
if token == "" {
|
||||||
|
t.Fatalf("expected token in response: %s", string(body))
|
||||||
|
}
|
||||||
|
return token
|
||||||
|
}
|
||||||
165
services/internal/service/db.go
Normal file
165
services/internal/service/db.go
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/driver/postgres"
|
||||||
|
"gorm.io/driver/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
backendStore Store = unavailableStore{err: errors.New("backend store not initialized")}
|
||||||
|
|
||||||
|
defaultStoreOnce sync.Once
|
||||||
|
defaultStoreErr error
|
||||||
|
)
|
||||||
|
|
||||||
|
func setBackendStore(store Store) {
|
||||||
|
if store == nil {
|
||||||
|
backendStore = unavailableStore{err: errors.New("backend store not initialized")}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
backendStore = store
|
||||||
|
}
|
||||||
|
|
||||||
|
func bootstrapDefaultStore() (Store, error) {
|
||||||
|
defaultStoreOnce.Do(func() {
|
||||||
|
db, err := openDBFromEnv()
|
||||||
|
if err != nil {
|
||||||
|
defaultStoreErr = err
|
||||||
|
setBackendStore(unavailableStore{err: err})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
store, err := newGORMStore(db)
|
||||||
|
if err != nil {
|
||||||
|
defaultStoreErr = err
|
||||||
|
setBackendStore(unavailableStore{err: err})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setBackendStore(store)
|
||||||
|
})
|
||||||
|
return backendStore, defaultStoreErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func openDBFromEnv() (*gorm.DB, error) {
|
||||||
|
dsn := strings.TrimSpace(os.Getenv("DATABASE_URL"))
|
||||||
|
if dsn == "" {
|
||||||
|
return nil, errors.New("DATABASE_URL is required")
|
||||||
|
}
|
||||||
|
return openDB(dsn)
|
||||||
|
}
|
||||||
|
|
||||||
|
func openDB(dsn string) (*gorm.DB, error) {
|
||||||
|
config := &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}
|
||||||
|
var (
|
||||||
|
db *gorm.DB
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
if strings.HasPrefix(dsn, "sqlite:") || strings.HasPrefix(dsn, "file:") || dsn == ":memory:" {
|
||||||
|
db, err = gorm.Open(sqlite.Open(dsn), config)
|
||||||
|
} else {
|
||||||
|
db, err = gorm.Open(postgres.Open(dsn), config)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("open database: %w", err)
|
||||||
|
}
|
||||||
|
sqlDB, err := db.DB()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("database handle: %w", err)
|
||||||
|
}
|
||||||
|
sqlDB.SetConnMaxLifetime(5 * time.Minute)
|
||||||
|
sqlDB.SetMaxIdleConns(5)
|
||||||
|
sqlDB.SetMaxOpenConns(10)
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := sqlDB.PingContext(ctx); err != nil {
|
||||||
|
return nil, fmt.Errorf("ping database: %w", err)
|
||||||
|
}
|
||||||
|
return db, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type unavailableStore struct {
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s unavailableStore) Ping(context.Context) error { return s.err }
|
||||||
|
|
||||||
|
func (s unavailableStore) UserByLogin(context.Context, string) (UserProfile, bool, error) {
|
||||||
|
return UserProfile{}, false, s.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s unavailableStore) UserByID(context.Context, string) (UserProfile, bool, error) {
|
||||||
|
return UserProfile{}, false, s.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s unavailableStore) AddUser(context.Context, string, string, string) (UserProfile, error) {
|
||||||
|
return UserProfile{}, s.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s unavailableStore) UpdatePassword(context.Context, string, string) error { return s.err }
|
||||||
|
|
||||||
|
func (s unavailableStore) ListUsers(context.Context) ([]UserProfile, error) { return nil, s.err }
|
||||||
|
|
||||||
|
func (s unavailableStore) ListContent(context.Context, ContentFilter) ([]ContentItem, error) {
|
||||||
|
return nil, s.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s unavailableStore) ContentByID(context.Context, string) (ContentItem, bool, error) {
|
||||||
|
return ContentItem{}, false, s.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s unavailableStore) AddContent(context.Context, ContentItem) (ContentItem, error) {
|
||||||
|
return ContentItem{}, s.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s unavailableStore) PatchContent(context.Context, string, ContentItem) (ContentItem, bool, error) {
|
||||||
|
return ContentItem{}, false, s.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s unavailableStore) DeleteContent(context.Context, string) (bool, error) { return false, s.err }
|
||||||
|
|
||||||
|
func (s unavailableStore) ListCategories(context.Context) ([]string, error) { return nil, s.err }
|
||||||
|
|
||||||
|
func (s unavailableStore) ListTags(context.Context) ([]string, error) { return nil, s.err }
|
||||||
|
|
||||||
|
func (s unavailableStore) ListSpeakers(context.Context) ([]Speaker, error) { return nil, s.err }
|
||||||
|
|
||||||
|
func (s unavailableStore) AddFile(context.Context, StoredFile) (StoredFile, error) {
|
||||||
|
return StoredFile{}, s.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s unavailableStore) FileByID(context.Context, string) (StoredFile, bool, error) {
|
||||||
|
return StoredFile{}, false, s.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s unavailableStore) UpsertSubscription(context.Context, string, string) ([]string, error) {
|
||||||
|
return nil, s.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s unavailableStore) ListNotifications(context.Context) ([]NotificationItem, error) {
|
||||||
|
return nil, s.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s unavailableStore) MarkNotificationRead(context.Context, string) (*NotificationItem, error) {
|
||||||
|
return nil, s.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s unavailableStore) ListComments(context.Context, string) ([]CommentItem, error) {
|
||||||
|
return nil, s.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s unavailableStore) AddComment(context.Context, string, UserProfile, string) (CommentItem, error) {
|
||||||
|
return CommentItem{}, s.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s unavailableStore) CountComments(context.Context) (int, error) { return 0, s.err }
|
||||||
|
|
||||||
|
func (s unavailableStore) ListAudit(context.Context) ([]AuditItem, error) { return nil, s.err }
|
||||||
121
services/internal/service/seed.go
Normal file
121
services/internal/service/seed.go
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type seedUser struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
Login string
|
||||||
|
Email string
|
||||||
|
Password string
|
||||||
|
Roles []RoleCode
|
||||||
|
Subscriptions []string
|
||||||
|
}
|
||||||
|
|
||||||
|
type seedContent struct {
|
||||||
|
ID string
|
||||||
|
Title string
|
||||||
|
Lead string
|
||||||
|
Body string
|
||||||
|
Type ContentType
|
||||||
|
Category string
|
||||||
|
Tags []string
|
||||||
|
AuthorLogin string
|
||||||
|
PublishedAt string
|
||||||
|
Duration string
|
||||||
|
Visibility Visibility
|
||||||
|
Status ContentStatus
|
||||||
|
Views int
|
||||||
|
ImageTone string
|
||||||
|
ModeratorComment string
|
||||||
|
ReviewComment string
|
||||||
|
}
|
||||||
|
|
||||||
|
type seedNotification struct {
|
||||||
|
ID string
|
||||||
|
UserLogin string
|
||||||
|
Title string
|
||||||
|
Description string
|
||||||
|
Read bool
|
||||||
|
CreatedAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
type seedComment struct {
|
||||||
|
ID string
|
||||||
|
ContentID string
|
||||||
|
AuthorLogin string
|
||||||
|
Text string
|
||||||
|
CreatedAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
type seedAudit struct {
|
||||||
|
ID string
|
||||||
|
ActorLogin string
|
||||||
|
Action string
|
||||||
|
Target string
|
||||||
|
CreatedAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
type seedSpeaker struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
Role string
|
||||||
|
Topics []string
|
||||||
|
Materials int
|
||||||
|
Subscribers int
|
||||||
|
}
|
||||||
|
|
||||||
|
var seedUsers = []seedUser{
|
||||||
|
{ID: "11111111-1111-1111-1111-111111111111", Name: "Демо-администратор", Login: "demo_admin", Email: "admin@dstu.ru", Password: "demo_password", Roles: []RoleCode{RoleAdministrator, RoleEditor}, Subscriptions: []string{"Новости", "Демо-спикер 01", "медиапроизводство"}},
|
||||||
|
{ID: "22222222-2222-2222-2222-222222222222", Name: "Демо-редактор", Login: "demo_editor", Email: "editor@dstu.ru", Password: "demo_password", Roles: []RoleCode{RoleEditor}, Subscriptions: []string{"Видео"}},
|
||||||
|
{ID: "33333333-3333-3333-3333-333333333333", Name: "Демо-модератор", Login: "demo_moderator", Email: "moderator@dstu.ru", Password: "demo_password", Roles: []RoleCode{RoleManager}, Subscriptions: []string{"Мероприятия"}},
|
||||||
|
{ID: "44444444-4444-4444-4444-444444444444", Name: "Демо-пользователь", Login: "demo_user", Email: "user@dstu.ru", Password: "demo_password", Roles: []RoleCode{RoleUser}, Subscriptions: []string{"Аудио"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
var seedCategories = []string{"Новости", "Статьи", "Видео", "Аудио", "Графика", "Мероприятия"}
|
||||||
|
|
||||||
|
var seedTags = []string{"медиапроизводство", "интервью", "анонс", "образование", "редакция", "архив"}
|
||||||
|
|
||||||
|
var seedSpeakers = []seedSpeaker{
|
||||||
|
{ID: "55555555-5555-5555-5555-555555555551", Name: "Демо-спикер 01", Role: "Приглашенный эксперт", Topics: []string{"медиапроизводство", "образование"}, Materials: 8, Subscribers: 132},
|
||||||
|
{ID: "55555555-5555-5555-5555-555555555552", Name: "Демо-спикер 02", Role: "Участник редакционного события", Topics: []string{"интервью", "анонс"}, Materials: 5, Subscribers: 74},
|
||||||
|
{ID: "55555555-5555-5555-5555-555555555553", Name: "Демо-спикер 03", Role: "Автор образовательных материалов", Topics: []string{"архив", "редакция"}, Materials: 12, Subscribers: 205},
|
||||||
|
}
|
||||||
|
|
||||||
|
var seedContentItems = []seedContent{
|
||||||
|
{ID: "66666666-6666-6666-6666-666666666661", Title: "Демо-новость о запуске медиаплатформы", Lead: "Публичная карточка показывает, как новости и статьи будут выглядеть в едином каталоге.", Body: "Демонстрационный материал без реальных персональных данных, подразделений и брендовых материалов.", Type: ContentTypeNews, Category: "Новости", Tags: []string{"медиапроизводство", "анонс"}, AuthorLogin: "demo_admin", PublishedAt: "2026-06-04", Visibility: VisibilityPublic, Status: ContentStatusPublished, Views: 1240, ImageTone: "from-university-700 via-university-500 to-sky-300"},
|
||||||
|
{ID: "66666666-6666-6666-6666-666666666662", Title: "Демо-видео: открытая лекция", Lead: "Видеоматериал с метаданными, статусом проверки, категорией и тегами.", Body: "В реальной системе здесь будет предпросмотр видео, CDN-ссылка, история модерации и аналитика просмотров.", Type: ContentTypeVideo, Category: "Видео", Tags: []string{"образование", "архив"}, AuthorLogin: "demo_admin", PublishedAt: "2026-06-09", Duration: "18:40", Visibility: VisibilityAuthenticated, Status: ContentStatusReview, Views: 382, ImageTone: "from-indigo-700 via-university-800 to-cyan-500"},
|
||||||
|
{ID: "66666666-6666-6666-6666-666666666663", Title: "Демо-аудио: выпуск университетского радио", Lead: "Аудиоконтент хранится в медиатеке и связывается с публикациями, авторами и тегами.", Body: "Этот пример показывает карточку аудио без использования реального названия передачи или записи.", Type: ContentTypeAudio, Category: "Аудио", Tags: []string{"интервью", "редакция"}, AuthorLogin: "demo_editor", PublishedAt: "2026-06-11", Duration: "32:10", Visibility: VisibilityPublic, Status: ContentStatusPublished, Views: 715, ImageTone: "from-blue-950 via-blue-700 to-emerald-300"},
|
||||||
|
{ID: "66666666-6666-6666-6666-666666666664", Title: "Демо-графика: афиша редакционного события", Lead: "Графические материалы можно фильтровать по типу, дате, категории и тегам.", Body: "Заглушка демонстрирует графический материал без копирования фотографий, логотипов или брендовых элементов.", Type: ContentTypeGraphic, Category: "Графика", Tags: []string{"анонс", "редакция"}, AuthorLogin: "demo_editor", PublishedAt: "2026-06-13", Visibility: VisibilityRole, Status: ContentStatusModeration, Views: 96, ImageTone: "from-sky-400 via-blue-600 to-slate-900"},
|
||||||
|
{ID: "66666666-6666-6666-6666-666666666665", Title: "Демо-анонс медиавстречи со спикером", Lead: "Мероприятия показаны как тип медиаконтента до подтверждения отдельной сущности Event.", Body: "События представлены как анонсы контента, потому что отдельная сущность Event требует подтверждения заказчиком.", Type: ContentTypeEvent, Category: "Мероприятия", Tags: []string{"анонс", "интервью"}, AuthorLogin: "demo_moderator", PublishedAt: "2026-06-17", Visibility: VisibilityPublic, Status: ContentStatusDraft, Views: 45, ImageTone: "from-university-900 via-violet-700 to-orange-300"},
|
||||||
|
}
|
||||||
|
|
||||||
|
var seedNotifications = []seedNotification{
|
||||||
|
{ID: "77777777-7777-7777-7777-777777777771", UserLogin: "demo_admin", Title: "Новый материал по подписке", Description: "В категории «Новости» появился демонстрационный материал.", Read: false, CreatedAt: "2026-06-13 10:20"},
|
||||||
|
{ID: "77777777-7777-7777-7777-777777777772", UserLogin: "demo_editor", Title: "Материал ожидает проверки", Description: "Демо-видео находится на этапе проверки перед публикацией.", Read: true, CreatedAt: "2026-06-12 16:45"},
|
||||||
|
}
|
||||||
|
|
||||||
|
var seedComments = []seedComment{
|
||||||
|
{ID: "88888888-8888-8888-8888-888888888881", ContentID: "66666666-6666-6666-6666-666666666661", AuthorLogin: "demo_user", Text: "Комментарий доступен авторизованным пользователям и может проходить модерацию.", CreatedAt: "2026-06-13 12:00"},
|
||||||
|
}
|
||||||
|
|
||||||
|
var seedAuditTrail = []seedAudit{
|
||||||
|
{ID: "99999999-9999-9999-9999-999999999991", ActorLogin: "demo_admin", Action: "изменил статус", Target: "Демо-видео: открытая лекция", CreatedAt: "2026-06-13 11:10"},
|
||||||
|
{ID: "99999999-9999-9999-9999-999999999992", ActorLogin: "demo_editor", Action: "создал черновик", Target: "Демо-анонс медиавстречи со спикером", CreatedAt: "2026-06-12 15:35"},
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustParseDate(value string) time.Time {
|
||||||
|
ts, err := time.Parse("2006-01-02", value)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return ts.UTC()
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustParseMinute(value string) time.Time {
|
||||||
|
ts, err := time.Parse("2006-01-02 15:04", value)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return ts.UTC()
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -22,6 +23,7 @@ type response struct {
|
|||||||
Service string `json:"service"`
|
Service string `json:"service"`
|
||||||
Domain string `json:"domain"`
|
Domain string `json:"domain"`
|
||||||
Capabilities []string `json:"capabilities,omitempty"`
|
Capabilities []string `json:"capabilities,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
Time time.Time `json:"time"`
|
Time time.Time `json:"time"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,6 +47,9 @@ func MustRun(cfg Config) {
|
|||||||
|
|
||||||
// Run starts an HTTP server for an internal service.
|
// Run starts an HTTP server for an internal service.
|
||||||
func Run(cfg Config) error {
|
func Run(cfg Config) error {
|
||||||
|
if _, err := bootstrapDefaultStore(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
port := EnvPort(cfg.DefaultPort)
|
port := EnvPort(cfg.DefaultPort)
|
||||||
server := &http.Server{
|
server := &http.Server{
|
||||||
Addr: ":" + port,
|
Addr: ":" + port,
|
||||||
@@ -58,6 +63,9 @@ func Run(cfg Config) error {
|
|||||||
|
|
||||||
// NewHandler returns the standard internal service HTTP API.
|
// NewHandler returns the standard internal service HTTP API.
|
||||||
func NewHandler(cfg Config) http.Handler {
|
func NewHandler(cfg Config) http.Handler {
|
||||||
|
if strings.TrimSpace(os.Getenv("DATABASE_URL")) != "" {
|
||||||
|
_, _ = bootstrapDefaultStore()
|
||||||
|
}
|
||||||
if cfg.Name == "" {
|
if cfg.Name == "" {
|
||||||
cfg.Name = "Fable Service"
|
cfg.Name = "Fable Service"
|
||||||
}
|
}
|
||||||
@@ -67,22 +75,42 @@ func NewHandler(cfg Config) http.Handler {
|
|||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||||
writeJSON(w, http.StatusOK, response{
|
store := backendStore
|
||||||
|
status := http.StatusOK
|
||||||
|
payload := response{
|
||||||
Status: "ok",
|
Status: "ok",
|
||||||
Service: cfg.Name,
|
Service: cfg.Name,
|
||||||
Domain: cfg.Domain,
|
Domain: cfg.Domain,
|
||||||
Capabilities: cfg.Capabilities,
|
Capabilities: cfg.Capabilities,
|
||||||
Time: time.Now().UTC(),
|
Time: time.Now().UTC(),
|
||||||
})
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := store.Ping(ctx); err != nil {
|
||||||
|
status = http.StatusServiceUnavailable
|
||||||
|
payload.Status = "degraded"
|
||||||
|
payload.Error = err.Error()
|
||||||
|
}
|
||||||
|
writeJSON(w, status, payload)
|
||||||
})
|
})
|
||||||
mux.HandleFunc("/ready", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/ready", func(w http.ResponseWriter, r *http.Request) {
|
||||||
writeJSON(w, http.StatusOK, response{
|
store := backendStore
|
||||||
|
status := http.StatusOK
|
||||||
|
payload := response{
|
||||||
Status: "ready",
|
Status: "ready",
|
||||||
Service: cfg.Name,
|
Service: cfg.Name,
|
||||||
Domain: cfg.Domain,
|
Domain: cfg.Domain,
|
||||||
Capabilities: cfg.Capabilities,
|
Capabilities: cfg.Capabilities,
|
||||||
Time: time.Now().UTC(),
|
Time: time.Now().UTC(),
|
||||||
})
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := store.Ping(ctx); err != nil {
|
||||||
|
status = http.StatusServiceUnavailable
|
||||||
|
payload.Status = "not_ready"
|
||||||
|
payload.Error = err.Error()
|
||||||
|
}
|
||||||
|
writeJSON(w, status, payload)
|
||||||
})
|
})
|
||||||
mux.HandleFunc("/metadata", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/metadata", func(w http.ResponseWriter, r *http.Request) {
|
||||||
writeJSON(w, http.StatusOK, cfg)
|
writeJSON(w, http.StatusOK, cfg)
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestHealthEndpoint(t *testing.T) {
|
func TestHealthEndpoint(t *testing.T) {
|
||||||
|
previous := backendStore
|
||||||
|
backendStore = newTestGORMStore(t)
|
||||||
|
defer func() { backendStore = previous }()
|
||||||
|
|
||||||
handler := NewHandler(Config{Name: "Test Service", Domain: "test", Capabilities: []string{"health"}})
|
handler := NewHandler(Config{Name: "Test Service", Domain: "test", Capabilities: []string{"health"}})
|
||||||
recorder := httptest.NewRecorder()
|
recorder := httptest.NewRecorder()
|
||||||
request := httptest.NewRequest(http.MethodGet, "/health", nil)
|
request := httptest.NewRequest(http.MethodGet, "/health", nil)
|
||||||
|
|||||||
@@ -1,229 +1,44 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import "context"
|
||||||
"fmt"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
var backendStore = newDemoStore()
|
type ContentFilter struct {
|
||||||
|
Term string
|
||||||
type demoStore struct {
|
Category string
|
||||||
mu sync.RWMutex
|
ContentType string
|
||||||
Users []UserProfile
|
SortMode string
|
||||||
Content []ContentItem
|
Status string
|
||||||
Speakers []Speaker
|
AuthorID string
|
||||||
Categories []string
|
Mine bool
|
||||||
Tags []string
|
User UserProfile
|
||||||
Notifications []NotificationItem
|
Exclude string
|
||||||
Comments []CommentItem
|
Limit int
|
||||||
Audit []AuditItem
|
OnlyEvents bool
|
||||||
Files map[string]StoredFile
|
OnlyMedia bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func newDemoStore() *demoStore {
|
type Store interface {
|
||||||
return &demoStore{
|
Ping(ctx context.Context) error
|
||||||
Users: []UserProfile{
|
UserByLogin(ctx context.Context, login string) (UserProfile, bool, error)
|
||||||
{ID: "demo-user-1", Name: "Демо-администратор", Login: "demo_admin", Roles: []RoleCode{RoleAdministrator, RoleEditor}, Subscriptions: []string{"Новости", "Демо-спикер 01", "медиапроизводство"}},
|
UserByID(ctx context.Context, id string) (UserProfile, bool, error)
|
||||||
{ID: "demo-user-2", Name: "Демо-редактор", Login: "demo_editor", Roles: []RoleCode{RoleEditor}, Subscriptions: []string{"Видео"}},
|
AddUser(ctx context.Context, login, name, password string) (UserProfile, error)
|
||||||
{ID: "demo-user-3", Name: "Демо-пользователь", Login: "demo_user", Roles: []RoleCode{RoleUser}, Subscriptions: []string{"Аудио"}},
|
UpdatePassword(ctx context.Context, userID, password string) error
|
||||||
},
|
ListUsers(ctx context.Context) ([]UserProfile, error)
|
||||||
Content: []ContentItem{
|
ListContent(ctx context.Context, filter ContentFilter) ([]ContentItem, error)
|
||||||
{ID: "demo-news-1", Title: "Демо-новость о запуске медиаплатформы", Lead: "Публичная карточка показывает, как новости и статьи будут выглядеть в едином каталоге.", Body: "Демонстрационный материал без реальных персональных данных, подразделений и брендовых материалов.", Type: ContentTypeNews, Category: "Новости", Tags: []string{"медиапроизводство", "анонс"}, Author: "Демо-редакция", PublishedAt: "2026-06-04", Visibility: VisibilityPublic, Status: ContentStatusPublished, Views: 1240, ImageTone: "from-university-700 via-university-500 to-sky-300"},
|
ContentByID(ctx context.Context, id string) (ContentItem, bool, error)
|
||||||
{ID: "demo-video-1", Title: "Демо-видео: открытая лекция", Lead: "Видеоматериал с метаданными, статусом проверки, категорией и тегами.", Body: "В реальной системе здесь будет предпросмотр видео, CDN-ссылка, история модерации и аналитика просмотров.", Type: ContentTypeVideo, Category: "Видео", Tags: []string{"образование", "архив"}, Author: "Демо-медиагруппа", PublishedAt: "2026-06-09", Duration: "18:40", Visibility: VisibilityAuthenticated, Status: ContentStatusReview, Views: 382, ImageTone: "from-indigo-700 via-university-800 to-cyan-500"},
|
AddContent(ctx context.Context, item ContentItem) (ContentItem, error)
|
||||||
{ID: "demo-audio-1", Title: "Демо-аудио: выпуск университетского радио", Lead: "Аудиоконтент хранится в медиатеке и связывается с публикациями, авторами и тегами.", Body: "Этот пример показывает карточку аудио без использования реального названия передачи или записи.", Type: ContentTypeAudio, Category: "Аудио", Tags: []string{"интервью", "редакция"}, Author: "Демо-редактор", PublishedAt: "2026-06-11", Duration: "32:10", Visibility: VisibilityPublic, Status: ContentStatusPublished, Views: 715, ImageTone: "from-blue-950 via-blue-700 to-emerald-300"},
|
PatchContent(ctx context.Context, id string, patch ContentItem) (ContentItem, bool, error)
|
||||||
{ID: "demo-graphic-1", Title: "Демо-графика: афиша редакционного события", Lead: "Графические материалы можно фильтровать по типу, дате, категории и тегам.", Body: "Заглушка демонстрирует графический материал без копирования фотографий, логотипов или брендовых элементов.", Type: ContentTypeGraphic, Category: "Графика", Tags: []string{"анонс", "редакция"}, Author: "Демо-дизайнер", PublishedAt: "2026-06-13", Visibility: VisibilityRole, Status: ContentStatusModeration, Views: 96, ImageTone: "from-sky-400 via-blue-600 to-slate-900"},
|
DeleteContent(ctx context.Context, id string) (bool, error)
|
||||||
{ID: "demo-event-1", Title: "Демо-анонс медиавстречи со спикером", Lead: "Мероприятия показаны как тип медиаконтента до подтверждения отдельной сущности Event.", Body: "События представлены как анонсы контента, потому что отдельная сущность Event требует подтверждения заказчиком.", Type: ContentTypeEvent, Category: "Мероприятия", Tags: []string{"анонс", "интервью"}, Author: "Демо-менеджер", PublishedAt: "2026-06-17", Visibility: VisibilityPublic, Status: ContentStatusDraft, Views: 45, ImageTone: "from-university-900 via-violet-700 to-orange-300"},
|
ListCategories(ctx context.Context) ([]string, error)
|
||||||
},
|
ListTags(ctx context.Context) ([]string, error)
|
||||||
Speakers: []Speaker{
|
ListSpeakers(ctx context.Context) ([]Speaker, error)
|
||||||
{ID: "demo-speaker-1", Name: "Демо-спикер 01", Role: "Приглашенный эксперт", Topics: []string{"медиапроизводство", "образование"}, Materials: 8, Subscribers: 132},
|
AddFile(ctx context.Context, file StoredFile) (StoredFile, error)
|
||||||
{ID: "demo-speaker-2", Name: "Демо-спикер 02", Role: "Участник редакционного события", Topics: []string{"интервью", "анонс"}, Materials: 5, Subscribers: 74},
|
FileByID(ctx context.Context, id string) (StoredFile, bool, error)
|
||||||
{ID: "demo-speaker-3", Name: "Демо-спикер 03", Role: "Автор образовательных материалов", Topics: []string{"архив", "редакция"}, Materials: 12, Subscribers: 205},
|
UpsertSubscription(ctx context.Context, userID, target string) ([]string, error)
|
||||||
},
|
ListNotifications(ctx context.Context) ([]NotificationItem, error)
|
||||||
Categories: []string{"Новости", "Статьи", "Видео", "Аудио", "Графика", "Мероприятия"},
|
MarkNotificationRead(ctx context.Context, id string) (*NotificationItem, error)
|
||||||
Tags: []string{"медиапроизводство", "интервью", "анонс", "образование", "редакция", "архив"},
|
ListComments(ctx context.Context, contentID string) ([]CommentItem, error)
|
||||||
Notifications: []NotificationItem{
|
AddComment(ctx context.Context, contentID string, user UserProfile, text string) (CommentItem, error)
|
||||||
{ID: "demo-notification-1", Title: "Новый материал по подписке", Description: "В категории «Новости» появился демонстрационный материал.", Read: false, CreatedAt: "2026-06-13 10:20"},
|
CountComments(ctx context.Context) (int, error)
|
||||||
{ID: "demo-notification-2", Title: "Материал ожидает проверки", Description: "Демо-видео находится на этапе проверки перед публикацией.", Read: true, CreatedAt: "2026-06-12 16:45"},
|
ListAudit(ctx context.Context) ([]AuditItem, error)
|
||||||
},
|
|
||||||
Comments: []CommentItem{
|
|
||||||
{ID: "demo-comment-1", ContentID: "demo-news-1", Author: "Демо-пользователь", Text: "Комментарий доступен авторизованным пользователям и может проходить модерацию.", CreatedAt: "2026-06-13 12:00"},
|
|
||||||
},
|
|
||||||
Audit: []AuditItem{
|
|
||||||
{ID: "demo-audit-1", Actor: "Демо-администратор", Action: "изменил статус", Target: "Демо-видео: открытая лекция", CreatedAt: "2026-06-13 11:10"},
|
|
||||||
{ID: "demo-audit-2", Actor: "Демо-редактор", Action: "создал черновик", Target: "Демо-анонс медиавстречи со спикером", CreatedAt: "2026-06-12 15:35"},
|
|
||||||
},
|
|
||||||
Files: map[string]StoredFile{},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *demoStore) userByLogin(login string) (UserProfile, bool) {
|
|
||||||
s.mu.RLock()
|
|
||||||
defer s.mu.RUnlock()
|
|
||||||
for _, user := range s.Users {
|
|
||||||
if user.Login == login {
|
|
||||||
return user, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return UserProfile{}, false
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *demoStore) upsertSubscription(userID, target string) []string {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
for index := range s.Users {
|
|
||||||
if s.Users[index].ID == userID {
|
|
||||||
for _, subscription := range s.Users[index].Subscriptions {
|
|
||||||
if subscription == target {
|
|
||||||
return append([]string(nil), s.Users[index].Subscriptions...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
s.Users[index].Subscriptions = append(s.Users[index].Subscriptions, target)
|
|
||||||
return append([]string(nil), s.Users[index].Subscriptions...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *demoStore) addUser(login, name string) UserProfile {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
user := UserProfile{ID: fmt.Sprintf("demo-user-%d", time.Now().UnixNano()), Name: name, Login: login, Roles: []RoleCode{RoleUser}, Subscriptions: []string{}}
|
|
||||||
s.Users = append(s.Users, user)
|
|
||||||
return user
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *demoStore) addContent(item ContentItem) ContentItem {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
s.Content = append([]ContentItem{item}, s.Content...)
|
|
||||||
s.Audit = append([]AuditItem{{ID: fmt.Sprintf("demo-audit-%d", time.Now().UnixNano()), Actor: item.Author, Action: "создал черновик", Target: item.Title, CreatedAt: time.Now().Format("2006-01-02 15:04")}}, s.Audit...)
|
|
||||||
return item
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *demoStore) addFile(file StoredFile) StoredFile {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
s.Files[file.ID] = file
|
|
||||||
return file
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *demoStore) fileByID(id string) (StoredFile, bool) {
|
|
||||||
s.mu.RLock()
|
|
||||||
defer s.mu.RUnlock()
|
|
||||||
file, ok := s.Files[id]
|
|
||||||
return file, ok
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *demoStore) patchContent(id string, patch ContentItem) (ContentItem, bool) {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
for index, item := range s.Content {
|
|
||||||
if item.ID != id {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if patch.Title != "" {
|
|
||||||
item.Title = patch.Title
|
|
||||||
}
|
|
||||||
if patch.Lead != "" {
|
|
||||||
item.Lead = patch.Lead
|
|
||||||
}
|
|
||||||
if patch.Body != "" {
|
|
||||||
item.Body = patch.Body
|
|
||||||
}
|
|
||||||
if patch.Type != "" {
|
|
||||||
item.Type = patch.Type
|
|
||||||
}
|
|
||||||
if patch.Category != "" {
|
|
||||||
item.Category = patch.Category
|
|
||||||
}
|
|
||||||
if patch.Tags != nil {
|
|
||||||
item.Tags = patch.Tags
|
|
||||||
}
|
|
||||||
if patch.Visibility != "" {
|
|
||||||
item.Visibility = patch.Visibility
|
|
||||||
}
|
|
||||||
if patch.Status != "" {
|
|
||||||
item.Status = patch.Status
|
|
||||||
}
|
|
||||||
if patch.MediaURL != "" {
|
|
||||||
item.MediaURL = patch.MediaURL
|
|
||||||
}
|
|
||||||
if patch.MediaKind != "" {
|
|
||||||
item.MediaKind = patch.MediaKind
|
|
||||||
}
|
|
||||||
if patch.MimeType != "" {
|
|
||||||
item.MimeType = patch.MimeType
|
|
||||||
}
|
|
||||||
if patch.FileName != "" {
|
|
||||||
item.FileName = patch.FileName
|
|
||||||
}
|
|
||||||
if patch.FileSize != 0 {
|
|
||||||
item.FileSize = patch.FileSize
|
|
||||||
}
|
|
||||||
s.Content[index] = item
|
|
||||||
return item, true
|
|
||||||
}
|
|
||||||
return ContentItem{}, false
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *demoStore) deleteContent(id string) bool {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
for index, item := range s.Content {
|
|
||||||
if item.ID == id {
|
|
||||||
s.Content = append(s.Content[:index], s.Content[index+1:]...)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *demoStore) contentByID(id string) (ContentItem, bool) {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
for index, item := range s.Content {
|
|
||||||
if item.ID == id {
|
|
||||||
s.Content[index].Views++
|
|
||||||
return s.Content[index], true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ContentItem{}, false
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *demoStore) searchContent(term, category, contentType, sortMode string) []ContentItem {
|
|
||||||
s.mu.RLock()
|
|
||||||
defer s.mu.RUnlock()
|
|
||||||
term = strings.ToLower(strings.TrimSpace(term))
|
|
||||||
items := make([]ContentItem, 0, len(s.Content))
|
|
||||||
for _, item := range s.Content {
|
|
||||||
joined := strings.ToLower(strings.Join(append([]string{item.Title, item.Lead, item.Body, item.Author, item.Category, string(item.Status), string(item.Type)}, item.Tags...), " "))
|
|
||||||
if term != "" && !strings.Contains(joined, term) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if category != "" && category != "Все" && item.Category != category {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if contentType != "" && string(item.Type) != contentType {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
items = append(items, item)
|
|
||||||
}
|
|
||||||
sort.SliceStable(items, func(i, j int) bool {
|
|
||||||
if sortMode == "newest" {
|
|
||||||
return items[i].PublishedAt > items[j].PublishedAt
|
|
||||||
}
|
|
||||||
return items[i].Views > items[j].Views
|
|
||||||
})
|
|
||||||
return items
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *demoStore) addComment(contentID string, user UserProfile, text string) CommentItem {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
comment := CommentItem{ID: fmt.Sprintf("demo-comment-%d", time.Now().UnixNano()), ContentID: contentID, Author: user.Name, Text: text, CreatedAt: time.Now().Format("2006-01-02 15:04")}
|
|
||||||
s.Comments = append([]CommentItem{comment}, s.Comments...)
|
|
||||||
return comment
|
|
||||||
}
|
}
|
||||||
|
|||||||
1238
services/internal/service/store_gorm.go
Normal file
1238
services/internal/service/store_gorm.go
Normal file
File diff suppressed because it is too large
Load Diff
42
services/internal/service/store_gorm_test.go
Normal file
42
services/internal/service/store_gorm_test.go
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestBootstrapStoreLoadsSeededUsersAndContent(t *testing.T) {
|
||||||
|
store := newTestGORMStore(t)
|
||||||
|
|
||||||
|
user, ok, err := store.UserByLogin(t.Context(), "demo_admin")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("user by login: %v", err)
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected seeded admin user")
|
||||||
|
}
|
||||||
|
if user.Name == "" {
|
||||||
|
t.Fatal("expected seeded admin profile")
|
||||||
|
}
|
||||||
|
|
||||||
|
items, err := store.ListContent(t.Context(), ContentFilter{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list content: %v", err)
|
||||||
|
}
|
||||||
|
if len(items) == 0 {
|
||||||
|
t.Fatal("expected seeded content")
|
||||||
|
}
|
||||||
|
if items[0].ID == "" {
|
||||||
|
t.Fatal("expected persisted content ids")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestGORMStore(t *testing.T) *gormStore {
|
||||||
|
t.Helper()
|
||||||
|
db, err := openDB("file::memory:?cache=shared")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open test db: %v", err)
|
||||||
|
}
|
||||||
|
store, err := newGORMStore(db)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("new gorm store: %v", err)
|
||||||
|
}
|
||||||
|
return store
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ const (
|
|||||||
ContentStatusModeration ContentStatus = "На модерации"
|
ContentStatusModeration ContentStatus = "На модерации"
|
||||||
ContentStatusReview ContentStatus = "На проверке"
|
ContentStatusReview ContentStatus = "На проверке"
|
||||||
ContentStatusPublished ContentStatus = "Опубликовано"
|
ContentStatusPublished ContentStatus = "Опубликовано"
|
||||||
|
ContentStatusReturned ContentStatus = "Возвращен"
|
||||||
ContentStatusArchived ContentStatus = "Архив"
|
ContentStatusArchived ContentStatus = "Архив"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -39,25 +40,33 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type ContentItem struct {
|
type ContentItem struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Lead string `json:"lead"`
|
Lead string `json:"lead"`
|
||||||
Body string `json:"body"`
|
Body string `json:"body"`
|
||||||
Type ContentType `json:"type"`
|
Excerpt string `json:"excerpt,omitempty"`
|
||||||
Category string `json:"category"`
|
Content string `json:"content,omitempty"`
|
||||||
Tags []string `json:"tags"`
|
Type ContentType `json:"type"`
|
||||||
Author string `json:"author"`
|
Category string `json:"category"`
|
||||||
PublishedAt string `json:"publishedAt"`
|
Tags []string `json:"tags"`
|
||||||
Duration string `json:"duration,omitempty"`
|
Author string `json:"author"`
|
||||||
Visibility Visibility `json:"visibility"`
|
PublishedAt string `json:"publishedAt"`
|
||||||
Status ContentStatus `json:"status"`
|
Duration string `json:"duration,omitempty"`
|
||||||
Views int `json:"views"`
|
Visibility Visibility `json:"visibility"`
|
||||||
ImageTone string `json:"imageTone"`
|
Status ContentStatus `json:"status"`
|
||||||
MediaURL string `json:"mediaUrl,omitempty"`
|
Views int `json:"views"`
|
||||||
MediaKind string `json:"mediaKind,omitempty"`
|
ImageTone string `json:"imageTone"`
|
||||||
MimeType string `json:"mimeType,omitempty"`
|
MediaURL string `json:"mediaUrl,omitempty"`
|
||||||
FileName string `json:"fileName,omitempty"`
|
MediaKind string `json:"mediaKind,omitempty"`
|
||||||
FileSize int64 `json:"fileSize,omitempty"`
|
MimeType string `json:"mimeType,omitempty"`
|
||||||
|
FileName string `json:"fileName,omitempty"`
|
||||||
|
FileSize int64 `json:"fileSize,omitempty"`
|
||||||
|
ModeratorComment string `json:"moderatorComment,omitempty"`
|
||||||
|
ReviewComment string `json:"reviewComment,omitempty"`
|
||||||
|
Rating int `json:"rating,omitempty"`
|
||||||
|
RatingAverage float64 `json:"ratingAverage,omitempty"`
|
||||||
|
RatingCount int `json:"ratingCount,omitempty"`
|
||||||
|
MyRating int `json:"myRating,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type StoredFile struct {
|
type StoredFile struct {
|
||||||
@@ -81,6 +90,7 @@ type UserProfile struct {
|
|||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Login string `json:"login"`
|
Login string `json:"login"`
|
||||||
|
PasswordHash string `json:"-"`
|
||||||
Roles []RoleCode `json:"roles"`
|
Roles []RoleCode `json:"roles"`
|
||||||
Subscriptions []string `json:"subscriptions"`
|
Subscriptions []string `json:"subscriptions"`
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user