diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..7ae29c0 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +node_modules +dist +build +.git +.env +*.log +coverage +services/bin diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b7ba11f --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +POSTGRES_DB=fable +POSTGRES_USER=fable +POSTGRES_PASSWORD=change_me_for_real_environment +JWT_SECRET=change_me_before_production +GATEWAY_PORT=3000 +WEB_PORT=5173 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7238758 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +node_modules/ +dist/ +build/ +.env +.env.* +!.env.example +*.log +coverage/ +.DS_Store +tmp/ +*.tmp +services/bin/ +.tmp/ diff --git a/.omx/state/notify-fallback-authority-owner.json b/.omx/state/notify-fallback-authority-owner.json new file mode 100644 index 0000000..da27584 --- /dev/null +++ b/.omx/state/notify-fallback-authority-owner.json @@ -0,0 +1,14 @@ +{ + "owner": "hud", + "pid": 1593489, + "cwd": "/home/mixa/Videos/67", + "heartbeat_at": "2026-06-09T12:05:35.612Z", + "cooldown_ms": 5000, + "jitter_ms": 171, + "skip_count": 260352, + "last_status": "skipped", + "last_reason": "rate_limited", + "last_spawn_at": "2026-06-09T12:05:32.610Z", + "last_skip_at": "2026-06-09T12:05:35.612Z", + "next_allowed_at": "2026-06-09T12:05:37.781Z" +} \ No newline at end of file diff --git a/.omx/state/notify-fallback-authority-state.json b/.omx/state/notify-fallback-authority-state.json new file mode 100644 index 0000000..da27584 --- /dev/null +++ b/.omx/state/notify-fallback-authority-state.json @@ -0,0 +1,14 @@ +{ + "owner": "hud", + "pid": 1593489, + "cwd": "/home/mixa/Videos/67", + "heartbeat_at": "2026-06-09T12:05:35.612Z", + "cooldown_ms": 5000, + "jitter_ms": 171, + "skip_count": 260352, + "last_status": "skipped", + "last_reason": "rate_limited", + "last_spawn_at": "2026-06-09T12:05:32.610Z", + "last_skip_at": "2026-06-09T12:05:35.612Z", + "next_allowed_at": "2026-06-09T12:05:37.781Z" +} \ No newline at end of file diff --git a/.omx/state/update-check.json b/.omx/state/update-check.json new file mode 100644 index 0000000..896eae6 --- /dev/null +++ b/.omx/state/update-check.json @@ -0,0 +1,4 @@ +{ + "last_checked_at": "2026-06-05T21:10:52.691Z", + "last_seen_latest": "0.18.9" +} \ No newline at end of file diff --git a/apps/gateway/Dockerfile b/apps/gateway/Dockerfile new file mode 100644 index 0000000..c471fef --- /dev/null +++ b/apps/gateway/Dockerfile @@ -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"] diff --git a/apps/gateway/package.json b/apps/gateway/package.json new file mode 100644 index 0000000..5336ab0 --- /dev/null +++ b/apps/gateway/package.json @@ -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" + } +} diff --git a/apps/gateway/src/index.ts b/apps/gateway/src/index.ts new file mode 100644 index 0000000..d593f63 --- /dev/null +++ b/apps/gateway/src/index.ts @@ -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; + +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; +}; + +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 = { + 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(); +const rateBuckets = new Map(); +const uploadedFiles = new Map(); + +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; + 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) { + 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, 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, 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> { + if (body instanceof FormData) { + return body; + } + if (body && typeof body === 'object') { + return body as Record; + } + 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)) { + 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; + 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) }; + 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) })) + .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}`); +}); diff --git a/apps/gateway/tsconfig.json b/apps/gateway/tsconfig.json new file mode 100644 index 0000000..dfc023a --- /dev/null +++ b/apps/gateway/tsconfig.json @@ -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"] +} diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile new file mode 100644 index 0000000..ca09f03 --- /dev/null +++ b/apps/web/Dockerfile @@ -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 diff --git a/apps/web/index.html b/apps/web/index.html new file mode 100644 index 0000000..0237a74 --- /dev/null +++ b/apps/web/index.html @@ -0,0 +1,16 @@ + + + + + + + Fable | Медиаплатформа университета + + +
+ + + diff --git a/apps/web/nginx.conf b/apps/web/nginx.conf new file mode 100644 index 0000000..86a1657 --- /dev/null +++ b/apps/web/nginx.conf @@ -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; + } +} diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..ca14afd --- /dev/null +++ b/apps/web/package.json @@ -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" + } +} diff --git a/apps/web/postcss.config.cjs b/apps/web/postcss.config.cjs new file mode 100644 index 0000000..5cbc2c7 --- /dev/null +++ b/apps/web/postcss.config.cjs @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {} + } +}; diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx new file mode 100644 index 0000000..61b6424 --- /dev/null +++ b/apps/web/src/App.tsx @@ -0,0 +1,1891 @@ +import { startTransition, useDeferredValue, useEffect, useState } from 'react'; +import type { ButtonHTMLAttributes, FormEvent } from 'react'; +import { AppMenu } from './components/AppMenu'; +import { + demoAudit, + demoCategories, + demoComments, + demoContent, + demoNotifications, + demoSpeakers, + demoTags, + demoUser, + demoUsers +} from './demo-data'; +import type { CommentItem, ContentItem, ContentStatus, ContentType, MediaKind, NotificationItem, Speaker, UserProfile, Visibility } from './types'; + +type View = 'home' | 'catalog' | 'search' | 'detail' | 'profile' | 'admin'; +type Theme = 'light' | 'dark'; +type FontScale = 'normal' | 'large' | 'xlarge'; +type ColorScheme = 'standard' | 'contrast'; +type SortMode = 'newest' | 'popular'; +type ApiStatus = 'checking' | 'online' | 'offline'; +type DraftState = { + title: string; + category: string; + type: ContentType; + file: File | null; + mediaUrl?: string; + mediaKind?: MediaKind; + mimeType?: string; + fileName?: string; + fileSize?: number; +}; + +const API_BASE = import.meta.env.VITE_API_BASE_URL ?? '/api'; + +const typeLabels: Record = { + news: 'Новость', + article: 'Статья', + video: 'Видео', + audio: 'Аудио', + graphic: 'Графика', + event: 'Мероприятие' +}; + +const typeOptions: ContentType[] = ['news', 'article', 'video', 'audio', 'graphic', 'event']; + +const initialDraft: DraftState = { title: '', category: 'Новости', type: 'news', file: null }; + +const statusTone: Record = { + Черновик: 'bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-200', + 'На модерации': 'bg-amber-100 text-amber-800 dark:bg-amber-400/20 dark:text-amber-100', + 'На проверке': 'bg-blue-100 text-blue-800 dark:bg-blue-400/20 dark:text-blue-100', + Опубликовано: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-400/20 dark:text-emerald-100', + Архив: 'bg-zinc-100 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-200' +}; + +const visibilityTone: Record = { + Публично: 'border-emerald-300 text-emerald-700 dark:border-emerald-400/50 dark:text-emerald-200', + 'После входа': 'border-university-300 text-university-800 dark:border-university-300/50 dark:text-university-100', + 'По роли': 'border-violet-300 text-violet-800 dark:border-violet-300/50 dark:text-violet-100' +}; + +const viewLabels: Record = { + home: 'Главная', + catalog: 'Каталог', + search: 'Поиск', + detail: 'Материал', + profile: 'Личный кабинет', + admin: 'Администрирование' +}; + +const apiStatusLabels: Record = { + checking: 'Проверка API', + online: 'Gateway подключен', + offline: 'Локальный fallback' +}; + +function cx(...classes: Array) { + return classes.filter(Boolean).join(' '); +} + +async function readApi(path: string, fallback: T): Promise { + try { + const response = await fetch(`${API_BASE}${path}`); + if (!response.ok) { + return fallback; + } + return (await response.json()) as T; + } catch { + return fallback; + } +} + +function formatDate(value: string) { + return new Intl.DateTimeFormat('ru-RU', { day: '2-digit', month: 'long', year: 'numeric' }).format(new Date(value)); +} + +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 mediaKindLabel(kind: MediaKind | undefined) { + const labels: Record = { + image: 'Изображение', + video: 'Видео', + audio: 'Аудио', + document: 'Документ', + other: 'Файл' + }; + return kind ? labels[kind] : 'Медиафайл'; +} + +function formatFileSize(bytes?: number) { + if (!bytes) { + return 'Размер не указан'; + } + if (bytes < 1024) { + return `${bytes} Б`; + } + if (bytes < 1024 * 1024) { + return `${(bytes / 1024).toFixed(1)} КБ`; + } + return `${(bytes / 1024 / 1024).toFixed(1)} МБ`; +} + +function resolveMediaUrl(url?: string) { + if (!url || url.startsWith('blob:') || url.startsWith('data:') || /^https?:\/\//.test(url)) { + return url; + } + if (url.startsWith('/api/')) { + const gatewayBase = API_BASE.endsWith('/api') ? API_BASE.slice(0, -4) : ''; + return `${gatewayBase}${url}`; + } + return `${API_BASE}${url.startsWith('/') ? '' : '/'}${url}`; +} + +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 Button({ className, variant = 'primary', type = 'button', ...props }: ButtonHTMLAttributes & { variant?: 'primary' | 'secondary' | 'ghost' | 'outline' }) { + const variants = { + primary: 'bg-university-800 text-white hover:bg-university-700 shadow-card', + secondary: 'bg-white text-university-900 hover:bg-university-50 dark:bg-white/10 dark:text-white dark:hover:bg-white/15', + ghost: 'bg-transparent text-slate-700 hover:bg-slate-100 dark:text-slate-200 dark:hover:bg-white/10', + outline: 'border border-university-300 bg-transparent text-university-900 hover:bg-university-50 dark:border-white/20 dark:text-white dark:hover:bg-white/10' + }; + + return ( + + + + + + ); +} + +function Header({ + user, + theme, + menuOpen, + searchQuery, + onSearchChange, + onSearchSubmit, + onNavigate, + onToggleTheme, + onOpenMenu, + onOpenAccessibility, + onOpenAuth, + onLogout +}: { + user: UserProfile | null; + theme: Theme; + menuOpen: boolean; + searchQuery: string; + onSearchChange: (value: string) => void; + onSearchSubmit: (event: FormEvent) => void; + onNavigate: (view: View) => void; + onToggleTheme: () => void; + onOpenMenu: () => void; + onOpenAccessibility: () => void; + onOpenAuth: () => void; + onLogout: () => void; +}) { + return ( +
+
+ + +
+ onSearchChange(event.target.value)} + placeholder="Поиск по материалам, тегам, авторам" + aria-label="Глобальный поиск" + /> + +
+ +
+ + + {user ? ( + + ) : ( + + )} + +
+
+
+
+ onSearchChange(event.target.value)} + placeholder="Поиск" + aria-label="Глобальный поиск" + /> + +
+
+
+ ); +} + +function AccessibilityPanel({ + open, + fontScale, + colorScheme, + hideImages, + onFontScale, + onColorScheme, + onHideImages, + onClose +}: { + open: boolean; + fontScale: FontScale; + colorScheme: ColorScheme; + hideImages: boolean; + onFontScale: (value: FontScale) => void; + onColorScheme: (value: ColorScheme) => void; + onHideImages: (value: boolean) => void; + onClose: () => void; +}) { + if (!open) { + return null; + } + + return ( + + ); +} + +function SystemStatus({ message, onClear }: { message: string | null; onClear: () => void }) { + if (!message) { + return
; + } + + return ( +
+
+

{message}

+ +
+
+ ); +} + +function OrientationBar({ view, apiStatus, user }: { view: View; apiStatus: ApiStatus; user: UserProfile | null }) { + const statusTone = { + checking: 'bg-amber-100 text-amber-900 dark:bg-amber-400/20 dark:text-amber-100', + online: 'bg-emerald-100 text-emerald-900 dark:bg-emerald-400/20 dark:text-emerald-100', + offline: 'bg-slate-100 text-slate-700 dark:bg-white/10 dark:text-slate-200' + } satisfies Record; + + return ( +
+
+ +
+ {apiStatusLabels[apiStatus]} + + {user ? `${user.name}: ${user.roles.join(', ')}` : 'Публичный доступ'} + +
+
+
+ ); +} + +function TaskLauncher({ user, onNavigate, onOpenAuth }: { user: UserProfile | null; onNavigate: (view: View) => void; onOpenAuth: () => void }) { + const tasks = [ + { + title: 'Найти материал', + description: 'Полнотекстовый поиск по заголовкам, авторам, тегам и категориям.', + action: 'Открыть поиск', + view: 'search' as View, + authRequired: false + }, + { + title: 'Посмотреть медиатеку', + description: 'Видео, аудио, графика и текстовые материалы в едином каталоге.', + action: 'Перейти в каталог', + view: 'catalog' as View, + authRequired: false + }, + { + title: 'Настроить подписки', + description: 'Категории, теги и спикеры для персональной ленты.', + action: user ? 'Открыть профиль' : 'Войти', + view: 'profile' as View, + authRequired: true + }, + { + title: 'Проверить модерацию', + description: 'Очередь материалов, роли, журнал действий и аналитика.', + action: user ? 'Открыть админку' : 'Войти', + view: 'admin' as View, + authRequired: true + } + ]; + + return ( +
+
+
+
+

Частые задачи

+

+ Сначала действие, потом раздел +

+
+

+ Блок снижает нагрузку на память: пользователю не нужно помнить, где находится нужная функция. +

+
+
+ {tasks.map((task, index) => ( + + ))} +
+
+
+ ); +} + +function MediaAlternatives({ item, compact = false }: { item?: ContentItem; compact?: boolean }) { + const mediaKind = item?.mediaKind ?? inferMediaKind(item?.mimeType, item?.fileName); + const metadata = item?.mediaUrl + ? [mediaKindLabel(mediaKind), item.fileName ?? 'Имя файла не указано', formatFileSize(item.fileSize)] + : ['Стенограмма: демо', 'Субтитры: демо', 'Описание: демо']; + + return ( +
+

Альтернативы медиа

+

+ {item?.mediaUrl + ? 'Файл доступен для просмотра в прототипе. В production здесь также будут стенограмма, субтитры и аудиоописание.' + : 'В production здесь будут стенограмма, субтитры, аудиоописание и сведения о файле. В прототипе показан доступный placeholder без реальных медиа.'} +

+
+ {metadata.map((entry) => ( + + {entry} + + ))} +
+
+ ); +} + +function ErrorSummary({ message }: { message: string | null }) { + if (!message) { + return null; + } + + return ( +
+

Проверьте форму

+

{message}

+
+ ); +} + +function HomeView({ + content, + speakers, + activeTab, + user, + onActiveTab, + onNavigate, + onOpen, + onPreview, + onOpenAuth +}: { + content: ContentItem[]; + speakers: Speaker[]; + activeTab: string; + user: UserProfile | null; + onActiveTab: (value: string) => void; + onNavigate: (view: View) => void; + onOpen: (item: ContentItem) => void; + onPreview: (item: ContentItem) => void; + onOpenAuth: () => void; +}) { + const hero = content[0] ?? demoContent[0]; + const tabbed = activeTab === 'Все' ? content.slice(0, 4) : content.filter((item) => item.category === activeTab).slice(0, 4); + const media = content.filter((item) => item.type === 'video' || item.type === 'audio' || item.type === 'graphic').slice(0, 3); + const events = content.filter((item) => item.type === 'event'); + + return ( + <> +
+
+ Демо-прототип по ТЗ +

+ Единая платформа управления медиаконтентом университета +

+

+ Публичный и персонифицированный контуры, полнотекстовый поиск, медиатека, подписки, уведомления, RBAC и административные сценарии в одном интерфейсе. +

+
+ + +
+
+ {[ + ['2 контура', 'Публичный и после входа'], + ['4 роли', 'Администратор, редактор, менеджер, пользователь'], + ['Demo-only', 'Без реальных данных и брендинга'] + ].map(([value, label]) => ( +
+

{value}

+

{label}

+
+ ))} +
+
+ +
+ +
+
+ {typeLabels[hero.type]} + {hero.status} +
+

{hero.title}

+

{hero.lead}

+
+ + +
+
+
+
+ + + +
+ +
+ {[...demoCategories, ...demoTags.slice(0, 4)].map((item) => ( + + ))} +
+
+ +
+
+ + +
+
+ {['Все', ...demoCategories].map((category) => ( + + ))} +
+
+ {tabbed.map((item) => ( + + ))} +
+
+ +
+ +
+ {media.map((item) => ( + + ))} +
+
+ +
+
+

Мероприятия

+

Анонсы до уточнения модели Event

+

+ В первом прототипе мероприятия показаны как тип медиаконтента, потому что в ТЗ они есть в функциях, но отсутствуют в таблице информационных объектов. +

+ +
+
+ {events.map((item) => ( + + ))} +
+
+ +
+ +
+ {speakers.map((speaker) => ( +
+
{speaker.name.slice(-2)}
+

{speaker.name}

+

{speaker.role}

+
+ {speaker.topics.map((topic) => ( + + {topic} + + ))} +
+

+ {speaker.materials} материалов, {speaker.subscribers} подписчиков +

+ +
+ ))} +
+
+ + ); +} + +function CatalogView({ + content, + categoryFilter, + typeFilter, + sortMode, + onCategoryFilter, + onTypeFilter, + onSortMode, + onOpen, + onPreview +}: { + content: ContentItem[]; + categoryFilter: string; + typeFilter: ContentType | 'all'; + sortMode: SortMode; + onCategoryFilter: (value: string) => void; + onTypeFilter: (value: ContentType | 'all') => void; + onSortMode: (value: SortMode) => void; + onOpen: (item: ContentItem) => void; + onPreview: (item: ContentItem) => void; +}) { + const filtered = [...content] + .filter((item) => categoryFilter === 'Все' || item.category === categoryFilter) + .filter((item) => typeFilter === 'all' || item.type === typeFilter) + .sort((a, b) => (sortMode === 'popular' ? b.views - a.views : new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime())); + + return ( +
+ +
+ + + +
+
+ Найдено: {filtered.length} + Категория: {categoryFilter} + Тип: {typeFilter === 'all' ? 'Все' : typeLabels[typeFilter]} +
+ + {filtered.length ? ( +
+ {filtered.map((item) => ( + + ))} +
+ ) : ( + + )} +
+ ); +} + +function SearchView({ + query, + results, + onQuery, + onOpen, + onPreview +}: { + query: string; + results: ContentItem[]; + onQuery: (value: string) => void; + onOpen: (item: ContentItem) => void; + onPreview: (item: ContentItem) => void; +}) { + return ( +
+ +
+ + +
+
+

+ Найдено: {results.length} +

+
+ {results.length ? ( +
+ {results.map((item) => ( + + ))} +
+ ) : ( + + )} +
+
+
+ ); +} + +function DetailView({ + item, + comments, + user, + commentText, + commentError, + onCommentText, + onSubmitComment, + onPreview, + onOpenAuth +}: { + item: ContentItem; + comments: CommentItem[]; + user: UserProfile | null; + commentText: string; + commentError: string | null; + onCommentText: (value: string) => void; + onSubmitComment: (event: FormEvent) => void; + onPreview: (item: ContentItem) => void; + onOpenAuth: () => void; +}) { + return ( +
+
+
+
+ {typeLabels[item.type]} + {item.status} + {item.visibility} +
+

{item.title}

+

{item.lead}

+ + +
+

{item.body}

+

+ Автор: {item.author}. Дата: {formatDate(item.publishedAt)}. Просмотры: {item.views.toLocaleString('ru-RU')}. +

+
+
+ + +
+
+ +