first commit 2
This commit is contained in:
31
apps/gateway/Dockerfile
Normal file
31
apps/gateway/Dockerfile
Normal 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
19
apps/gateway/package.json
Normal 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
841
apps/gateway/src/index.ts
Normal 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}`);
|
||||
});
|
||||
14
apps/gateway/tsconfig.json
Normal file
14
apps/gateway/tsconfig.json
Normal 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
17
apps/web/Dockerfile
Normal 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
16
apps/web/index.html
Normal 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
29
apps/web/nginx.conf
Normal 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
26
apps/web/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
apps/web/postcss.config.cjs
Normal file
6
apps/web/postcss.config.cjs
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
};
|
||||
1891
apps/web/src/App.tsx
Normal file
1891
apps/web/src/App.tsx
Normal file
File diff suppressed because it is too large
Load Diff
145
apps/web/src/components/AppMenu.tsx
Normal file
145
apps/web/src/components/AppMenu.tsx
Normal 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
216
apps/web/src/demo-data.ts
Normal 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
140
apps/web/src/index.css
Normal 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
10
apps/web/src/main.tsx
Normal 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
71
apps/web/src/types.ts
Normal 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
1
apps/web/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
40
apps/web/tailwind.config.cjs
Normal file
40
apps/web/tailwind.config.cjs
Normal 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
21
apps/web/tsconfig.json
Normal 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
15
apps/web/vite.config.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user