1
0
forked from mixa/67

first commit 2

This commit is contained in:
mixa
2026-06-15 00:20:48 +03:00
parent 17bfa26689
commit e885e3e6fd
52 changed files with 9107 additions and 0 deletions

31
apps/gateway/Dockerfile Normal file
View File

@@ -0,0 +1,31 @@
FROM node:26-alpine AS build
WORKDIR /app
ENV NPM_CONFIG_FETCH_RETRIES=5 \
NPM_CONFIG_FETCH_RETRY_MINTIMEOUT=20000 \
NPM_CONFIG_FETCH_RETRY_MAXTIMEOUT=120000 \
NPM_CONFIG_AUDIT=false \
NPM_CONFIG_FUND=false
COPY package.json package-lock.json ./
COPY apps/gateway/package.json apps/gateway/tsconfig.json apps/gateway/
RUN npm ci --workspace @fable/gateway
COPY apps/gateway/src apps/gateway/src
RUN npm --workspace @fable/gateway run build
FROM node:26-alpine
WORKDIR /app
ENV NODE_ENV=production \
NPM_CONFIG_FETCH_RETRIES=5 \
NPM_CONFIG_FETCH_RETRY_MINTIMEOUT=20000 \
NPM_CONFIG_FETCH_RETRY_MAXTIMEOUT=120000 \
NPM_CONFIG_AUDIT=false \
NPM_CONFIG_FUND=false
COPY package.json package-lock.json ./
COPY apps/gateway/package.json apps/gateway/package.json
RUN npm ci --workspace @fable/gateway --omit=dev
COPY --from=build /app/apps/gateway/dist apps/gateway/dist
EXPOSE 3000
CMD ["node", "apps/gateway/dist/index.js"]

19
apps/gateway/package.json Normal file
View File

@@ -0,0 +1,19 @@
{
"name": "@fable/gateway",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "tsc && node --watch dist/index.js",
"build": "tsc",
"start": "node dist/index.js",
"check": "tsc --noEmit"
},
"dependencies": {
"elysia": "^1.3.0"
},
"devDependencies": {
"@types/node": "^24.0.0",
"typescript": "^5.9.0"
}
}

841
apps/gateway/src/index.ts Normal file
View File

@@ -0,0 +1,841 @@
import { Buffer } from 'node:buffer';
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
import { Elysia } from 'elysia';
import { WebStandardAdapter } from 'elysia/adapter/web-standard';
type ContentType = 'news' | 'article' | 'video' | 'audio' | 'graphic' | 'event';
type ContentStatus = 'Черновик' | 'На модерации' | 'На проверке' | 'Опубликовано' | 'Архив';
type Visibility = 'Публично' | 'После входа' | 'По роли';
type RoleCode = 'администратор' | 'редактор' | 'менеджер' | 'пользователь';
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?: 'image' | 'video' | 'audio' | 'document' | 'other';
mimeType?: string;
fileName?: string;
fileSize?: number;
};
type MediaKind = NonNullable<ContentItem['mediaKind']>;
type StoredUpload = {
id: string;
name: string;
mimeType: string;
size: number;
data: Buffer;
};
type UserProfile = {
id: string;
name: string;
login: string;
roles: RoleCode[];
subscriptions: string[];
};
type SetContext = {
status?: number | string;
headers: Record<string, string | number | undefined>;
};
const port = Number(process.env.GATEWAY_PORT ?? process.env.PORT ?? 3000);
const corsOrigin = process.env.CORS_ORIGIN ?? '*';
const rateLimitWindowMs = Number(process.env.RATE_LIMIT_WINDOW_MS ?? 60_000);
const rateLimitMax = Number(process.env.RATE_LIMIT_MAX ?? 180);
const serviceUrls: Record<string, string | undefined> = {
auth: process.env.AUTH_SERVICE_URL,
user: process.env.USER_SERVICE_URL,
content: process.env.CONTENT_SERVICE_URL,
taxonomy: process.env.TAXONOMY_SERVICE_URL,
speaker: process.env.SPEAKER_SERVICE_URL,
subscription: process.env.SUBSCRIPTION_SERVICE_URL,
notification: process.env.NOTIFICATION_SERVICE_URL,
comment: process.env.COMMENT_SERVICE_URL,
search: process.env.SEARCH_SERVICE_URL,
analytics: process.env.ANALYTICS_SERVICE_URL,
audit: process.env.AUDIT_SERVICE_URL,
media: process.env.MEDIA_SERVICE_URL
};
const categories = ['Новости', 'Статьи', 'Видео', 'Аудио', 'Графика', 'Мероприятия'];
const tags = ['медиапроизводство', 'интервью', 'анонс', 'образование', 'редакция', 'архив'];
const contentTypes: ContentType[] = ['news', 'article', 'video', 'audio', 'graphic', 'event'];
const demoUser: UserProfile = {
id: 'demo-user-1',
name: 'Демо-администратор',
login: 'demo_admin',
roles: ['администратор', 'редактор'],
subscriptions: ['Новости', 'Демо-спикер 01', 'медиапроизводство']
};
const users: UserProfile[] = [
demoUser,
{ id: 'demo-user-2', name: 'Демо-редактор', login: 'demo_editor', roles: ['редактор'], subscriptions: ['Видео'] },
{ id: 'demo-user-3', name: 'Демо-пользователь', login: 'demo_user', roles: ['пользователь'], subscriptions: ['Аудио'] }
];
let content: 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: 'События представлены как анонсы контента, потому что отдельная сущность Event требует подтверждения заказчиком.',
type: 'event',
category: 'Мероприятия',
tags: ['анонс', 'интервью'],
author: 'Демо-менеджер',
publishedAt: '2026-06-17',
visibility: 'Публично',
status: 'Черновик',
views: 45,
imageTone: 'from-university-900 via-violet-700 to-orange-300'
}
];
const speakers = [
{ 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 }
];
const notifications = [
{ 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' }
];
const comments = [
{ id: 'demo-comment-1', contentId: 'demo-news-1', author: 'Демо-пользователь', text: 'Комментарий доступен авторизованным пользователям и может проходить модерацию.', createdAt: '2026-06-13 12:00' }
];
const audit = [
{ 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' }
];
const tokens = new Map<string, UserProfile>();
const rateBuckets = new Map<string, { count: number; resetAt: number }>();
const uploadedFiles = new Map<string, StoredUpload>();
function readServiceToken(token: string): UserProfile | null {
if (!token.startsWith('demo-token-')) {
return null;
}
try {
const encoded = token.slice('demo-token-'.length).replace(/-/g, '+').replace(/_/g, '/');
const padded = encoded.padEnd(Math.ceil(encoded.length / 4) * 4, '=');
const binary = atob(padded);
const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));
const parsed = JSON.parse(new TextDecoder().decode(bytes)) as Pick<UserProfile, 'id' | 'name' | 'login' | 'roles'>;
const stored = users.find((item) => item.id === parsed.id);
return {
id: parsed.id,
name: parsed.name,
login: parsed.login,
roles: parsed.roles,
subscriptions: stored?.subscriptions ?? []
};
} catch {
return null;
}
}
function fail(set: SetContext, status: number, code: string, message: string) {
set.status = status;
return {
error: {
code,
message,
requestId: set.headers['x-request-id'] ? String(set.headers['x-request-id']) : null
}
};
}
function readToken(request: Request) {
const header = request.headers.get('authorization');
if (!header?.startsWith('Bearer ')) {
return null;
}
return header.slice('Bearer '.length);
}
function readUser(request: Request) {
const token = readToken(request);
if (!token) {
return null;
}
if (token === 'demo-token-local-fallback') {
return demoUser;
}
return tokens.get(token) ?? readServiceToken(token);
}
function requireUser(request: Request, set: SetContext) {
const user = readUser(request);
if (!user) {
return { user: null, response: fail(set, 401, 'UNAUTHORIZED', 'Требуется аутентификация') };
}
return { user, response: null };
}
function requireRole(request: Request, set: SetContext, role: RoleCode) {
const auth = requireUser(request, set);
if (!auth.user) {
return auth;
}
if (!auth.user.roles.includes(role)) {
return { user: null, response: fail(set, 403, 'FORBIDDEN', 'Недостаточно прав доступа') };
}
return auth;
}
function applyRateLimit(request: Request, set: SetContext) {
const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || 'local';
const now = Date.now();
const current = rateBuckets.get(ip);
if (!current || current.resetAt <= now) {
rateBuckets.set(ip, { count: 1, resetAt: now + rateLimitWindowMs });
return undefined;
}
current.count += 1;
if (current.count > rateLimitMax) {
return fail(set, 429, 'RATE_LIMITED', 'Слишком много запросов');
}
return undefined;
}
function searchContent(query: Record<string, string | undefined>) {
const term = (query.q ?? query.query ?? '').trim().toLowerCase();
const category = query.category;
const type = query.type as ContentType | undefined;
const sorted = [...content]
.filter((item) => !term || [item.title, item.lead, item.body, item.author, item.category, item.status, ...item.tags].join(' ').toLowerCase().includes(term))
.filter((item) => !category || category === 'Все' || item.category === category)
.filter((item) => !type || item.type === type)
.sort((a, b) => (query.sort === 'newest' ? new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime() : b.views - a.views));
return sorted;
}
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';
}
function readStringField(source: FormData | Record<string, unknown>, key: string) {
const rawValue = source instanceof FormData ? source.get(key) : source[key];
const value = Array.isArray(rawValue) ? rawValue[0] : rawValue;
if (typeof value === 'string') {
return value.trim();
}
if (typeof value === 'number' || typeof value === 'boolean') {
return String(value);
}
return '';
}
function readFileField(source: FormData | Record<string, unknown>, key: string) {
const rawValue = source instanceof FormData ? source.get(key) : source[key];
const value = Array.isArray(rawValue) ? rawValue.find(isBlobLike) : rawValue;
return isBlobLike(value) ? value : null;
}
async function readUploadSource(request: Request, body: unknown): Promise<FormData | Record<string, unknown>> {
if (body instanceof FormData) {
return body;
}
if (body && typeof body === 'object') {
return body as Record<string, unknown>;
}
return request.formData();
}
function inferMediaKind(mimeType = '', fileName = ''): MediaKind {
const lowerName = fileName.toLowerCase();
if (mimeType.startsWith('image/')) return 'image';
if (mimeType.startsWith('video/')) return 'video';
if (mimeType.startsWith('audio/')) return 'audio';
if (mimeType === 'application/pdf' || /\.(pdf|doc|docx|ppt|pptx|xls|xlsx|txt|rtf)$/i.test(lowerName)) return 'document';
return 'other';
}
function defaultContentTypeForMedia(kind: MediaKind): ContentType {
if (kind === 'video') return 'video';
if (kind === 'audio') return 'audio';
if (kind === 'image') return 'graphic';
return 'article';
}
function defaultCategoryForMedia(kind: MediaKind) {
if (kind === 'video') return 'Видео';
if (kind === 'audio') return 'Аудио';
if (kind === 'image') return 'Графика';
return 'Статьи';
}
function parseTags(value: string) {
const tags = value
.split(/[,;\n]/)
.map((tag) => tag.trim())
.filter(Boolean);
return tags.length ? [...new Set(tags)] : ['демо', 'файл'];
}
async function readServiceHealth(name: string, url: string | undefined) {
if (!url) {
return { name, status: 'not_configured' };
}
try {
const response = await fetch(`${url}/health`, { signal: AbortSignal.timeout(1500) });
return { name, status: response.ok ? 'ok' : 'error', url };
} catch {
return { name, status: 'unreachable', url };
}
}
function serviceForAPIPath(pathname: string) {
if (!pathname.startsWith('/api/') || pathname === '/api/health' || pathname === '/api/services/health') {
return null;
}
const path = pathname.slice('/api'.length);
if (path.startsWith('/auth/')) return 'auth';
if (path === '/users' || path.startsWith('/users/') || path === '/admin/users' || path === '/roles' || path === '/admin/roles') return 'user';
if (path === '/content' || path.startsWith('/content/') || path === '/events') return 'content';
if (path === '/media' || path.startsWith('/media/')) return 'media';
if (path === '/categories' || path === '/tags') return 'taxonomy';
if (path === '/speakers' || path.startsWith('/speakers/')) return 'speaker';
if (path === '/subscriptions' || path.startsWith('/subscriptions/')) return 'subscription';
if (path === '/notifications' || path.startsWith('/notifications/')) return 'notification';
if (path.startsWith('/comments/')) return 'comment';
if (path === '/search' || path.startsWith('/search/')) return 'search';
if (path === '/analytics/summary' || path === '/admin/dashboard') return 'analytics';
if (path === '/admin/audit' || path.startsWith('/audit')) return 'audit';
return null;
}
function encodeProxyBody(body: unknown, headers: Headers) {
if (body === undefined || body === null) {
return undefined;
}
if (typeof body === 'string' || body instanceof Blob || body instanceof ArrayBuffer || body instanceof FormData || body instanceof URLSearchParams) {
return body;
}
if (headers.get('content-type')?.includes('multipart/form-data') && typeof body === 'object') {
const form = new FormData();
for (const [key, value] of Object.entries(body as Record<string, unknown>)) {
if (Array.isArray(value)) {
for (const entry of value) {
if (isBlobLike(entry)) form.append(key, entry, entry.name ?? 'uploaded-file');
else if (entry !== undefined && entry !== null) form.append(key, String(entry));
}
} else if (isBlobLike(value)) {
form.append(key, value, value.name ?? 'uploaded-file');
} else if (value !== undefined && value !== null) {
form.append(key, String(value));
}
}
headers.delete('content-type');
return form;
}
if (!headers.has('content-type')) {
headers.set('content-type', 'application/json');
}
return JSON.stringify(body);
}
async function proxyAPIRequest(request: Request, set: SetContext, body: unknown) {
const source = new URL(request.url);
const serviceName = serviceForAPIPath(source.pathname);
if (!serviceName) {
return undefined;
}
const serviceURL = serviceUrls[serviceName];
if (!serviceURL) {
return undefined;
}
try {
const target = new URL(serviceURL);
target.pathname = source.pathname.replace(/^\/api/, '') || '/';
target.search = source.search;
const headers = new Headers(request.headers);
const requestId = set.headers['x-request-id'];
if (requestId) {
headers.set('x-request-id', String(requestId));
}
headers.set('x-forwarded-by', 'fable-gateway');
const init: RequestInit = {
method: request.method,
headers,
signal: AbortSignal.timeout(1800)
};
if (request.method !== 'GET' && request.method !== 'HEAD') {
init.body = encodeProxyBody(body, headers);
}
const response = await fetch(target, init);
const responseHeaders = new Headers(response.headers);
for (const [key, value] of Object.entries(set.headers)) {
if (value !== undefined && !responseHeaders.has(key)) {
responseHeaders.set(key, String(value));
}
}
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers: responseHeaders
});
} catch {
return undefined;
}
}
function createNodeRequest(req: IncomingMessage) {
const host = req.headers.host ?? `localhost:${port}`;
const url = new URL(req.url ?? '/', `http://${host}`);
const headers = new Headers();
for (const [key, value] of Object.entries(req.headers)) {
if (Array.isArray(value)) {
for (const entry of value) headers.append(key, entry);
} else if (value !== undefined) {
headers.set(key, value);
}
}
const init: RequestInit & { duplex?: 'half' } = {
method: req.method,
headers
};
if (req.method !== 'GET' && req.method !== 'HEAD') {
init.body = req as unknown as BodyInit;
init.duplex = 'half';
}
return new Request(url, init);
}
async function writeNodeResponse(res: ServerResponse, response: Response) {
res.statusCode = response.status;
res.statusMessage = response.statusText;
response.headers.forEach((value, key) => {
res.setHeader(key, value);
});
if (!response.body) {
res.end();
return;
}
const body = Buffer.from(await response.arrayBuffer());
res.end(body);
}
const app = new Elysia({ adapter: WebStandardAdapter })
.onRequest(({ request, set }) => {
const requestId = request.headers.get('x-request-id') ?? crypto.randomUUID();
set.headers['x-request-id'] = requestId;
set.headers['access-control-allow-origin'] = corsOrigin;
set.headers['access-control-allow-methods'] = 'GET,POST,PATCH,DELETE,OPTIONS';
set.headers['access-control-allow-headers'] = 'authorization,content-type,x-request-id';
set.headers['access-control-expose-headers'] = 'x-request-id';
})
.onBeforeHandle(async ({ request, set, body }) => applyRateLimit(request, set) ?? (await proxyAPIRequest(request, set, body)))
.onError(({ error, set }) => fail(set, 500, 'INTERNAL_ERROR', error instanceof Error ? error.message : 'Неизвестная ошибка'))
.options('*', () => new Response(null, { status: 204 }))
.get('/health', ({ set }) => ({ status: 'ok', service: 'gateway', requestId: set.headers['x-request-id'] }))
.get('/api/health', ({ set }) => ({ status: 'ok', service: 'gateway', requestId: set.headers['x-request-id'] }))
.get('/api/services/health', async () => ({ items: await Promise.all(Object.entries(serviceUrls).map(([name, url]) => readServiceHealth(name, url))) }))
.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) {
return fail(set, 400, 'VALIDATION_ERROR', 'Укажите логин и пароль не короче 8 символов');
}
const user: UserProfile = {
id: `demo-user-${Date.now()}`,
name: payload.name?.trim() || 'Демо-пользователь',
login: payload.login,
roles: ['пользователь'],
subscriptions: []
};
users.push(user);
const token = `demo-token-${crypto.randomUUID()}`;
tokens.set(token, user);
set.status = 201;
return { token, user };
})
.post('/api/auth/login', ({ body, set }) => {
const payload = body as Partial<{ login: string; password: string }>;
if (!payload.login || !payload.password) {
return fail(set, 400, 'VALIDATION_ERROR', 'Укажите логин и пароль');
}
const user = users.find((item) => item.login === payload.login) ?? demoUser;
const token = `demo-token-${crypto.randomUUID()}`;
tokens.set(token, user);
return { token, user };
})
.get('/api/auth/me', ({ request, set }) => {
const auth = requireUser(request, set);
if (!auth.user) {
return auth.response;
}
return { user: auth.user };
})
.post('/api/auth/logout', ({ request }) => {
const token = readToken(request);
if (token) {
tokens.delete(token);
}
return { ok: true };
})
.post('/api/auth/change-password', ({ request, body, set }) => {
const auth = requireUser(request, set);
if (!auth.user) {
return auth.response;
}
const payload = body as Partial<{ nextPassword: string }>;
if (!payload.nextPassword || payload.nextPassword.length < 8) {
return fail(set, 400, 'VALIDATION_ERROR', 'Новый пароль должен быть не короче 8 символов');
}
return { ok: true };
})
.get('/api/content', () => ({ items: content }))
.get('/api/content/:id', ({ params, set }) => {
const item = content.find((entry) => entry.id === params.id);
if (!item) {
return fail(set, 404, 'NOT_FOUND', 'Материал не найден');
}
item.views += 1;
return { item };
})
.post('/api/content', ({ request, body, set }) => {
const auth = requireUser(request, set);
if (!auth.user) {
return auth.response;
}
if (!auth.user.roles.some((role) => role === 'администратор' || role === 'редактор' || role === 'менеджер')) {
return fail(set, 403, 'FORBIDDEN', 'Создание материалов требует роли редактора, менеджера или администратора');
}
const payload = body as Partial<ContentItem>;
if (!payload.title || !payload.category || !payload.type) {
return fail(set, 400, 'VALIDATION_ERROR', 'Укажите название, категорию и тип материала');
}
const item: ContentItem = {
id: `demo-content-${Date.now()}`,
title: payload.title,
lead: payload.lead ?? 'Демонстрационный черновик',
body: payload.body ?? 'Демо-описание материала.',
type: payload.type,
category: payload.category,
tags: payload.tags ?? [],
author: auth.user.name,
publishedAt: new Date().toISOString().slice(0, 10),
visibility: payload.visibility ?? 'После входа',
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
};
content = [item, ...content];
set.status = 201;
return { item };
})
.patch('/api/content/:id', ({ request, params, body, set }) => {
const auth = requireUser(request, set);
if (!auth.user) {
return auth.response;
}
const index = content.findIndex((item) => item.id === params.id);
if (index === -1) {
return fail(set, 404, 'NOT_FOUND', 'Материал не найден');
}
content[index] = { ...content[index], ...(body as Partial<ContentItem>) };
return { item: content[index] };
})
.delete('/api/content/:id', ({ request, params, set }) => {
const auth = requireRole(request, set, 'администратор');
if (!auth.user) {
return auth.response;
}
content = content.filter((item) => item.id !== params.id);
return { ok: true };
})
.get('/api/media/files/:id', ({ params, set }) => {
const file = uploadedFiles.get(params.id);
if (!file) {
return fail(set, 404, 'NOT_FOUND', 'Файл не найден');
}
const data = new Uint8Array(file.data.byteLength);
data.set(file.data);
return new Response(data, {
headers: {
'content-type': file.mimeType || 'application/octet-stream',
'content-length': String(file.size),
'content-disposition': `inline; filename="${file.name.replace(/"/g, '')}"`
}
});
})
.get('/api/media', () => ({ items: content.filter((item) => item.type === 'video' || item.type === 'audio' || item.type === 'graphic') }))
.post('/api/media', async ({ request, body, set }) => {
const auth = requireUser(request, set);
if (!auth.user) {
return auth.response;
}
if (!auth.user.roles.some((role) => role === 'администратор' || role === 'редактор' || role === 'менеджер')) {
return fail(set, 403, 'FORBIDDEN', 'Создание материалов требует роли редактора, менеджера или администратора');
}
const source = await readUploadSource(request, body);
const file = readFileField(source, 'file');
if (!file) {
return fail(set, 400, 'VALIDATION_ERROR', 'Прикрепите файл');
}
const data = Buffer.from(await file.arrayBuffer());
if (!data.length) {
return fail(set, 400, 'INVALID_FILE', 'Файл пустой или поврежден');
}
const fileName = file.name || 'uploaded-file';
const mimeType = file.type || 'application/octet-stream';
const mediaKind = inferMediaKind(mimeType, fileName);
const id = `demo-file-${Date.now()}`;
uploadedFiles.set(id, { id, name: fileName, mimeType, size: data.byteLength, data });
const requestedType = readStringField(source, 'type') as ContentType;
const item: ContentItem = {
id: `demo-media-${Date.now()}`,
title: readStringField(source, 'title') || fileName,
lead: readStringField(source, 'lead') || 'Демонстрационный медиаматериал с загруженным файлом.',
body: readStringField(source, 'body') || 'Файл загружен в in-memory demo-хранилище gateway и доступен только до перезапуска процесса.',
type: contentTypes.includes(requestedType) ? requestedType : defaultContentTypeForMedia(mediaKind),
category: readStringField(source, 'category') || defaultCategoryForMedia(mediaKind),
tags: parseTags(readStringField(source, 'tags')),
author: auth.user.name,
publishedAt: new Date().toISOString().slice(0, 10),
visibility: 'После входа',
status: 'Черновик',
views: 0,
imageTone: 'from-university-800 via-slate-700 to-sky-300',
mediaUrl: `/api/media/files/${id}`,
mediaKind,
mimeType,
fileName,
fileSize: data.byteLength
};
content = [item, ...content];
set.status = 201;
return { item };
})
.get('/api/events', () => ({ items: content.filter((item) => item.type === 'event'), note: 'Event представлен как тип контента до подтверждения отдельной сущности.' }))
.get('/api/categories', () => ({ items: categories }))
.get('/api/tags', () => ({ items: tags }))
.get('/api/speakers', () => ({ items: speakers }))
.get('/api/search', ({ query }) => ({ items: searchContent(query as Record<string, string | undefined>) }))
.get('/api/subscriptions', ({ request, set }) => {
const auth = requireUser(request, set);
if (!auth.user) {
return auth.response;
}
return { items: auth.user.subscriptions };
})
.post('/api/subscriptions', ({ request, body, set }) => {
const auth = requireUser(request, set);
if (!auth.user) {
return auth.response;
}
const payload = body as Partial<{ target: string }>;
if (!payload.target) {
return fail(set, 400, 'VALIDATION_ERROR', 'Укажите объект подписки');
}
auth.user.subscriptions = [...new Set([...auth.user.subscriptions, payload.target])];
return { items: auth.user.subscriptions };
})
.get('/api/notifications', () => ({ items: notifications }))
.patch('/api/notifications/:id/read', ({ request, params, set }) => {
const auth = requireUser(request, set);
if (!auth.user) {
return auth.response;
}
const item = notifications.find((entry) => entry.id === params.id);
if (item) {
item.read = true;
}
return { item };
})
.get('/api/comments/:contentId', ({ params }) => ({ items: comments.filter((item) => item.contentId === params.contentId) }))
.post('/api/comments/:contentId', ({ request, params, body, set }) => {
const auth = requireUser(request, set);
if (!auth.user) {
return auth.response;
}
const payload = body as Partial<{ text: string }>;
if (!payload.text?.trim()) {
return fail(set, 400, 'VALIDATION_ERROR', 'Комментарий не может быть пустым');
}
const item = { id: `demo-comment-${Date.now()}`, contentId: params.contentId, author: auth.user.name, text: payload.text.trim(), createdAt: new Date().toISOString() };
comments.unshift(item);
set.status = 201;
return { item };
})
.get('/api/analytics/summary', ({ request, set }) => {
const auth = requireRole(request, set, 'администратор');
if (!auth.user) {
return auth.response;
}
return {
totalViews: content.reduce((sum, item) => sum + item.views, 0),
subscribers: speakers.reduce((sum, item) => sum + item.subscribers, 0),
activeUsers: users.length,
popular: [...content].sort((a, b) => b.views - a.views).slice(0, 3)
};
})
.get('/api/admin/dashboard', ({ request, set }) => {
const auth = requireRole(request, set, 'администратор');
if (!auth.user) {
return auth.response;
}
return {
users: users.length,
content: content.length,
moderationQueue: content.filter((item) => item.status !== 'Опубликовано').length,
roles: ['администратор', 'редактор', 'менеджер', 'пользователь']
};
})
.get('/api/admin/users', ({ request, set }) => {
const auth = requireRole(request, set, 'администратор');
if (!auth.user) {
return auth.response;
}
return { items: users };
})
.get('/api/admin/audit', ({ request, set }) => {
const auth = requireRole(request, set, 'администратор');
if (!auth.user) {
return auth.response;
}
return { items: audit };
})
.get('/api/admin/roles', ({ request, set }) => {
const auth = requireRole(request, set, 'администратор');
if (!auth.user) {
return auth.response;
}
return {
items: [
{ code: 'администратор', permissions: ['*'] },
{ code: 'редактор', permissions: ['content:create', 'content:update', 'comments:moderate'] },
{ code: 'менеджер', permissions: ['content:create', 'content:publish', 'subscriptions:manage'] },
{ code: 'пользователь', permissions: ['content:read', 'comments:create', 'subscriptions:create'] }
]
};
})
.all('*', ({ set }) => fail(set, 404, 'NOT_FOUND', 'Маршрут не найден'));
const server = createServer(async (req, res) => {
try {
const request = createNodeRequest(req);
const response = await app.fetch(request);
await writeNodeResponse(res, response);
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown gateway error';
res.statusCode = 500;
res.setHeader('content-type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ error: { code: 'INTERNAL_ERROR', message } }));
}
});
server.listen(port, () => {
console.log(`Fable Elysia gateway is running on Node at http://localhost:${port}`);
});

View File

@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"skipLibCheck": true,
"types": ["node"],
"rootDir": "src",
"outDir": "dist"
},
"include": ["src"]
}

17
apps/web/Dockerfile Normal file
View File

@@ -0,0 +1,17 @@
FROM node:24-alpine AS build
WORKDIR /app
COPY package.json ./
COPY apps/web/package.json apps/web/package.json
COPY apps/gateway/package.json apps/gateway/package.json
RUN npm install
COPY apps/web apps/web
WORKDIR /app/apps/web
RUN npm run build
FROM nginx:1.27-alpine
COPY --from=build /app/apps/web/dist /usr/share/nginx/html
COPY apps/web/nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

16
apps/web/index.html Normal file
View File

@@ -0,0 +1,16 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
name="description"
content="Демо-прототип университетской платформы управления медиаконтентом Fable"
/>
<title>Fable | Медиаплатформа университета</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

29
apps/web/nginx.conf Normal file
View File

@@ -0,0 +1,29 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location /api/ {
proxy_pass http://gateway:3000/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /health {
proxy_pass http://gateway:3000/health;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
try_files $uri $uri/ /index.html;
}
}

26
apps/web/package.json Normal file
View File

@@ -0,0 +1,26 @@
{
"name": "@fable/web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "tsc --noEmit && vite build",
"preview": "vite preview --host 0.0.0.0"
},
"dependencies": {
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
"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"
}
}

View File

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

1891
apps/web/src/App.tsx Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,145 @@
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>
);
}

216
apps/web/src/demo-data.ts Normal file
View File

@@ -0,0 +1,216 @@
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']
}
];

140
apps/web/src/index.css Normal file
View File

@@ -0,0 +1,140 @@
@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;
}
}

10
apps/web/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
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>
);

71
apps/web/src/types.ts Normal file
View File

@@ -0,0 +1,71 @@
export type ContentType = 'news' | 'article' | 'video' | 'audio' | 'graphic' | 'event';
export type MediaKind = 'image' | 'video' | 'audio' | 'document' | 'other';
export type ContentStatus = 'Черновик' | 'На модерации' | 'На проверке' | 'Опубликовано' | 'Архив';
export type Visibility = 'Публично' | 'После входа' | 'По роли';
export type RoleCode = 'администратор' | 'редактор' | 'менеджер' | 'пользователь';
export type ContentItem = {
id: string;
title: string;
lead: string;
body: string;
type: ContentType;
category: string;
tags: string[];
author: string;
publishedAt: string;
duration?: string;
visibility: Visibility;
status: ContentStatus;
views: number;
imageTone: string;
mediaUrl?: string;
mediaKind?: MediaKind;
mimeType?: string;
fileName?: string;
fileSize?: number;
};
export type Speaker = {
id: string;
name: string;
role: string;
topics: string[];
materials: number;
subscribers: number;
};
export type UserProfile = {
id: string;
name: string;
login: string;
roles: RoleCode[];
subscriptions: string[];
};
export type NotificationItem = {
id: string;
title: string;
description: string;
read: boolean;
createdAt: string;
};
export type CommentItem = {
id: string;
author: string;
text: string;
createdAt: string;
};
export type AuditItem = {
id: string;
actor: string;
action: string;
target: string;
createdAt: string;
};

1
apps/web/src/vite-env.d.ts vendored Normal file
View File

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

View File

@@ -0,0 +1,40 @@
/** @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: []
};

21
apps/web/tsconfig.json Normal file
View File

@@ -0,0 +1,21 @@
{
"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": []
}

15
apps/web/vite.config.ts Normal file
View File

@@ -0,0 +1,15 @@
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
}
}
}
});