1
0
forked from mixa/67

3 Commits
main ... main

Author SHA1 Message Date
mixa
c78212263b real back 2026-06-22 22:39:08 +03:00
27600872a8 Merge pull request 'Replace web app with DSTU media frontend' (#1) from sovaTyT/67:codex/dstu-media-frontend into main
Reviewed-on: mixa/67#1
2026-06-22 20:28:42 +03:00
Угрюмов Алексей
cc12011ce2 Replace web app with DSTU media frontend 2026-06-22 20:13:14 +03:00
88 changed files with 15139 additions and 4236 deletions

View File

@@ -1,6 +1,9 @@
POSTGRES_DB=fable
POSTGRES_USER=fable
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
WEB_PORT=5173

View File

@@ -16,14 +16,14 @@
Все данные в интерфейсе и API являются явно демонстрационными заглушками. Реальные люди, подразделения, интеграции, юридические сведения и брендовые материалы не добавлялись.
Backend сейчас использует in-memory demo store внутри Go-сервисов. PostgreSQL schema и Docker PostgreSQL подготовлены, но постоянное хранение через БД остается следующим этапом.
Backend теперь использует PostgreSQL через GORM в Go-сервисах. Демо-данные остаются только как seed-набор для локального запуска и проверки сценариев.
## Локальный запуск
Установить зависимости:
```bash
npm install
pnpm install
```
Запустить весь проект без Docker:
@@ -40,13 +40,15 @@ npm run dev
npm run dev:backend
```
Для этого локально должен быть доступен PostgreSQL на `127.0.0.1:5432` или должен быть задан `DATABASE_URL`.
Запустить только gateway с fallback demo API:
```bash
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:
@@ -60,7 +62,13 @@ npm run dev:web
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, если он понадобится позже:
@@ -82,7 +90,7 @@ Internal services health through gateway: `http://localhost:3000/api/services/he
Пароль: `demo_password`
Auth service принимает демо-учетную запись и возвращает demo token. Это не production-аутентификация; для промышленного контура нужен подписанный JWT или другой проверяемый token format.
Auth service принимает демо-учетную запись из seed-данных, проверяет хэш пароля и выдает подписанный token для локального backend-контура.
## Backend API

View File

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

1
apps/web/.env.example Normal file
View File

@@ -0,0 +1 @@
VITE_API_URL=http://localhost:8000/api

9
apps/web/.gitignore vendored Normal file
View File

@@ -0,0 +1,9 @@
node_modules
dist
coverage
.env
.DS_Store
*.local
*.log
*.tsbuildinfo
.backend_known_hosts

3
apps/web/.npmrc Normal file
View File

@@ -0,0 +1,3 @@
save-exact=true
strict-peer-dependencies=true
auto-install-peers=false

6
apps/web/.prettierrc Normal file
View File

@@ -0,0 +1,6 @@
{
"semi": true,
"singleQuote": false,
"trailingComma": "all",
"printWidth": 88
}

156
apps/web/DESIGN.md Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

104
apps/web/README.md Normal file
View 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
View 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 },
],
},
},
];

View File

@@ -5,12 +5,22 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
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>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

View File

@@ -1,26 +1,49 @@
{
"name": "@fable/web",
"private": true,
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "tsc --noEmit && vite build",
"preview": "vite preview --host 0.0.0.0"
"build": "vite build",
"preview": "vite preview --host 0.0.0.0",
"check": "eslint .",
"lint": "eslint .",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"react": "^19.1.0",
"react-dom": "^19.1.0"
"@hookform/resolvers": "5.2.2",
"@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": {
"@types/node": "^24.0.0",
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.0",
"@vitejs/plugin-react": "^5.0.0",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"typescript": "^5.9.0",
"vite": "^8.0.16"
"@eslint/js": "9.36.0",
"@tailwindcss/vite": "4.1.13",
"@testing-library/jest-dom": "6.8.0",
"@testing-library/react": "16.3.0",
"@testing-library/user-event": "14.6.1",
"@vitejs/plugin-react": "5.0.4",
"autoprefixer": "10.4.21",
"eslint": "9.36.0",
"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"
}
}

View File

@@ -1,6 +0,0 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};

File diff suppressed because it is too large Load Diff

162
apps/web/src/app/App.jsx Normal file
View 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>
);
}

View 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();
});
});

View 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>
);
}

View 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>
);
}

View 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 });
});
}

View File

@@ -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>
);
}

View File

@@ -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']
}
];

View File

@@ -1,140 +1,3 @@
@tailwind base;
@tailwind components;
@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
View 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>,
);

View File

@@ -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>
);

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
</>
);
}

View 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>
);
}

View 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();
});
});

View 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>
);
}

View 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>
);
}

View 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();
});
});

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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 };

View 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),
};

View 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);
}

View 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],
};

View File

@@ -0,0 +1,6 @@
import { clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs) {
return twMerge(clsx(inputs));
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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}
/>
);
}

View 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>
);
}

View 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>;
}

View 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
View 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;
}
}

View 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>);
}

View 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,
}),
});

View File

@@ -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;
};

View File

@@ -1 +0,0 @@
/// <reference types="vite/client" />

View File

@@ -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: []
};

View File

@@ -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
View 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,
},
});

View File

@@ -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
}
}
}
});

View File

@@ -2,13 +2,13 @@ CREATE EXTENSION IF NOT EXISTS pgcrypto;
DO $$
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;
END $$;
DO $$
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;
END $$;
@@ -76,6 +76,9 @@ CREATE TABLE IF NOT EXISTS speakers (
display_name TEXT NOT NULL,
role_description 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(),
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_label TEXT,
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,
archived_at TIMESTAMPTZ,
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()
);
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 (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,

View File

@@ -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:
postgres:
image: postgres:17-alpine
@@ -61,8 +67,7 @@ services:
dockerfile: infra/docker/go-service.Dockerfile
args:
SERVICE: auth
environment:
PORT: 8080
environment: *go-service-env
user-service:
build:
@@ -70,8 +75,7 @@ services:
dockerfile: infra/docker/go-service.Dockerfile
args:
SERVICE: user
environment:
PORT: 8080
environment: *go-service-env
content-service:
build:
@@ -79,8 +83,7 @@ services:
dockerfile: infra/docker/go-service.Dockerfile
args:
SERVICE: content
environment:
PORT: 8080
environment: *go-service-env
taxonomy-service:
build:
@@ -88,8 +91,7 @@ services:
dockerfile: infra/docker/go-service.Dockerfile
args:
SERVICE: taxonomy
environment:
PORT: 8080
environment: *go-service-env
speaker-service:
build:
@@ -97,8 +99,7 @@ services:
dockerfile: infra/docker/go-service.Dockerfile
args:
SERVICE: speaker
environment:
PORT: 8080
environment: *go-service-env
subscription-service:
build:
@@ -106,8 +107,7 @@ services:
dockerfile: infra/docker/go-service.Dockerfile
args:
SERVICE: subscription
environment:
PORT: 8080
environment: *go-service-env
notification-service:
build:
@@ -115,8 +115,7 @@ services:
dockerfile: infra/docker/go-service.Dockerfile
args:
SERVICE: notification
environment:
PORT: 8080
environment: *go-service-env
comment-service:
build:
@@ -124,8 +123,7 @@ services:
dockerfile: infra/docker/go-service.Dockerfile
args:
SERVICE: comment
environment:
PORT: 8080
environment: *go-service-env
search-service:
build:
@@ -133,8 +131,7 @@ services:
dockerfile: infra/docker/go-service.Dockerfile
args:
SERVICE: search
environment:
PORT: 8080
environment: *go-service-env
analytics-service:
build:
@@ -142,8 +139,7 @@ services:
dockerfile: infra/docker/go-service.Dockerfile
args:
SERVICE: analytics
environment:
PORT: 8080
environment: *go-service-env
audit-service:
build:
@@ -151,8 +147,7 @@ services:
dockerfile: infra/docker/go-service.Dockerfile
args:
SERVICE: audit
environment:
PORT: 8080
environment: *go-service-env
media-service:
build:
@@ -160,8 +155,7 @@ services:
dockerfile: infra/docker/go-service.Dockerfile
args:
SERVICE: media
environment:
PORT: 8080
environment: *go-service-env
volumes:
postgres_data:

View 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).

View File

@@ -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 должен быть совместим с результатом, но крупные визуальные доработки не входят в критический путь этой спецификации.

5504
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,16 +3,13 @@
"private": true,
"version": "0.1.0",
"description": "Университетская платформа управления медиаконтентом по plan.md",
"workspaces": [
"apps/*"
],
"scripts": {
"dev": "bash scripts/dev.sh",
"dev:web": "npm --workspace @fable/web run dev",
"build:web": "npm --workspace @fable/web run build",
"dev:web": "cd apps/web && bash node_modules/.bin/vite --host 0.0.0.0",
"build:web": "cd apps/web && bash node_modules/.bin/vite build",
"dev:backend": "bash scripts/dev-backend.sh",
"dev:gateway": "npm --workspace @fable/gateway run dev",
"check:gateway": "npm --workspace @fable/gateway run check",
"dev:gateway": "cd apps/gateway && bash node_modules/.bin/tsc && node --watch dist/index.js",
"check:gateway": "cd apps/gateway && bash node_modules/.bin/tsc --noEmit",
"test:go": "cd services && go test ./...",
"check": "npm run build:web && npm run check:gateway && npm run test:go"
}

3620
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

10
pnpm-workspace.yaml Normal file
View 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"

View File

@@ -3,6 +3,9 @@ set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
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"
pids=()
@@ -21,7 +24,7 @@ start_service() {
local name="$1"
local port="$2"
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+=("$!")
}

View File

@@ -1,3 +1,23 @@
module fable/services
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
View 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=

View File

@@ -2,7 +2,6 @@ package service
import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"io"
@@ -16,6 +15,7 @@ type tokenPayload struct {
Name string `json:"name"`
Login string `json:"login"`
Roles []RoleCode `json:"roles"`
ExpiresAt int64 `json:"exp"`
}
type apiError struct {
@@ -25,6 +25,36 @@ type apiError struct {
} `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 {
path := strings.TrimPrefix(r.URL.Path, "/api")
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":
var payload struct {
Login string `json:"login"`
Email string `json:"email"`
Password string `json:"password"`
}
if !decodeJSON(w, r, &payload) {
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", "Укажите логин и пароль")
return true
}
user, ok := backendStore.userByLogin(payload.Login)
if !ok {
user, _ = backendStore.userByLogin("demo_admin")
user, ok, err := backendStore.UserByLogin(r.Context(), login)
if err != nil {
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
case r.Method == http.MethodPost && path == "/auth/register":
var payload struct {
Login string `json:"login"`
Email string `json:"email"`
Password string `json:"password"`
Name string `json:"name"`
}
if !decodeJSON(w, r, &payload) {
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 символов")
return true
}
@@ -98,8 +146,17 @@ func handleAuth(w http.ResponseWriter, r *http.Request, path string) bool {
if name == "" {
name = "Демо-пользователь"
}
user := backendStore.addUser(payload.Login, name)
writeJSON(w, http.StatusCreated, map[string]any{"token": makeToken(user), "user": user})
user, err := backendStore.AddUser(r.Context(), login, name, payload.Password)
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
case r.Method == http.MethodGet && path == "/auth/me":
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})
return true
case r.Method == http.MethodPost && path == "/auth/change-password":
if _, ok := requireAuth(w, r); !ok {
user, ok := requireAuth(w, r)
if !ok {
return true
}
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 символов")
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})
return true
default:
@@ -138,9 +200,11 @@ func handleUser(w http.ResponseWriter, r *http.Request, path string) bool {
if _, ok := requireRole(w, r, RoleAdministrator); !ok {
return true
}
backendStore.mu.RLock()
items := append([]UserProfile(nil), backendStore.Users...)
backendStore.mu.RUnlock()
items, err := backendStore.ListUsers(r.Context())
if err != nil {
writeStoreError(w, err)
return true
}
writeJSON(w, http.StatusOK, map[string]any{"items": items})
return true
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 {
switch {
case r.Method == http.MethodGet && path == "/content":
backendStore.mu.RLock()
items := append([]ContentItem(nil), backendStore.Content...)
backendStore.mu.RUnlock()
query := r.URL.Query()
user, _ := userFromRequest(r)
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})
return true
case r.Method == http.MethodGet && path == "/events":
backendStore.mu.RLock()
items := make([]ContentItem, 0)
for _, item := range backendStore.Content {
if item.Type == ContentTypeEvent {
items = append(items, item)
items, err := backendStore.ListContent(r.Context(), ContentFilter{OnlyEvents: true, SortMode: "newest"})
if err != nil {
writeStoreError(w, err)
return true
}
}
backendStore.mu.RUnlock()
writeJSON(w, http.StatusOK, map[string]any{"items": items, "note": "Event представлен как тип контента до подтверждения отдельной сущности."})
return true
case r.Method == http.MethodGet && strings.HasPrefix(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 {
writeAPIError(w, http.StatusNotFound, "NOT_FOUND", "Материал не найден")
return true
@@ -196,6 +283,10 @@ func handleContent(w http.ResponseWriter, r *http.Request, path string) bool {
if !decodeJSON(w, r, &payload) {
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) == "" {
writeAPIError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Укажите название, категорию и тип материала")
return true
@@ -211,15 +302,22 @@ func handleContent(w http.ResponseWriter, r *http.Request, path string) bool {
Author: user.Name,
PublishedAt: time.Now().Format("2006-01-02"),
Visibility: VisibilityAuthenticated,
Status: ContentStatusDraft,
Status: firstStatus(payload.Status, ContentStatusDraft),
ImageTone: firstNonEmpty(payload.ImageTone, "from-university-800 via-slate-700 to-sky-300"),
MediaURL: payload.MediaURL,
MediaKind: payload.MediaKind,
MimeType: payload.MimeType,
FileName: payload.FileName,
FileSize: payload.FileSize,
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
case r.Method == http.MethodPatch && strings.HasPrefix(path, "/content/"):
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) {
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 {
writeAPIError(w, http.StatusNotFound, "NOT_FOUND", "Материал не найден")
return true
@@ -240,7 +346,12 @@ func handleContent(w http.ResponseWriter, r *http.Request, path string) bool {
if _, ok := requireRole(w, r, RoleAdministrator); !ok {
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", "Материал не найден")
return true
}
@@ -255,14 +366,22 @@ func handleTaxonomy(w http.ResponseWriter, r *http.Request, path string) bool {
if r.Method != http.MethodGet {
return false
}
backendStore.mu.RLock()
defer backendStore.mu.RUnlock()
switch path {
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
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
default:
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 {
if r.Method == http.MethodGet && path == "/speakers" {
backendStore.mu.RLock()
items := append([]Speaker(nil), backendStore.Speakers...)
backendStore.mu.RUnlock()
items, err := backendStore.ListSpeakers(r.Context())
if err != nil {
writeStoreError(w, err)
return true
}
writeJSON(w, http.StatusOK, map[string]any{"items": items})
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 {
switch {
case r.Method == http.MethodGet && path == "/media":
backendStore.mu.RLock()
items := make([]ContentItem, 0)
for _, item := range backendStore.Content {
if item.Type == ContentTypeVideo || item.Type == ContentTypeAudio || item.Type == ContentTypeGraphic {
items = append(items, item)
items, err := backendStore.ListContent(r.Context(), ContentFilter{OnlyMedia: true, SortMode: "newest"})
if err != nil {
writeStoreError(w, err)
return true
}
}
backendStore.mu.RUnlock()
writeJSON(w, http.StatusOK, map[string]any{"items": items})
return true
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 {
writeAPIError(w, http.StatusNotFound, "NOT_FOUND", "Файл не найден")
return true
@@ -347,13 +469,17 @@ func handleMedia(w http.ResponseWriter, r *http.Request, path string) bool {
return true
}
stored := backendStore.addFile(StoredFile{
stored, err := backendStore.AddFile(r.Context(), StoredFile{
ID: fmt.Sprintf("demo-file-%d", time.Now().UnixNano()),
Name: firstNonEmpty(header.Filename, "uploaded-file"),
MimeType: mimeType,
Size: int64(len(data)),
Data: data,
})
if err != nil {
writeStoreError(w, err)
return true
}
category := strings.TrimSpace(r.FormValue("category"))
if category == "" {
@@ -379,7 +505,12 @@ func handleMedia(w http.ResponseWriter, r *http.Request, path string) bool {
FileName: stored.Name,
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 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 {
if r.Method == http.MethodGet && path == "/search" {
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 false
@@ -417,7 +553,12 @@ func handleSubscription(w http.ResponseWriter, r *http.Request, path string) boo
writeAPIError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Укажите объект подписки")
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
default:
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 {
switch {
case r.Method == http.MethodGet && path == "/notifications":
backendStore.mu.RLock()
items := append([]NotificationItem(nil), backendStore.Notifications...)
backendStore.mu.RUnlock()
items, err := backendStore.ListNotifications(r.Context())
if err != nil {
writeStoreError(w, err)
return true
}
writeJSON(w, http.StatusOK, map[string]any{"items": items})
return true
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
}
id := strings.TrimSuffix(strings.TrimPrefix(path, "/notifications/"), "/read")
backendStore.mu.Lock()
var updated *NotificationItem
for index := range backendStore.Notifications {
if backendStore.Notifications[index].ID == id {
backendStore.Notifications[index].Read = true
updated = &backendStore.Notifications[index]
break
updated, err := backendStore.MarkNotificationRead(r.Context(), id)
if err != nil {
writeStoreError(w, err)
return true
}
}
backendStore.mu.Unlock()
writeJSON(w, http.StatusOK, map[string]any{"item": updated})
return true
default:
@@ -461,14 +599,11 @@ func handleComment(w http.ResponseWriter, r *http.Request, path string) bool {
contentID := strings.TrimPrefix(path, "/comments/")
switch r.Method {
case http.MethodGet:
backendStore.mu.RLock()
items := make([]CommentItem, 0)
for _, comment := range backendStore.Comments {
if comment.ContentID == contentID {
items = append(items, comment)
items, err := backendStore.ListComments(r.Context(), contentID)
if err != nil {
writeStoreError(w, err)
return true
}
}
backendStore.mu.RUnlock()
writeJSON(w, http.StatusOK, map[string]any{"items": items})
return true
case http.MethodPost:
@@ -486,7 +621,12 @@ func handleComment(w http.ResponseWriter, r *http.Request, path string) bool {
writeAPIError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Комментарий не может быть пустым")
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
default:
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") {
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
}
backendStore.mu.RLock()
defer backendStore.mu.RUnlock()
totalViews := 0
moderationQueue := 0
for _, item := range backendStore.Content {
for _, item := range items {
totalViews += item.Views
if item.Status != ContentStatusPublished {
moderationQueue++
}
}
subscribers := 0
for _, speaker := range backendStore.Speakers {
for _, speaker := range speakers {
subscribers += speaker.Subscribers
}
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
}
popular := append([]ContentItem(nil), backendStore.Content...)
writeJSON(w, http.StatusOK, map[string]any{"totalViews": totalViews, "subscribers": subscribers, "activeUsers": len(backendStore.Users), "popular": popular})
popular := append([]ContentItem(nil), items...)
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
}
@@ -528,9 +708,11 @@ func handleAudit(w http.ResponseWriter, r *http.Request, path string) bool {
if _, ok := requireRole(w, r, RoleAdministrator); !ok {
return true
}
backendStore.mu.RLock()
items := append([]AuditItem(nil), backendStore.Audit...)
backendStore.mu.RUnlock()
items, err := backendStore.ListAudit(r.Context())
if err != nil {
writeStoreError(w, err)
return true
}
writeJSON(w, http.StatusOK, map[string]any{"items": items})
return true
}
@@ -555,11 +737,13 @@ func writeAPIError(w http.ResponseWriter, status int, code, message string) {
writeJSON(w, status, payload)
}
func makeToken(user UserProfile) string {
payload := tokenPayload{ID: user.ID, Name: user.Name, Login: user.Login, Roles: user.Roles}
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 writeStoreError(w http.ResponseWriter, err error) {
writeAPIError(w, http.StatusServiceUnavailable, "STORE_UNAVAILABLE", err.Error())
}
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) {
@@ -568,30 +752,16 @@ func userFromRequest(r *http.Request) (UserProfile, bool) {
return UserProfile{}, false
}
token := strings.TrimPrefix(header, "Bearer ")
if token == "demo-token-local-fallback" {
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)
payload, err := parseToken(token)
if err != nil {
return UserProfile{}, false
}
var payload tokenPayload
if err := json.Unmarshal(bytes, &payload); err != nil {
user, ok, err := backendStore.UserByID(r.Context(), payload.ID)
if err != nil {
return UserProfile{}, false
}
user := UserProfile{ID: payload.ID, Name: payload.Name, Login: payload.Login, Roles: payload.Roles}
backendStore.mu.RLock()
defer backendStore.mu.RUnlock()
for _, stored := range backendStore.Users {
if stored.ID == user.ID {
user.Subscriptions = append([]string(nil), stored.Subscriptions...)
break
}
if !ok {
return UserProfile{}, false
}
return user, true
}
@@ -632,6 +802,13 @@ func firstNonEmpty(value, fallback string) string {
return value
}
func firstStatus(value, fallback ContentStatus) ContentStatus {
if strings.TrimSpace(string(value)) == "" {
return fallback
}
return value
}
func inferMediaKind(mimeType, fileName string) string {
lowerName := strings.ToLower(fileName)
switch {

View 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))
}

View 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
}

View 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 }

View 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()
}

View File

@@ -1,6 +1,7 @@
package service
import (
"context"
"encoding/json"
"log"
"net/http"
@@ -22,6 +23,7 @@ type response struct {
Service string `json:"service"`
Domain string `json:"domain"`
Capabilities []string `json:"capabilities,omitempty"`
Error string `json:"error,omitempty"`
Time time.Time `json:"time"`
}
@@ -45,6 +47,9 @@ func MustRun(cfg Config) {
// Run starts an HTTP server for an internal service.
func Run(cfg Config) error {
if _, err := bootstrapDefaultStore(); err != nil {
return err
}
port := EnvPort(cfg.DefaultPort)
server := &http.Server{
Addr: ":" + port,
@@ -58,6 +63,9 @@ func Run(cfg Config) error {
// NewHandler returns the standard internal service HTTP API.
func NewHandler(cfg Config) http.Handler {
if strings.TrimSpace(os.Getenv("DATABASE_URL")) != "" {
_, _ = bootstrapDefaultStore()
}
if cfg.Name == "" {
cfg.Name = "Fable Service"
}
@@ -67,22 +75,42 @@ func NewHandler(cfg Config) http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, response{
store := backendStore
status := http.StatusOK
payload := response{
Status: "ok",
Service: cfg.Name,
Domain: cfg.Domain,
Capabilities: cfg.Capabilities,
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) {
writeJSON(w, http.StatusOK, response{
store := backendStore
status := http.StatusOK
payload := response{
Status: "ready",
Service: cfg.Name,
Domain: cfg.Domain,
Capabilities: cfg.Capabilities,
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) {
writeJSON(w, http.StatusOK, cfg)

View File

@@ -8,6 +8,10 @@ import (
)
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"}})
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/health", nil)

View File

@@ -1,229 +1,44 @@
package service
import (
"fmt"
"sort"
"strings"
"sync"
"time"
)
import "context"
var backendStore = newDemoStore()
type demoStore struct {
mu sync.RWMutex
Users []UserProfile
Content []ContentItem
Speakers []Speaker
Categories []string
Tags []string
Notifications []NotificationItem
Comments []CommentItem
Audit []AuditItem
Files map[string]StoredFile
type ContentFilter struct {
Term string
Category string
ContentType string
SortMode string
Status string
AuthorID string
Mine bool
User UserProfile
Exclude string
Limit int
OnlyEvents bool
OnlyMedia bool
}
func newDemoStore() *demoStore {
return &demoStore{
Users: []UserProfile{
{ID: "demo-user-1", Name: "Демо-администратор", Login: "demo_admin", Roles: []RoleCode{RoleAdministrator, RoleEditor}, Subscriptions: []string{"Новости", "Демо-спикер 01", "медиапроизводство"}},
{ID: "demo-user-2", Name: "Демо-редактор", Login: "demo_editor", Roles: []RoleCode{RoleEditor}, Subscriptions: []string{"Видео"}},
{ID: "demo-user-3", Name: "Демо-пользователь", Login: "demo_user", Roles: []RoleCode{RoleUser}, Subscriptions: []string{"Аудио"}},
},
Content: []ContentItem{
{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"},
{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"},
{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"},
{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"},
{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"},
},
Speakers: []Speaker{
{ID: "demo-speaker-1", Name: "Демо-спикер 01", Role: "Приглашенный эксперт", Topics: []string{"медиапроизводство", "образование"}, Materials: 8, Subscribers: 132},
{ID: "demo-speaker-2", Name: "Демо-спикер 02", Role: "Участник редакционного события", Topics: []string{"интервью", "анонс"}, Materials: 5, Subscribers: 74},
{ID: "demo-speaker-3", Name: "Демо-спикер 03", Role: "Автор образовательных материалов", Topics: []string{"архив", "редакция"}, Materials: 12, Subscribers: 205},
},
Categories: []string{"Новости", "Статьи", "Видео", "Аудио", "Графика", "Мероприятия"},
Tags: []string{"медиапроизводство", "интервью", "анонс", "образование", "редакция", "архив"},
Notifications: []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"},
},
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
type Store interface {
Ping(ctx context.Context) error
UserByLogin(ctx context.Context, login string) (UserProfile, bool, error)
UserByID(ctx context.Context, id string) (UserProfile, bool, error)
AddUser(ctx context.Context, login, name, password string) (UserProfile, error)
UpdatePassword(ctx context.Context, userID, password string) error
ListUsers(ctx context.Context) ([]UserProfile, error)
ListContent(ctx context.Context, filter ContentFilter) ([]ContentItem, error)
ContentByID(ctx context.Context, id string) (ContentItem, bool, error)
AddContent(ctx context.Context, item ContentItem) (ContentItem, error)
PatchContent(ctx context.Context, id string, patch ContentItem) (ContentItem, bool, error)
DeleteContent(ctx context.Context, id string) (bool, error)
ListCategories(ctx context.Context) ([]string, error)
ListTags(ctx context.Context) ([]string, error)
ListSpeakers(ctx context.Context) ([]Speaker, error)
AddFile(ctx context.Context, file StoredFile) (StoredFile, error)
FileByID(ctx context.Context, id string) (StoredFile, bool, error)
UpsertSubscription(ctx context.Context, userID, target string) ([]string, error)
ListNotifications(ctx context.Context) ([]NotificationItem, error)
MarkNotificationRead(ctx context.Context, id string) (*NotificationItem, error)
ListComments(ctx context.Context, contentID string) ([]CommentItem, error)
AddComment(ctx context.Context, contentID string, user UserProfile, text string) (CommentItem, error)
CountComments(ctx context.Context) (int, error)
ListAudit(ctx context.Context) ([]AuditItem, error)
}

File diff suppressed because it is too large Load Diff

View 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
}

View File

@@ -18,6 +18,7 @@ const (
ContentStatusModeration ContentStatus = "На модерации"
ContentStatusReview ContentStatus = "На проверке"
ContentStatusPublished ContentStatus = "Опубликовано"
ContentStatusReturned ContentStatus = "Возвращен"
ContentStatusArchived ContentStatus = "Архив"
)
@@ -43,6 +44,8 @@ type ContentItem struct {
Title string `json:"title"`
Lead string `json:"lead"`
Body string `json:"body"`
Excerpt string `json:"excerpt,omitempty"`
Content string `json:"content,omitempty"`
Type ContentType `json:"type"`
Category string `json:"category"`
Tags []string `json:"tags"`
@@ -58,6 +61,12 @@ type ContentItem struct {
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 {
@@ -81,6 +90,7 @@ type UserProfile struct {
ID string `json:"id"`
Name string `json:"name"`
Login string `json:"login"`
PasswordHash string `json:"-"`
Roles []RoleCode `json:"roles"`
Subscriptions []string `json:"subscriptions"`
}