diff --git a/apps/web/.env.example b/apps/web/.env.example new file mode 100644 index 0000000..b0e8fc8 --- /dev/null +++ b/apps/web/.env.example @@ -0,0 +1 @@ +VITE_API_URL=http://localhost:8000/api diff --git a/apps/web/.gitignore b/apps/web/.gitignore new file mode 100644 index 0000000..1a202a3 --- /dev/null +++ b/apps/web/.gitignore @@ -0,0 +1,9 @@ +node_modules +dist +coverage +.env +.DS_Store +*.local +*.log +*.tsbuildinfo +.backend_known_hosts diff --git a/apps/web/.npmrc b/apps/web/.npmrc new file mode 100644 index 0000000..f1dc991 --- /dev/null +++ b/apps/web/.npmrc @@ -0,0 +1,3 @@ +save-exact=true +strict-peer-dependencies=true +auto-install-peers=false diff --git a/apps/web/.prettierrc b/apps/web/.prettierrc new file mode 100644 index 0000000..6bb3bf2 --- /dev/null +++ b/apps/web/.prettierrc @@ -0,0 +1,6 @@ +{ + "semi": true, + "singleQuote": false, + "trailingComma": "all", + "printWidth": 88 +} diff --git a/apps/web/DESIGN.md b/apps/web/DESIGN.md new file mode 100644 index 0000000..2f19646 --- /dev/null +++ b/apps/web/DESIGN.md @@ -0,0 +1,156 @@ +--- +version: alpha +name: DSTU Editorial Signal +description: A calm editorial media system for a large technical university. +colors: + primary: "#123C36" + primary-strong: "#082B27" + accent: "#D65A3A" + accent-soft: "#F6DDD4" + paper: "#F5F2EA" + surface: "#FFFDF8" + ink: "#17201E" + muted: "#66706D" + line: "#D9D8D0" + success: "#247A5A" + warning: "#A96616" + danger: "#B43D3D" + on-primary: "#FFFFFF" +typography: + display: + fontFamily: "Georgia, Cambria, serif" + fontSize: 4rem + fontWeight: 500 + lineHeight: 1.02 + letterSpacing: "-0.035em" + h1: + fontFamily: "Georgia, Cambria, serif" + fontSize: 3rem + fontWeight: 500 + lineHeight: 1.08 + h2: + fontFamily: "Georgia, Cambria, serif" + fontSize: 2rem + fontWeight: 500 + lineHeight: 1.15 + body: + fontFamily: "Inter, Arial, sans-serif" + fontSize: 1rem + fontWeight: 400 + lineHeight: 1.65 + label: + fontFamily: "Inter, Arial, sans-serif" + fontSize: 0.75rem + fontWeight: 700 + lineHeight: 1.2 + letterSpacing: "0.08em" +rounded: + sm: 0.375rem + md: 0.75rem + lg: 1.25rem +spacing: + xs: 0.25rem + sm: 0.5rem + md: 1rem + lg: 1.5rem + xl: 2.5rem + 2xl: 4rem +components: + button-primary: + backgroundColor: "{colors.primary}" + textColor: "{colors.on-primary}" + rounded: "{rounded.sm}" + padding: "0.75rem 1.125rem" + height: "2.75rem" + button-accent: + backgroundColor: "{colors.accent}" + textColor: "{colors.on-primary}" + rounded: "{rounded.sm}" + padding: "0.75rem 1.125rem" + height: "2.75rem" + card: + backgroundColor: "{colors.surface}" + textColor: "{colors.ink}" + rounded: "{rounded.md}" + padding: "1.25rem" +motion: + feedback: 140ms + content: 240ms + easing: "cubic-bezier(0.2, 0, 0, 1)" +--- + +## Overview + +The portal should feel like the digital edition of a respected technical university +newspaper combined with the calm wayfinding of a contemporary campus. It is made for +students checking what happens today, researchers reading long-form work, and staff +moving material through an editorial workflow. + +The page is a publication first and a software dashboard second. Public pages should +feel authored and alive. Administrative pages inherit the same typography, color and +editorial hierarchy rather than becoming a generic enterprise product. + +## Colors + +The canvas is warm paper, never sterile white. Deep green is institutional and +architectural; it anchors navigation, important buttons and large information blocks. +Terracotta is scarce and energetic, reserved for the current moment: primary calls to +action, live status and selected states. Body copy uses softened ink rather than black. + +Status colors communicate meaning and never replace a written label. + +## Typography + +Headlines use a restrained editorial serif. Interface copy, metadata and controls use a +neutral sans serif. Large titles are allowed to breathe, while dashboards use smaller, +denser headings. Long articles have a comfortable measure of roughly 68 characters. + +Uppercase is reserved for short section labels and metadata, never paragraphs or +navigation menus. + +## Layout + +Public pages use a 12-column editorial grid with deliberate asymmetry: a leading story +may occupy seven columns while a compact news rail occupies five. The maximum content +width follows the available viewport with a readable editorial cap. On narrow screens, +content becomes a single readable column without decorative reordering. + +Dashboard layouts use a fixed desktop rail and a drawer on mobile. Tables may scroll, +but the page itself must not. + +Responsive behavior is fluid rather than tied to named device widths. Layouts use +`minmax()`, `clamp()`, fractional columns and content-based wrapping. Media tabs scroll +when space is limited and collections gain columns only when their content fits. + +## Elevation & Depth + +Depth is mostly created with borders, overlapping paper surfaces and spacing. Shadows +are soft and rare. Hover states may lift a card by two pixels but should never make the +interface feel springy. + +## Shapes + +Corners are modest. Buttons and inputs use 0.375rem corners; cards use 0.75rem; feature +media may use 1.25rem. Pills are reserved for compact filters, tags and status, not general +containers. + +## Components + +Cards expose a clear reading order: section, title, summary, then metadata. Images do +not carry text overlays except in the single leading story. Forms use persistent labels, +visible focus rings and errors placed next to fields. + +Motion is quick and mechanical. Menus, dialogs and panels may combine opacity with a +small transform. Content transitions use cross-fades. Nothing bounces or takes longer +than 300ms. Reduced-motion users receive immediate state changes. + +## Do's and Don'ts + +- **Do** make the first screen feel like today's university edition. +- **Do** let real Russian headlines create the visual rhythm. +- **Do** preserve generous reading space around long-form content. +- **Do** use terracotta sparingly so it keeps meaning. +- **Don't** use gradients, glassmorphism, neon glows or giant rounded containers. +- **Don't** make every card animate independently. +- **Don't** turn the public portal into a grid of identical dashboard widgets. +- **Don't** hide essential labels behind icons. diff --git a/apps/web/IMG_4963.png b/apps/web/IMG_4963.png new file mode 100644 index 0000000..0dd173b Binary files /dev/null and b/apps/web/IMG_4963.png differ diff --git a/apps/web/README.md b/apps/web/README.md new file mode 100644 index 0000000..a7eb7df --- /dev/null +++ b/apps/web/README.md @@ -0,0 +1,98 @@ +# ДГТУ МЕДИА + +Frontend MVP информационной системы управления медиаконтентом университета. Проект +выполнен на JavaScript/JSX, React, Vite и Tailwind CSS. + +## Запуск + +Требуются Node.js 22+ и Corepack. + +```bash +corepack prepare pnpm@11.0.0 --activate +corepack pnpm install +corepack pnpm dev +``` + +Откройте `http://localhost:5173`. + +## Проверки + +```bash +corepack pnpm lint +corepack pnpm test +corepack pnpm build +``` + +## Структура + +```text +src/ + app/ маршрутизация, layouts и состояние сессии + pages/ публичные страницы и ролевые кабинеты + shared/ + api/ Axios-клиент и общая обработка ошибок + data/ реалистичные mock-данные + lib/ небольшие общие helpers + ui/ переиспользуемые компоненты +``` + +В `DESIGN.md` описаны дизайн-токены, визуальный характер, правила компонентов и +анимаций. + +## Реализованные страницы + +- главная редакционная страница; +- каталог с поиском и фильтрами через URL; +- страница материала; +- афиша событий; +- медиаканалы: радио, журналы и социальные сети; +- страница о медиапортале; +- вход и тестовые роли; +- профиль пользователя; +- кабинет редактора и форма материала; +- очередь модератора; +- статистика и пользователи администратора; +- страницы 403 и 404 с SVG-анимацией. + +## Тестовые роли + +Пароль может быть любым от 6 символов. + +| Роль | Учётная запись | +| --- | --- | +| Пользователь | `user@dstu.ru` | +| Редактор | `editor@dstu.ru` | +| Модератор | `moderator@dstu.ru` | +| Администратор | `admin@dstu.ru` | + +Роль также можно переключить в шапке кабинета для демонстрации интерфейсов. + +## Ожидаемые backend endpoints + +- `POST /auth/login`, `POST /auth/refresh`, `POST /auth/logout`; +- `GET/PATCH /users/me`; +- CRUD `/materials`, `/categories`, `/tags`, `/events`; +- `/materials/:id/comments`, `/subscriptions`, `/notifications`; +- `/moderation/queue`, `/moderation/:id/approve`, `/moderation/:id/return`; +- `/admin/users`, `/admin/stats`, `/admin/audit-log`; +- `POST /uploads`. + +Адрес API задаётся через `VITE_API_URL`. Пока интерфейс использует локальные mock-данные. + +## Безопасность зависимостей + +Проект закреплён на pnpm 11. В `pnpm-workspace.yaml` включены: + +- `minimumReleaseAge: 1440`; +- `blockExoticSubdeps: true`; +- `trustPolicy: no-downgrade`; +- явный `allowBuilds` только для `esbuild` и `@tailwindcss/oxide`. + +Lockfile необходимо хранить в репозитории. + +## Ограничения MVP + +- данные не сохраняются после обновления страницы; +- загрузка файлов и комментарии представлены интерфейсом без backend; +- внешние изображения загружаются с Unsplash; +- график администратора демонстрационный. diff --git a/apps/web/eslint.config.js b/apps/web/eslint.config.js new file mode 100644 index 0000000..6ea65fe --- /dev/null +++ b/apps/web/eslint.config.js @@ -0,0 +1,31 @@ +import js from "@eslint/js"; +import globals from "globals"; +import reactHooks from "eslint-plugin-react-hooks"; +import reactRefresh from "eslint-plugin-react-refresh"; + +export default [ + { ignores: ["dist", "coverage"] }, + { + ...js.configs.recommended, + files: ["**/*.{js,jsx}"], + languageOptions: { + ecmaVersion: 2022, + globals: { ...globals.browser, ...globals.node, ...globals.vitest }, + parserOptions: { + ecmaFeatures: { jsx: true }, + sourceType: "module", + }, + }, + plugins: { + "react-hooks": reactHooks, + "react-refresh": reactRefresh, + }, + rules: { + ...reactHooks.configs.recommended.rules, + "react-refresh/only-export-components": [ + "warn", + { allowConstantExport: true }, + ], + }, + }, +]; diff --git a/apps/web/index.html b/apps/web/index.html index 0237a74..db0c2e2 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -5,12 +5,22 @@ - Fable | Медиаплатформа университета + + ДГТУ МЕДИА +
- + diff --git a/apps/web/package.json b/apps/web/package.json index ca14afd..5d58113 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,26 +1,49 @@ { "name": "@fable/web", - "private": true, "version": "0.1.0", + "private": true, "type": "module", "scripts": { "dev": "vite --host 0.0.0.0", - "build": "tsc --noEmit && vite build", - "preview": "vite preview --host 0.0.0.0" + "build": "vite build", + "preview": "vite preview --host 0.0.0.0", + "check": "eslint .", + "lint": "eslint .", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { - "react": "^19.1.0", - "react-dom": "^19.1.0" + "@hookform/resolvers": "5.2.2", + "@tanstack/react-query": "5.90.2", + "axios": "1.12.2", + "clsx": "2.1.1", + "framer-motion": "12.23.22", + "lucide-react": "0.544.0", + "react": "19.2.7", + "react-dom": "19.2.7", + "react-hook-form": "7.63.0", + "react-router-dom": "7.9.1", + "tailwind-merge": "3.3.1", + "zod": "4.1.11", + "zustand": "5.0.8" }, "devDependencies": { - "@types/node": "^24.0.0", - "@types/react": "^19.1.0", - "@types/react-dom": "^19.1.0", - "@vitejs/plugin-react": "^5.0.0", - "autoprefixer": "^10.4.20", - "postcss": "^8.4.49", - "tailwindcss": "^3.4.17", - "typescript": "^5.9.0", - "vite": "^8.0.16" + "@eslint/js": "9.36.0", + "@tailwindcss/vite": "4.1.13", + "@testing-library/jest-dom": "6.8.0", + "@testing-library/react": "16.3.0", + "@testing-library/user-event": "14.6.1", + "@vitejs/plugin-react": "5.0.4", + "autoprefixer": "10.4.21", + "eslint": "9.36.0", + "eslint-plugin-react-hooks": "5.2.0", + "eslint-plugin-react-refresh": "0.4.22", + "globals": "16.4.0", + "jsdom": "27.0.0", + "postcss": "8.5.6", + "prettier": "3.6.2", + "tailwindcss": "4.1.13", + "vite": "7.1.7", + "vitest": "3.2.4" } } diff --git a/apps/web/postcss.config.cjs b/apps/web/postcss.config.cjs deleted file mode 100644 index 5cbc2c7..0000000 --- a/apps/web/postcss.config.cjs +++ /dev/null @@ -1,6 +0,0 @@ -module.exports = { - plugins: { - tailwindcss: {}, - autoprefixer: {} - } -}; diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx deleted file mode 100644 index 61b6424..0000000 --- a/apps/web/src/App.tsx +++ /dev/null @@ -1,1891 +0,0 @@ -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')}. -

-
-
- - -
-
- -