Merge pull request 'Replace web app with DSTU media frontend' (#1) from sovaTyT/67:codex/dstu-media-frontend into main

Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
2026-06-22 20:28:42 +03:00
64 changed files with 8311 additions and 3813 deletions

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

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

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

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

3
apps/web/.npmrc Normal file
View File

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

6
apps/web/.prettierrc Normal file
View File

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

156
apps/web/DESIGN.md Normal file
View File

@@ -0,0 +1,156 @@
---
version: alpha
name: DSTU Editorial Signal
description: A calm editorial media system for a large technical university.
colors:
primary: "#123C36"
primary-strong: "#082B27"
accent: "#D65A3A"
accent-soft: "#F6DDD4"
paper: "#F5F2EA"
surface: "#FFFDF8"
ink: "#17201E"
muted: "#66706D"
line: "#D9D8D0"
success: "#247A5A"
warning: "#A96616"
danger: "#B43D3D"
on-primary: "#FFFFFF"
typography:
display:
fontFamily: "Georgia, Cambria, serif"
fontSize: 4rem
fontWeight: 500
lineHeight: 1.02
letterSpacing: "-0.035em"
h1:
fontFamily: "Georgia, Cambria, serif"
fontSize: 3rem
fontWeight: 500
lineHeight: 1.08
h2:
fontFamily: "Georgia, Cambria, serif"
fontSize: 2rem
fontWeight: 500
lineHeight: 1.15
body:
fontFamily: "Inter, Arial, sans-serif"
fontSize: 1rem
fontWeight: 400
lineHeight: 1.65
label:
fontFamily: "Inter, Arial, sans-serif"
fontSize: 0.75rem
fontWeight: 700
lineHeight: 1.2
letterSpacing: "0.08em"
rounded:
sm: 0.375rem
md: 0.75rem
lg: 1.25rem
spacing:
xs: 0.25rem
sm: 0.5rem
md: 1rem
lg: 1.5rem
xl: 2.5rem
2xl: 4rem
components:
button-primary:
backgroundColor: "{colors.primary}"
textColor: "{colors.on-primary}"
rounded: "{rounded.sm}"
padding: "0.75rem 1.125rem"
height: "2.75rem"
button-accent:
backgroundColor: "{colors.accent}"
textColor: "{colors.on-primary}"
rounded: "{rounded.sm}"
padding: "0.75rem 1.125rem"
height: "2.75rem"
card:
backgroundColor: "{colors.surface}"
textColor: "{colors.ink}"
rounded: "{rounded.md}"
padding: "1.25rem"
motion:
feedback: 140ms
content: 240ms
easing: "cubic-bezier(0.2, 0, 0, 1)"
---
## Overview
The portal should feel like the digital edition of a respected technical university
newspaper combined with the calm wayfinding of a contemporary campus. It is made for
students checking what happens today, researchers reading long-form work, and staff
moving material through an editorial workflow.
The page is a publication first and a software dashboard second. Public pages should
feel authored and alive. Administrative pages inherit the same typography, color and
editorial hierarchy rather than becoming a generic enterprise product.
## Colors
The canvas is warm paper, never sterile white. Deep green is institutional and
architectural; it anchors navigation, important buttons and large information blocks.
Terracotta is scarce and energetic, reserved for the current moment: primary calls to
action, live status and selected states. Body copy uses softened ink rather than black.
Status colors communicate meaning and never replace a written label.
## Typography
Headlines use a restrained editorial serif. Interface copy, metadata and controls use a
neutral sans serif. Large titles are allowed to breathe, while dashboards use smaller,
denser headings. Long articles have a comfortable measure of roughly 68 characters.
Uppercase is reserved for short section labels and metadata, never paragraphs or
navigation menus.
## Layout
Public pages use a 12-column editorial grid with deliberate asymmetry: a leading story
may occupy seven columns while a compact news rail occupies five. The maximum content
width follows the available viewport with a readable editorial cap. On narrow screens,
content becomes a single readable column without decorative reordering.
Dashboard layouts use a fixed desktop rail and a drawer on mobile. Tables may scroll,
but the page itself must not.
Responsive behavior is fluid rather than tied to named device widths. Layouts use
`minmax()`, `clamp()`, fractional columns and content-based wrapping. Media tabs scroll
when space is limited and collections gain columns only when their content fits.
## Elevation & Depth
Depth is mostly created with borders, overlapping paper surfaces and spacing. Shadows
are soft and rare. Hover states may lift a card by two pixels but should never make the
interface feel springy.
## Shapes
Corners are modest. Buttons and inputs use 0.375rem corners; cards use 0.75rem; feature
media may use 1.25rem. Pills are reserved for compact filters, tags and status, not general
containers.
## Components
Cards expose a clear reading order: section, title, summary, then metadata. Images do
not carry text overlays except in the single leading story. Forms use persistent labels,
visible focus rings and errors placed next to fields.
Motion is quick and mechanical. Menus, dialogs and panels may combine opacity with a
small transform. Content transitions use cross-fades. Nothing bounces or takes longer
than 300ms. Reduced-motion users receive immediate state changes.
## Do's and Don'ts
- **Do** make the first screen feel like today's university edition.
- **Do** let real Russian headlines create the visual rhythm.
- **Do** preserve generous reading space around long-form content.
- **Do** use terracotta sparingly so it keeps meaning.
- **Don't** use gradients, glassmorphism, neon glows or giant rounded containers.
- **Don't** make every card animate independently.
- **Don't** turn the public portal into a grid of identical dashboard widgets.
- **Don't** hide essential labels behind icons.

BIN
apps/web/IMG_4963.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

98
apps/web/README.md Normal file
View File

@@ -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;
- график администратора демонстрационный.

31
apps/web/eslint.config.js Normal file
View File

@@ -0,0 +1,31 @@
import js from "@eslint/js";
import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
export default [
{ ignores: ["dist", "coverage"] },
{
...js.configs.recommended,
files: ["**/*.{js,jsx}"],
languageOptions: {
ecmaVersion: 2022,
globals: { ...globals.browser, ...globals.node, ...globals.vitest },
parserOptions: {
ecmaFeatures: { jsx: true },
sourceType: "module",
},
},
plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
"react-refresh/only-export-components": [
"warn",
{ allowConstantExport: true },
],
},
},
];

View File

@@ -5,12 +5,22 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
name="description"
content="Демо-прототип университетской платформы управления медиаконтентом Fable"
content="Медиапортал ДГТУ: новости, исследования, события и университетская жизнь."
/>
<title>Fable | Медиаплатформа университета</title>
<meta name="theme-color" content="#f5f2ea" />
<title>ДГТУ МЕДИА</title>
<script>
(() => {
const savedTheme = localStorage.getItem("theme");
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
if (savedTheme === "dark" || (!savedTheme && prefersDark)) {
document.documentElement.classList.add("dark");
}
})();
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

View File

@@ -1,26 +1,49 @@
{
"name": "@fable/web",
"private": true,
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "tsc --noEmit && vite build",
"preview": "vite preview --host 0.0.0.0"
"build": "vite build",
"preview": "vite preview --host 0.0.0.0",
"check": "eslint .",
"lint": "eslint .",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"react": "^19.1.0",
"react-dom": "^19.1.0"
"@hookform/resolvers": "5.2.2",
"@tanstack/react-query": "5.90.2",
"axios": "1.12.2",
"clsx": "2.1.1",
"framer-motion": "12.23.22",
"lucide-react": "0.544.0",
"react": "19.2.7",
"react-dom": "19.2.7",
"react-hook-form": "7.63.0",
"react-router-dom": "7.9.1",
"tailwind-merge": "3.3.1",
"zod": "4.1.11",
"zustand": "5.0.8"
},
"devDependencies": {
"@types/node": "^24.0.0",
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.0",
"@vitejs/plugin-react": "^5.0.0",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"typescript": "^5.9.0",
"vite": "^8.0.16"
"@eslint/js": "9.36.0",
"@tailwindcss/vite": "4.1.13",
"@testing-library/jest-dom": "6.8.0",
"@testing-library/react": "16.3.0",
"@testing-library/user-event": "14.6.1",
"@vitejs/plugin-react": "5.0.4",
"autoprefixer": "10.4.21",
"eslint": "9.36.0",
"eslint-plugin-react-hooks": "5.2.0",
"eslint-plugin-react-refresh": "0.4.22",
"globals": "16.4.0",
"jsdom": "27.0.0",
"postcss": "8.5.6",
"prettier": "3.6.2",
"tailwindcss": "4.1.13",
"vite": "7.1.7",
"vitest": "3.2.4"
}
}

View File

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

File diff suppressed because it is too large Load Diff

162
apps/web/src/app/App.jsx Normal file
View File

@@ -0,0 +1,162 @@
import { lazy, Suspense } from "react";
import { Navigate, Route, Routes } from "react-router-dom";
import { PublicLayout } from "./layouts/PublicLayout";
import { useSession } from "./store/session";
import { HomePage } from "../pages/HomePage";
import { NotFoundPage } from "../pages/NotFoundPage";
import { Skeleton } from "../shared/ui/States";
const MaterialsPage = lazy(() =>
import("../pages/MaterialsPage").then((module) => ({
default: module.MaterialsPage,
})),
);
const MaterialPage = lazy(() =>
import("../pages/MaterialPage").then((module) => ({
default: module.MaterialPage,
})),
);
const EventsPage = lazy(() =>
import("../pages/EventsPage").then((module) => ({
default: module.EventsPage,
})),
);
const AboutPage = lazy(() =>
import("../pages/AboutPage").then((module) => ({
default: module.AboutPage,
})),
);
const MediaChannelsPage = lazy(() =>
import("../pages/MediaChannelsPage").then((module) => ({
default: module.MediaChannelsPage,
})),
);
const LoginPage = lazy(() =>
import("../pages/LoginPage").then((module) => ({
default: module.LoginPage,
})),
);
const ForbiddenPage = lazy(() =>
import("../pages/ForbiddenPage").then((module) => ({
default: module.ForbiddenPage,
})),
);
const CabinetLayout = lazy(() =>
import("./layouts/CabinetLayout").then((module) => ({
default: module.CabinetLayout,
})),
);
const CabinetHomePage = lazy(() =>
import("../pages/cabinet/CabinetHomePage").then((module) => ({
default: module.CabinetHomePage,
})),
);
const ProfilePage = lazy(() =>
import("../pages/cabinet/ProfilePage").then((module) => ({
default: module.ProfilePage,
})),
);
const EditorPage = lazy(() =>
import("../pages/cabinet/EditorPage").then((module) => ({
default: module.EditorPage,
})),
);
const MaterialFormPage = lazy(() =>
import("../pages/cabinet/MaterialFormPage").then((module) => ({
default: module.MaterialFormPage,
})),
);
const ModeratorPage = lazy(() =>
import("../pages/cabinet/ModeratorPage").then((module) => ({
default: module.ModeratorPage,
})),
);
const AdminPage = lazy(() =>
import("../pages/cabinet/AdminPage").then((module) => ({
default: module.AdminPage,
})),
);
function CabinetFallback() {
return (
<div className="grid gap-4 p-6">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-32 w-full" />
<Skeleton className="h-56 w-full" />
</div>
);
}
function RequireAuth({ children, roles }) {
const user = useSession((state) => state.user);
if (!user) return <Navigate to="/login" replace />;
if (roles && !roles.includes(user.role)) return <Navigate to="/403" replace />;
return children;
}
export function App() {
return (
<Suspense fallback={<CabinetFallback />}>
<Routes>
<Route element={<PublicLayout />}>
<Route index element={<HomePage />} />
<Route path="materials" element={<MaterialsPage />} />
<Route path="materials/:slug" element={<MaterialPage />} />
<Route path="events" element={<EventsPage />} />
<Route path="media" element={<MediaChannelsPage />} />
<Route path="about" element={<AboutPage />} />
</Route>
<Route path="/login" element={<LoginPage />} />
<Route path="/403" element={<ForbiddenPage />} />
<Route
path="/cabinet"
element={
<RequireAuth>
<CabinetLayout />
</RequireAuth>
}
>
<Route index element={<CabinetHomePage />} />
<Route path="profile" element={<ProfilePage />} />
<Route
path="editor"
element={
<RequireAuth roles={["editor", "admin"]}>
<EditorPage />
</RequireAuth>
}
/>
<Route
path="editor/new"
element={
<RequireAuth roles={["editor", "admin"]}>
<MaterialFormPage />
</RequireAuth>
}
/>
<Route
path="moderation"
element={
<RequireAuth roles={["moderator", "admin"]}>
<ModeratorPage />
</RequireAuth>
}
/>
<Route
path="admin"
element={
<RequireAuth roles={["admin"]}>
<AdminPage />
</RequireAuth>
}
/>
</Route>
<Route path="*" element={<NotFoundPage />} />
</Routes>
</Suspense>
);
}

View File

@@ -0,0 +1,21 @@
import { screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { renderWithProviders } from "../test/renderWithProviders";
import { App } from "./App";
import { useSession } from "./store/session";
describe("защищенные маршруты", () => {
beforeEach(() => {
useSession.setState({ user: null });
});
it("перенаправляет гостя со страницы кабинета на вход", async () => {
renderWithProviders(
<MemoryRouter initialEntries={["/cabinet"]}>
<App />
</MemoryRouter>,
);
expect(await screen.findByRole("heading", { name: "Вход в систему" })).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,160 @@
import {
BarChart3,
BookOpen,
ChevronRight,
FilePenLine,
LayoutDashboard,
LogOut,
Menu,
ShieldCheck,
UserRound,
Users,
X,
} from "lucide-react";
import { AnimatePresence } from "framer-motion";
import { useState } from "react";
import { Link, NavLink, Outlet, useLocation, useNavigate } from "react-router-dom";
import { useSession } from "../store/session";
import { Button } from "../../shared/ui/Button";
import { Badge } from "../../shared/ui/Badge";
import { DstuLogo } from "../../shared/ui/DstuLogo";
import { cn } from "../../shared/lib/cn";
import { ThemeToggle } from "../../shared/ui/ThemeToggle";
import { PageTransition } from "../../shared/ui/PageTransition";
const roleLabels = {
user: "Пользователь",
editor: "Редактор",
moderator: "Модератор",
admin: "Администратор",
};
const navItems = [
{ to: "/cabinet", label: "Обзор", icon: LayoutDashboard, roles: ["user", "editor", "moderator", "admin"], end: true },
{ to: "/cabinet/profile", label: "Профиль", icon: UserRound, roles: ["user", "editor", "moderator", "admin"] },
{ to: "/cabinet/editor", label: "Мои материалы", icon: FilePenLine, roles: ["editor", "admin"] },
{ to: "/cabinet/moderation", label: "Модерация", icon: ShieldCheck, roles: ["moderator", "admin"] },
{ to: "/cabinet/admin", label: "Управление", icon: Users, roles: ["admin"] },
];
export function CabinetLayout() {
const [open, setOpen] = useState(false);
const user = useSession((state) => state.user);
const logout = useSession((state) => state.logout);
const switchRole = useSession((state) => state.switchRole);
const navigate = useNavigate();
const location = useLocation();
const visibleNav = navItems.filter((item) => item.roles.includes(user.role));
const navigation = (
<>
<div className="flex h-18 items-center justify-between border-b border-white/10 px-5">
<Link to="/" className="flex items-center gap-3 text-white" onClick={() => setOpen(false)}>
<DstuLogo className="size-9" />
<span className="font-serif text-lg">ДГТУ МЕДИА</span>
</Link>
<button
className="grid size-10 place-items-center rounded-md text-white/80 hover:bg-white/10 hover:text-white lg:hidden"
onClick={() => setOpen(false)}
aria-label="Закрыть меню"
>
<X className="size-5" />
</button>
</div>
<nav className="flex-1 space-y-1 p-4">
{visibleNav.map(({ to, label, icon: Icon, end }) => (
<NavLink
key={to}
to={to}
end={end}
onClick={() => setOpen(false)}
className={({ isActive }) =>
cn(
"flex items-center gap-3 rounded-md px-3 py-2.5 text-sm font-semibold transition",
isActive
? "bg-dstu-menu-accent text-dstu-menu"
: "text-white/70 hover:bg-white/10 hover:text-white",
)
}
>
<Icon className="size-4.5" />
{label}
</NavLink>
))}
</nav>
<div className="border-t border-white/10 p-4">
<button
onClick={() => {
logout();
navigate("/");
}}
className="flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-sm font-semibold text-white/70 hover:bg-white/10 hover:text-white"
>
<LogOut className="size-4.5" /> Выйти
</button>
</div>
</>
);
return (
<div className="min-h-screen bg-paper lg:grid lg:grid-cols-[clamp(14rem,20vw,18rem)_minmax(0,1fr)]">
<aside className="hidden min-h-screen flex-col bg-dstu-menu text-white lg:flex">{navigation}</aside>
{open && (
<>
<button className="fixed inset-0 z-40 bg-ink/35 lg:hidden" onClick={() => setOpen(false)} aria-label="Закрыть меню" />
<aside className="fixed inset-y-0 left-0 z-50 flex w-[min(86vw,22rem)] flex-col bg-dstu-menu text-white lg:hidden">
{navigation}
</aside>
</>
)}
<div className="min-w-0">
<header className="sticky top-0 z-30 flex h-18 items-center gap-4 border-b border-line bg-paper/95 px-4 backdrop-blur sm:px-6">
<button className="grid size-10 place-items-center rounded-md lg:hidden" onClick={() => setOpen(true)} aria-label="Открыть меню">
<Menu className="size-5" />
</button>
<div className="hidden items-center gap-2 text-sm text-muted sm:flex">
<Link to="/cabinet">Кабинет</Link>
{location.pathname !== "/cabinet" && (
<>
<ChevronRight className="size-4" />
<span className="text-ink">Раздел</span>
</>
)}
</div>
<div className="ml-auto flex items-center gap-3">
<ThemeToggle />
<label className="hidden text-xs text-muted md:block">
Тестовая роль
<select
value={user.role}
onChange={(event) => {
switchRole(event.target.value);
navigate("/cabinet");
}}
className="ml-2 rounded-md border border-line bg-surface px-2 py-1.5 text-ink dark:[color-scheme:dark]"
>
<option value="user">Пользователь</option>
<option value="editor">Редактор</option>
<option value="moderator">Модератор</option>
<option value="admin">Администратор</option>
</select>
</label>
<div className="text-right">
<p className="text-sm font-semibold">{user.name}</p>
<Badge className="mt-1">{roleLabels[user.role]}</Badge>
</div>
</div>
</header>
<main className="page-shell-wide overflow-x-clip py-4 sm:py-6 lg:py-8">
<AnimatePresence mode="wait" initial={false}>
<PageTransition key={location.pathname} routeKey={location.pathname}>
<Outlet />
</PageTransition>
</AnimatePresence>
</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,227 @@
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import {
BookOpen,
CalendarDays,
RadioTower,
LogIn,
Menu,
Search,
UserRound,
X,
} from "lucide-react";
import { useState } from "react";
import { Link, NavLink, Outlet, useLocation, useNavigate } from "react-router-dom";
import { useSession } from "../store/session";
import { Button } from "../../shared/ui/Button";
import { DstuLogo } from "../../shared/ui/DstuLogo";
import { ThemeToggle } from "../../shared/ui/ThemeToggle";
import { PageTransition } from "../../shared/ui/PageTransition";
import { cn } from "../../shared/lib/cn";
const links = [
{ to: "/materials", label: "Материалы", icon: BookOpen },
{ to: "/events", label: "События", icon: CalendarDays },
{ to: "/media", label: "Медиаканалы", icon: RadioTower },
{ to: "/about", label: "Об университете" },
];
export function PublicLayout() {
const [menuOpen, setMenuOpen] = useState(false);
const [searchOpen, setSearchOpen] = useState(false);
const user = useSession((state) => state.user);
const location = useLocation();
const navigate = useNavigate();
const reduceMotion = useReducedMotion();
return (
<div className="min-h-screen bg-paper">
<header className="sticky top-0 z-40 border-b border-dstu-menu-line bg-dstu-menu text-white shadow-sm">
<div className="page-shell flex h-18 items-center gap-5">
<Link className="flex shrink-0 items-center gap-3" to="/" viewTransition>
<DstuLogo className="size-10" />
<span>
<span className="block font-serif text-lg leading-none">ДГТУ МЕДИА</span>
<span className="mt-1 hidden text-xs font-bold uppercase tracking-[0.12em] text-white/55 sm:block">
Университетское медиа
</span>
</span>
</Link>
<nav className="ml-8 hidden items-center gap-7 lg:flex" aria-label="Основная навигация">
{links.map(({ to, label }) => (
<NavLink
key={to}
to={to}
viewTransition
className={({ isActive }) =>
cn(
"text-sm font-semibold transition hover:text-dstu-menu-accent",
isActive ? "text-dstu-menu-accent" : "text-white/75",
)
}
>
{label}
</NavLink>
))}
</nav>
<div className="ml-auto flex items-center gap-2">
<ThemeToggle className="text-white/75 hover:bg-white/10 hover:text-white" />
<button
className="grid size-10 place-items-center rounded-md text-white/75 transition hover:bg-white/10 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-dstu-menu-accent"
onClick={() => setSearchOpen((value) => !value)}
aria-label="Открыть поиск"
>
<Search className="size-5" />
</button>
{user ? (
<Button
className="hidden border-white/25 bg-white/10 text-white hover:border-dstu-menu-accent hover:text-dstu-menu-accent sm:inline-flex"
variant="secondary"
icon={<UserRound className="size-4" />}
onClick={() => navigate("/cabinet")}
>
Кабинет
</Button>
) : (
<Button
className="hidden border-white/25 bg-white/10 text-white hover:border-dstu-menu-accent hover:text-dstu-menu-accent sm:inline-flex"
variant="secondary"
icon={<LogIn className="size-4" />}
onClick={() => navigate("/login", { state: { from: location.pathname } })}
>
Войти
</Button>
)}
<button
className="grid size-10 place-items-center rounded-md text-white/80 hover:bg-white/10 hover:text-white lg:hidden"
onClick={() => setMenuOpen(true)}
aria-label="Открыть меню"
>
<Menu className="size-5" />
</button>
</div>
</div>
<AnimatePresence>
{searchOpen && (
<motion.form
initial={reduceMotion ? false : { opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
onSubmit={(event) => {
event.preventDefault();
const data = new FormData(event.currentTarget);
navigate(`/materials?q=${encodeURIComponent(String(data.get("q") ?? ""))}`);
setSearchOpen(false);
}}
className="border-t border-line bg-surface"
>
<div className="page-shell flex gap-3 py-4">
<input
autoFocus
name="q"
placeholder="Что вы хотите найти?"
className="min-w-0 flex-1 rounded-md border border-line bg-white px-4 py-2.5 text-ink outline-none focus:border-primary focus:ring-2 focus:ring-primary/15 dark:bg-paper"
/>
<Button type="submit">Найти</Button>
</div>
</motion.form>
)}
</AnimatePresence>
</header>
<AnimatePresence>
{menuOpen && (
<>
<motion.button
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-40 bg-ink/30 lg:hidden"
onClick={() => setMenuOpen(false)}
aria-label="Закрыть меню"
/>
<motion.aside
initial={reduceMotion ? false : { x: "100%" }}
animate={{ x: 0 }}
exit={{ x: "100%" }}
transition={{ duration: 0.22 }}
className="fixed inset-y-0 right-0 z-50 w-[min(88vw,24rem)] bg-dstu-menu p-6 text-white shadow-2xl lg:hidden"
>
<div className="flex items-center justify-between">
<span className="font-serif text-2xl">Навигация</span>
<button
className="grid size-10 place-items-center rounded-md hover:bg-white/10"
onClick={() => setMenuOpen(false)}
aria-label="Закрыть меню"
>
<X className="size-5" />
</button>
</div>
<nav className="mt-8 grid gap-2">
{links.map(({ to, label, icon: Icon }) => (
<NavLink
key={to}
to={to}
onClick={() => setMenuOpen(false)}
className="flex items-center gap-3 rounded-md px-3 py-3 font-semibold text-white/80 hover:bg-white/10 hover:text-white"
>
{Icon && <Icon className="size-5 text-dstu-menu-accent" />}
{label}
</NavLink>
))}
<NavLink
to={user ? "/cabinet" : "/login"}
onClick={() => setMenuOpen(false)}
className="mt-4 rounded-md bg-dstu-menu-accent px-4 py-3 text-center font-semibold text-dstu-menu"
>
{user ? "Личный кабинет" : "Войти"}
</NavLink>
</nav>
</motion.aside>
</>
)}
</AnimatePresence>
<main className="overflow-x-clip">
<AnimatePresence mode="wait" initial={false}>
<PageTransition key={location.pathname} routeKey={location.pathname}>
<Outlet />
</PageTransition>
</AnimatePresence>
</main>
<footer className="mt-20 bg-dstu-menu text-white">
<div className="page-shell grid gap-10 py-14 md:grid-cols-3">
<div>
<p className="font-serif text-2xl">ДГТУ МЕДИА</p>
<p className="mt-3 max-w-sm text-sm leading-6 text-white/65">
Новости, исследования и события Донского государственного технического университета.
</p>
</div>
<div>
<p className="text-xs font-bold uppercase tracking-widest text-white/50">Разделы</p>
<div className="mt-4 grid gap-2 text-sm">
<Link to="/materials">Материалы</Link>
<Link to="/events">События</Link>
<Link to="/media">Медиаканалы</Link>
<Link to="/about">Об университете</Link>
</div>
</div>
<div>
<p className="text-xs font-bold uppercase tracking-widest text-white/50">Контакты</p>
<p className="mt-4 text-sm leading-6 text-white/75">
пл. Гагарина, 1, Ростов-на-Дону
<br />
media@dstu.ru
</p>
</div>
</div>
<div className="border-t border-white/10 px-4 py-5 text-center text-xs text-white/45">
© 2026 ДГТУ. Учебный проект.
</div>
</footer>
</div>
);
}

View File

@@ -0,0 +1,98 @@
import { create } from "zustand";
import { authApi } from "../../shared/api/endpoints";
import { tokenStorage } from "../../shared/api/client";
function readStoredUser() {
try {
return JSON.parse(sessionStorage.getItem("user") ?? "null");
} catch {
return null;
}
}
function persistUser(user) {
if (user) sessionStorage.setItem("user", JSON.stringify(user));
else sessionStorage.removeItem("user");
}
function getToken(payload) {
return payload?.accessToken ?? payload?.token ?? payload?.jwt;
}
function getUser(payload) {
return payload?.user ?? payload?.profile ?? payload;
}
export const useSession = create((set, get) => ({
user: readStoredUser(),
initializing: false,
register: async (payload) => {
const response = await authApi.register(payload);
const token = getToken(response);
const user = getUser(response);
tokenStorage.set(token);
set({ user });
persistUser(user);
return user;
},
login: async ({ email, password }) => {
const response = await authApi.login({ email, password });
const token = getToken(response);
tokenStorage.set(token);
const user = response.user ?? (await authApi.me());
set({ user });
persistUser(user);
return user;
},
loadMe: async () => {
if (!tokenStorage.get()) return get().user;
set({ initializing: true });
try {
const user = await authApi.me();
set({ user, initializing: false });
persistUser(user);
return user;
} catch (error) {
set({ user: null, initializing: false });
persistUser(null);
throw error;
}
},
logout: async () => {
if (tokenStorage.get()) {
try {
await authApi.logout();
} catch {
// The local session still must be cleared if backend logout fails.
}
}
tokenStorage.clear();
persistUser(null);
set({ user: null });
},
changePassword: (payload) => authApi.changePassword(payload),
updateUser: (patch) => {
const user = { ...get().user, ...patch };
set({ user });
persistUser(user);
return user;
},
switchRole: () => get().user,
}));
if (typeof window !== "undefined") {
window.addEventListener("auth:unauthorized", () => {
persistUser(null);
useSession.setState({ user: null });
});
}

View File

@@ -1,145 +0,0 @@
import { useEffect } from 'react';
import type { UserProfile } from '../types';
export type MenuView = 'home' | 'catalog' | 'search' | 'detail' | 'profile' | 'admin';
type MenuItem = {
label: string;
view: MenuView;
description: string;
requiresAuth?: boolean;
};
const menuItems: MenuItem[] = [
{ label: 'Новости и статьи', view: 'catalog', description: 'Публичные публикации, анонсы и материалы редакций' },
{ label: 'Медиа', view: 'catalog', description: 'Видео, аудио, текстовые и графические материалы' },
{ label: 'Мероприятия', view: 'catalog', description: 'Демо-анонсы до подтверждения отдельной сущности Event' },
{ label: 'Спикеры', view: 'home', description: 'Поиск и подписки на демонстрационных спикеров' },
{ label: 'Категории и теги', view: 'search', description: 'Фильтры, быстрые теги и полнотекстовый поиск' },
{ label: 'Личный кабинет', view: 'profile', description: 'Профиль, подписки, уведомления и роли', requiresAuth: true },
{ label: 'Администрирование', view: 'admin', description: 'Модерация, пользователи, роли, журнал действий', requiresAuth: true }
];
export function AppMenu({
open,
user,
onNavigate,
onClose,
onOpenAuth
}: {
open: boolean;
user: UserProfile | null;
onNavigate: (view: MenuView) => void;
onClose: () => void;
onOpenAuth: () => void;
}) {
useEffect(() => {
if (!open) {
return;
}
const previousOverflow = document.body.style.overflow;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose();
}
};
document.body.style.overflow = 'hidden';
window.addEventListener('keydown', handleKeyDown);
return () => {
document.body.style.overflow = previousOverflow;
window.removeEventListener('keydown', handleKeyDown);
};
}, [open, onClose]);
if (!open) {
return null;
}
const handleItemClick = (item: MenuItem) => {
if (item.requiresAuth && !user) {
onClose();
onOpenAuth();
return;
}
onClose();
onNavigate(item.view);
};
return (
<div
id="fullscreen-menu"
className="fixed inset-0 overflow-y-auto bg-[#f6f9fd] text-[#071426]"
role="dialog"
aria-modal="true"
aria-labelledby="app-menu-title"
style={{ zIndex: 2147483647 }}
>
<div className="mx-auto grid min-h-screen w-[min(1180px,calc(100vw-32px))] gap-8 py-6 lg:grid-cols-[360px_1fr] lg:py-10">
<aside className="flex flex-col rounded-[2rem] bg-[#11519c] p-6 text-white shadow-[0_24px_80px_rgba(17,81,156,0.24)] lg:sticky lg:top-10 lg:h-[calc(100vh-5rem)]">
<div>
<p className="text-sm font-black uppercase tracking-[0.28em] text-white/70">Меню платформы</p>
<h2 id="app-menu-title" className="mt-5 text-5xl font-black leading-none tracking-tight">
Fable
</h2>
<p className="mt-5 text-base leading-7 text-white/80">
Навигация только по разделам медиаплатформы: публичный контур, поиск, профиль и административные сценарии.
</p>
</div>
<div className="mt-8 rounded-[1.5rem] bg-white/15 p-4 text-sm leading-6 text-white/80">
{user ? `Вы вошли как ${user.name}. Роли: ${user.roles.join(', ')}.` : 'Сейчас открыт публичный доступ. Для профиля и администрирования нужен вход.'}
</div>
<div className="mt-auto flex flex-col gap-3 pt-8">
{!user ? (
<button className="min-h-12 rounded-full bg-white px-5 py-3 text-sm font-black text-[#11519c] transition hover:bg-[#eef7ff]" type="button" onClick={onOpenAuth}>
Войти в личный кабинет
</button>
) : null}
<button className="min-h-12 rounded-full border border-white/30 px-5 py-3 text-sm font-black text-white transition hover:bg-white/10" type="button" onClick={onClose}>
Закрыть меню
</button>
</div>
</aside>
<main className="pb-10" aria-label="Разделы меню">
<div className="mb-5 flex flex-col justify-between gap-3 rounded-[1.5rem] border border-[#d7e3f1] bg-white p-5 shadow-sm md:flex-row md:items-center">
<div>
<p className="text-sm font-black uppercase tracking-[0.22em] text-[#11519c]">Быстрый переход</p>
<p className="mt-2 text-sm leading-6 text-slate-600">Esc закрывает меню. Выберите раздел или вернитесь на страницу.</p>
</div>
<button className="min-h-11 rounded-full bg-[#071426] px-5 py-2.5 text-sm font-black text-white transition hover:bg-[#11519c]" type="button" onClick={onClose}>
На страницу
</button>
</div>
<div className="grid gap-4 md:grid-cols-2">
{menuItems.map((item, index) => {
const locked = item.requiresAuth && !user;
return (
<button
key={`${item.label}-${index}`}
className="group min-h-44 rounded-[2rem] border border-[#d7e3f1] bg-white p-6 text-left shadow-sm transition hover:-translate-y-0.5 hover:border-[#11519c] hover:shadow-[0_18px_50px_rgba(17,81,156,0.16)]"
type="button"
onClick={() => handleItemClick(item)}
>
<span className="text-sm font-black text-[#11519c]">{String(index + 1).padStart(2, '0')}</span>
<span className="mt-5 block text-2xl font-black leading-tight text-[#071426]">{item.label}</span>
<span className="mt-3 block text-sm leading-6 text-slate-600">{item.description}</span>
<span className="mt-5 inline-flex min-h-10 items-center rounded-full bg-[#eef7ff] px-4 text-sm font-black text-[#11519c] group-hover:bg-[#11519c] group-hover:text-white">
{locked ? 'Войти для доступа' : 'Открыть раздел'}
</span>
</button>
);
})}
</div>
</main>
</div>
</div>
);
}

View File

@@ -1,216 +0,0 @@
import type { AuditItem, CommentItem, ContentItem, NotificationItem, Speaker, UserProfile } from './types';
export const demoCategories = [
'Новости',
'Статьи',
'Видео',
'Аудио',
'Графика',
'Мероприятия'
];
export const demoTags = [
'медиапроизводство',
'интервью',
'анонс',
'образование',
'редакция',
'архив'
];
export const demoContent: ContentItem[] = [
{
id: 'demo-news-1',
title: 'Демо-новость о запуске медиаплатформы',
lead: 'Публичная карточка показывает, как новости и статьи будут выглядеть в едином каталоге.',
body:
'Это демонстрационный материал без реальных персональных данных, подразделений и брендовых материалов. Он нужен только для проверки интерфейса, поиска, фильтров и жизненного цикла публикации.',
type: 'news',
category: 'Новости',
tags: ['медиапроизводство', 'анонс'],
author: 'Демо-редакция',
publishedAt: '2026-06-04',
visibility: 'Публично',
status: 'Опубликовано',
views: 1240,
imageTone: 'from-university-700 via-university-500 to-sky-300'
},
{
id: 'demo-video-1',
title: 'Демо-видео: открытая лекция',
lead: 'Видеоматериал с метаданными, статусом проверки, категорией и тегами.',
body:
'В реальной системе здесь будет предпросмотр видео, CDN-ссылка, история модерации и аналитика просмотров. В прототипе используется безопасная заглушка.',
type: 'video',
category: 'Видео',
tags: ['образование', 'архив'],
author: 'Демо-медиагруппа',
publishedAt: '2026-06-09',
duration: '18:40',
visibility: 'После входа',
status: 'На проверке',
views: 382,
imageTone: 'from-indigo-700 via-university-800 to-cyan-500'
},
{
id: 'demo-audio-1',
title: 'Демо-аудио: выпуск университетского радио',
lead: 'Аудиоконтент хранится в медиатеке и связывается с публикациями, авторами и тегами.',
body:
'Этот пример показывает карточку аудио без использования реального названия передачи или записи. Права доступа и публикация управляются через роли.',
type: 'audio',
category: 'Аудио',
tags: ['интервью', 'редакция'],
author: 'Демо-редактор',
publishedAt: '2026-06-11',
duration: '32:10',
visibility: 'Публично',
status: 'Опубликовано',
views: 715,
imageTone: 'from-blue-950 via-blue-700 to-emerald-300'
},
{
id: 'demo-graphic-1',
title: 'Демо-графика: афиша редакционного события',
lead: 'Графические материалы можно фильтровать по типу, дате, категории и тегам.',
body:
'Заглушка демонстрирует графический материал без копирования фотографий, логотипов или брендовых элементов сторонних сайтов.',
type: 'graphic',
category: 'Графика',
tags: ['анонс', 'редакция'],
author: 'Демо-дизайнер',
publishedAt: '2026-06-13',
visibility: 'По роли',
status: 'На модерации',
views: 96,
imageTone: 'from-sky-400 via-blue-600 to-slate-900'
},
{
id: 'demo-event-1',
title: 'Демо-анонс медиавстречи со спикером',
lead: 'Мероприятия показаны как тип медиаконтента до подтверждения отдельной сущности Event.',
body:
'В ТЗ мероприятия указаны в пользовательских функциях, но не выделены в таблице информационных объектов. Поэтому в первом прототипе они представлены как анонсы контента.',
type: 'event',
category: 'Мероприятия',
tags: ['анонс', 'интервью'],
author: 'Демо-менеджер',
publishedAt: '2026-06-17',
visibility: 'Публично',
status: 'Черновик',
views: 45,
imageTone: 'from-university-900 via-violet-700 to-orange-300'
},
{
id: 'demo-article-1',
title: 'Демо-статья о жизненном цикле материала',
lead: 'Публикация проходит этапы: черновик, модерация, проверка, публикация, архив.',
body:
'Материал показывает администраторские сценарии, очереди проверки, журнал действий и ограничения видимости.',
type: 'article',
category: 'Статьи',
tags: ['медиапроизводство', 'архив'],
author: 'Демо-автор',
publishedAt: '2026-06-20',
visibility: 'После входа',
status: 'Архив',
views: 531,
imageTone: 'from-slate-900 via-university-800 to-sky-200'
}
];
export const demoSpeakers: Speaker[] = [
{
id: 'demo-speaker-1',
name: 'Демо-спикер 01',
role: 'Приглашенный эксперт',
topics: ['медиапроизводство', 'образование'],
materials: 8,
subscribers: 132
},
{
id: 'demo-speaker-2',
name: 'Демо-спикер 02',
role: 'Участник редакционного события',
topics: ['интервью', 'анонс'],
materials: 5,
subscribers: 74
},
{
id: 'demo-speaker-3',
name: 'Демо-спикер 03',
role: 'Автор образовательных материалов',
topics: ['архив', 'редакция'],
materials: 12,
subscribers: 205
}
];
export const demoUser: UserProfile = {
id: 'demo-user-1',
name: 'Демо-администратор',
login: 'demo_admin',
roles: ['администратор', 'редактор'],
subscriptions: ['Новости', 'Демо-спикер 01', 'медиапроизводство']
};
export const demoNotifications: NotificationItem[] = [
{
id: 'demo-notification-1',
title: 'Новый материал по подписке',
description: 'В категории «Новости» появился демонстрационный материал.',
read: false,
createdAt: '2026-06-13 10:20'
},
{
id: 'demo-notification-2',
title: 'Материал ожидает проверки',
description: 'Демо-видео находится на этапе проверки перед публикацией.',
read: true,
createdAt: '2026-06-12 16:45'
}
];
export const demoComments: CommentItem[] = [
{
id: 'demo-comment-1',
author: 'Демо-пользователь',
text: 'Комментарий доступен только авторизованным пользователям и может проходить модерацию.',
createdAt: '2026-06-13 12:00'
}
];
export const demoAudit: AuditItem[] = [
{
id: 'demo-audit-1',
actor: 'Демо-администратор',
action: 'изменил статус',
target: 'Демо-видео: открытая лекция',
createdAt: '2026-06-13 11:10'
},
{
id: 'demo-audit-2',
actor: 'Демо-редактор',
action: 'создал черновик',
target: 'Демо-анонс медиавстречи со спикером',
createdAt: '2026-06-12 15:35'
}
];
export const demoUsers: UserProfile[] = [
demoUser,
{
id: 'demo-user-2',
name: 'Демо-редактор',
login: 'demo_editor',
roles: ['редактор'],
subscriptions: ['Видео']
},
{
id: 'demo-user-3',
name: 'Демо-пользователь',
login: 'demo_user',
roles: ['пользователь'],
subscriptions: ['Аудио', 'Демо-спикер 03']
}
];

View File

@@ -1,140 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
color-scheme: light;
font-family: 'Golos Text', Inter, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #f5f8fc;
color: #071426;
}
.dark {
color-scheme: dark;
background: #06101f;
color: #f6f9ff;
}
html[data-font-scale='large'] {
font-size: 18px;
}
html[data-font-scale='xlarge'] {
font-size: 20px;
}
body {
min-width: 320px;
min-height: 100vh;
margin: 0;
background:
radial-gradient(circle at top left, rgba(17, 81, 156, 0.13), transparent 34rem),
linear-gradient(180deg, #f7fbff 0%, #eef4fb 54%, #f8fafc 100%);
}
.dark body {
background:
radial-gradient(circle at top left, rgba(51, 136, 243, 0.24), transparent 30rem),
linear-gradient(180deg, #06101f 0%, #0a1728 58%, #07111e 100%);
}
.contrast body {
background: #ffffff;
color: #000000;
}
.dark.contrast body {
background: #000000;
color: #ffffff;
}
button,
input,
select,
textarea {
font: inherit;
}
a,
button,
input,
select,
textarea {
touch-action: manipulation;
}
.skip-link {
position: fixed;
left: 1rem;
top: 1rem;
z-index: 1000;
transform: translateY(-150%);
border-radius: 999px;
background: #ffffff;
color: #11519c;
box-shadow: 0 18px 50px rgba(7, 20, 38, 0.2);
font-weight: 800;
padding: 0.75rem 1rem;
transition: transform 160ms ease;
}
.skip-link:focus {
transform: translateY(0);
}
button:focus-visible,
a:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible {
outline: none;
box-shadow: 0 0 0 3px rgba(17, 81, 156, 0.45);
}
input:focus-visible {
border-radius: 1rem;
}
.media-visual {
position: relative;
overflow: hidden;
}
.media-visual::after {
content: '';
position: absolute;
inset: 16% 12%;
border: 1px solid rgba(255, 255, 255, 0.34);
border-radius: 999px;
transform: rotate(-10deg);
}
.no-images .media-visual {
display: none;
}
.safe-area {
width: min(1180px, calc(100vw - 32px));
margin-inline: auto;
}
.scrollbar-soft::-webkit-scrollbar {
width: 10px;
height: 10px;
}
.scrollbar-soft::-webkit-scrollbar-thumb {
background: rgba(17, 81, 156, 0.35);
border-radius: 999px;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
}
}

26
apps/web/src/main.jsx Normal file
View File

@@ -0,0 +1,26 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { BrowserRouter } from "react-router-dom";
import { App } from "./app/App";
import "./styles.css";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
retry: 1,
refetchOnWindowFocus: false,
},
},
});
createRoot(document.getElementById("root")).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
</BrowserRouter>
</QueryClientProvider>
</StrictMode>,
);

View File

@@ -1,10 +0,0 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
import './index.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>
);

View File

@@ -0,0 +1,34 @@
import { Building2, GraduationCap, Microscope } from "lucide-react";
export function AboutPage() {
return (
<div className="page-shell py-12">
<div className="rounded-xl bg-primary p-8 text-white sm:p-14">
<p className="text-xs font-bold uppercase tracking-widest text-white/55">О медиапортале</p>
<h1 className="mt-4 max-w-4xl font-serif text-4xl leading-tight sm:text-6xl">
Университет говорит голосами тех, кто его создаёт
</h1>
<p className="mt-6 max-w-2xl text-lg leading-8 text-white/70">
«ДГТУ МЕДИА» объединяет новости, исследования, события и истории сообщества в
одном открытом цифровом пространстве.
</p>
</div>
<div className="mt-10 grid gap-6 md:grid-cols-3">
{[
[GraduationCap, "Образование", "Показываем, как меняются программы и учебные практики."],
[Microscope, "Исследования", "Объясняем сложные разработки ясным человеческим языком."],
[Building2, "Сообщество", "Сохраняем события и голоса университетской жизни."],
].map(([Icon, title, text]) => {
const ItemIcon = Icon;
return (
<div key={String(title)} className="rounded-lg border border-line bg-surface p-6">
<ItemIcon className="size-6 text-accent" />
<h2 className="mt-6 font-serif text-2xl">{String(title)}</h2>
<p className="mt-3 leading-7 text-muted">{String(text)}</p>
</div>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,87 @@
import { CalendarDays, MapPin, MoveRight } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { directoriesApi } from "../shared/api/endpoints";
import { queryKeys } from "../shared/api/queryKeys";
import { toList } from "../shared/api/normalize";
import { Badge } from "../shared/ui/Badge";
import { Button } from "../shared/ui/Button";
import { EmptyState, ErrorState, Skeleton } from "../shared/ui/States";
export function EventsPage() {
const { data: eventsPayload, isLoading, isError, refetch } = useQuery({
queryKey: queryKeys.events(),
queryFn: directoriesApi.events,
});
const pageEvents = toList(eventsPayload);
return (
<div className="page-shell py-10">
<div className="grid gap-8 border-b border-line pb-10 md:grid-cols-2">
<div>
<p className="text-xs font-bold uppercase tracking-widest text-accent">Афиша ДГТУ</p>
<h1 className="mt-3 font-serif text-5xl">События</h1>
</div>
<p className="self-end text-lg leading-8 text-muted">
Открытые лекции, фестивали, выставки и встречи. Всё, что помогает университету
разговаривать с городом.
</p>
</div>
<div className="mt-8 flex gap-2 overflow-x-auto pb-2">
{["Все события", "Лекции", "Фестивали", "Экскурсии", "Выставки"].map(
(item, index) => (
<Button key={item} variant={index === 0 ? "primary" : "secondary"} size="sm">
{item}
</Button>
),
)}
</div>
<div className="mt-8 divide-y divide-line border-y border-line">
{isLoading ? (
[1, 2, 3].map((item) => <Skeleton key={item} className="my-6 h-28 w-full" />)
) : isError ? (
<div className="py-8">
<ErrorState retry={refetch} />
</div>
) : pageEvents.length ? (
pageEvents.map((event, index) => (
<article
key={event.id}
className="group grid gap-5 py-8 md:grid-cols-[minmax(7rem,1fr)_minmax(0,5fr)_auto] md:items-center"
>
<div className="flex items-baseline gap-2 md:block">
<span className="font-serif text-5xl text-primary">{event.date.split(" ")[0]}</span>
<span className="text-sm uppercase tracking-widest text-muted">
{event.date.split(" ")[1]}
</span>
</div>
<div>
<Badge tone={index === 0 ? "accent" : "neutral"}>{event.category}</Badge>
<h2 className="mt-3 font-serif text-2xl transition group-hover:text-primary sm:text-3xl">
{event.title}
</h2>
<p className="mt-3 max-w-2xl leading-7 text-muted">{event.description}</p>
<div className="mt-4 flex flex-wrap gap-4 text-sm text-muted">
<span className="flex items-center gap-1.5">
<CalendarDays className="size-4" /> {event.time}
</span>
<span className="flex items-center gap-1.5">
<MapPin className="size-4" /> {event.place}
</span>
</div>
</div>
<Button className="md:justify-self-end" variant="secondary" icon={<MoveRight className="size-4" />}>
Подробнее
</Button>
</article>
))
) : (
<div className="py-8">
<EmptyState title="Событий нет" text="Backend пока не вернул события." />
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,24 @@
import { ArrowLeft, LockKeyhole } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { Button } from "../shared/ui/Button";
export function ForbiddenPage() {
const navigate = useNavigate();
return (
<div className="grid min-h-screen place-items-center bg-paper px-4">
<div className="max-w-lg text-center">
<div className="mx-auto grid size-20 place-items-center rounded-full bg-accent-soft text-accent">
<LockKeyhole className="size-9" />
</div>
<p className="mt-6 text-xs font-bold uppercase tracking-widest text-accent">Ошибка 403</p>
<h1 className="mt-3 font-serif text-4xl">Недостаточно прав</h1>
<p className="mt-4 leading-7 text-muted">
Этот раздел доступен другой роли. В тестовом кабинете роль можно переключить в верхней панели.
</p>
<Button className="mt-7" icon={<ArrowLeft className="size-4" />} onClick={() => navigate("/cabinet")}>
Вернуться в кабинет
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,204 @@
import { ArrowRight, CalendarDays, ChevronRight } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { Link } from "react-router-dom";
import { contentApi, directoriesApi } from "../shared/api/endpoints";
import { normalizeContentList, toList } from "../shared/api/normalize";
import { queryKeys } from "../shared/api/queryKeys";
import { Badge } from "../shared/ui/Badge";
import { Button } from "../shared/ui/Button";
import { MaterialCard } from "../shared/ui/MaterialCard";
import { ResponsiveImage } from "../shared/ui/ResponsiveImage";
import { EmptyState, ErrorState, Skeleton } from "../shared/ui/States";
function eventDateParts(value = "") {
const date = new Date(value);
if (!Number.isNaN(date.getTime())) {
return [
date.toLocaleDateString("ru-RU", { day: "numeric" }),
date.toLocaleDateString("ru-RU", { month: "short" }),
];
}
const [day = "", month = ""] = String(value).split(" ");
return [day, month];
}
export function HomePage() {
const { data: contentPayload, isLoading, isError, refetch } = useQuery({
queryKey: queryKeys.content({ page: "home" }),
queryFn: () => contentApi.list({ limit: 6 }),
});
const { data: eventsPayload } = useQuery({
queryKey: queryKeys.events({ page: "home" }),
queryFn: () => directoriesApi.events({ limit: 3 }),
});
const { data: categoriesPayload } = useQuery({
queryKey: queryKeys.categories,
queryFn: directoriesApi.categories,
});
const pageMaterials = normalizeContentList(contentPayload);
const pageEvents = toList(eventsPayload);
const pageCategories = toList(categoriesPayload);
const featured = pageMaterials.find((item) => item.featured) ?? pageMaterials[0];
if (isLoading) {
return (
<div className="page-shell grid gap-6 py-10">
<Skeleton className="h-96 w-full" />
<Skeleton className="h-44 w-full" />
</div>
);
}
if (isError) {
return (
<div className="page-shell py-16">
<ErrorState retry={refetch} />
</div>
);
}
if (!featured) {
return (
<div className="page-shell py-16">
<EmptyState title="Материалы не найдены" text="Backend пока не вернул опубликованные записи." />
</div>
);
}
return (
<>
<section className="border-b border-line">
<div className="page-shell grid gap-8 py-8 md:py-12 lg:grid-cols-12">
<article className="relative overflow-hidden rounded-xl bg-primary lg:col-span-8">
<ResponsiveImage
src={featured.image}
alt=""
eager
className="absolute inset-0 h-full w-full object-cover opacity-45 mix-blend-luminosity"
/>
<div className="relative flex min-h-[clamp(26rem,60vh,36rem)] flex-col justify-end p-6 text-white sm:p-10 lg:p-12">
<Badge className="w-fit bg-white/90 text-primary">{featured.category}</Badge>
<h1 className="mt-5 max-w-4xl font-serif text-4xl leading-[1.04] tracking-[-0.03em] sm:text-5xl lg:text-6xl">
{featured.title}
</h1>
<p className="mt-5 max-w-2xl text-base leading-7 text-white/80 sm:text-lg">{featured.excerpt}</p>
<Link className="mt-7 w-fit" to={`/materials/${featured.slug}`} viewTransition>
<Button variant="accent" size="lg" icon={<ArrowRight className="size-4" />}>
Читать материал
</Button>
</Link>
</div>
</article>
<aside className="flex flex-col rounded-xl border border-line bg-surface p-6 lg:col-span-4">
<div className="flex items-center justify-between">
<div>
<span className="text-xs font-bold uppercase tracking-widest text-accent">Сегодня</span>
<h2 className="mt-2 font-serif text-3xl">В университете</h2>
</div>
</div>
<div className="mt-7 divide-y divide-line">
{pageMaterials.slice(1, 4).map((item) => (
<Link key={item.id} to={`/materials/${item.slug}`} viewTransition className="group block py-5 first:pt-0">
<span className="text-xs font-bold uppercase tracking-wider text-muted">
{item.type} · {item.publishedAt}
</span>
<h3 className="mt-2 font-serif text-xl leading-snug transition group-hover:text-primary">{item.title}</h3>
</Link>
))}
</div>
<Link className="mt-auto flex items-center gap-2 border-t border-line pt-5 text-sm font-bold text-primary" to="/materials">
Все материалы <ChevronRight className="size-4" />
</Link>
</aside>
</div>
</section>
<section className="page-shell py-16">
<div className="flex items-end justify-between gap-6">
<div>
<p className="text-xs font-bold uppercase tracking-widest text-accent">Новая лента</p>
<h2 className="mt-2 font-serif text-4xl">Последние публикации</h2>
</div>
<Link className="hidden text-sm font-bold text-primary sm:block" to="/materials">
Смотреть все
</Link>
</div>
<div className="mt-8 grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{pageMaterials.slice(1, 4).map((material) => (
<MaterialCard key={material.id} material={material} />
))}
</div>
</section>
<section className="bg-primary text-white">
<div className="page-shell grid gap-10 py-16 lg:grid-cols-[minmax(16rem,3fr)_minmax(0,5fr)]">
<div>
<p className="text-xs font-bold uppercase tracking-widest text-white/55">Календарь</p>
<h2 className="mt-3 font-serif text-4xl">Ближайшие события</h2>
<p className="mt-4 max-w-md leading-7 text-white/65">
Лекции, выставки, защиты проектов и встречи университетского сообщества.
</p>
<Link to="/events">
<Button className="mt-7 border-white/25 text-white hover:bg-white/10" variant="secondary">
Открыть календарь
</Button>
</Link>
</div>
<div className="divide-y divide-white/15 border-y border-white/15">
{pageEvents.length === 0 && (
<div className="py-8 text-white/65">Backend пока не вернул ближайшие события.</div>
)}
{pageEvents.map((event) => {
const [day, month] = eventDateParts(event.date ?? event.startsAt);
return (
<article
key={event.id}
className="grid gap-4 py-5 sm:grid-cols-[minmax(5rem,1fr)_minmax(0,5fr)_auto] sm:items-center"
>
<div>
<span className="block font-serif text-2xl">{day}</span>
<span className="text-xs uppercase tracking-wider text-white/50">{month}</span>
</div>
<div>
<h3 className="font-serif text-xl">{event.title}</h3>
<p className="mt-1 text-sm text-white/55">
{event.time ?? event.startsAt} · {event.place ?? event.location}
</p>
</div>
<CalendarDays className="hidden size-5 text-white/45 sm:block" />
</article>
);
})}
</div>
</div>
</section>
<section className="page-shell py-16">
<p className="text-xs font-bold uppercase tracking-widest text-accent">Навигация по темам</p>
<div className="mt-5 grid border-l border-t border-line sm:grid-cols-2 lg:grid-cols-4">
{pageCategories.map((category, index) => {
const name = category.name ?? category.title ?? category.label;
const value = category.slug ?? category.id ?? name;
return (
<Link
key={category.id ?? value}
to={`/materials?category=${value}`}
className="group min-h-56 border-b border-r border-line bg-surface p-6 transition hover:bg-primary hover:text-white"
>
<span className="font-serif text-4xl text-line transition group-hover:text-white/25">
{String(index + 1).padStart(2, "0")}
</span>
<h3 className="mt-12 font-serif text-2xl">{name}</h3>
<p className="mt-3 text-sm leading-6 text-muted transition group-hover:text-white/65">
{category.description}
</p>
</Link>
);
})}
</div>
</section>
</>
);
}

View File

@@ -0,0 +1,82 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { ArrowLeft, KeyRound } from "lucide-react";
import { useForm } from "react-hook-form";
import { Link, useLocation, useNavigate } from "react-router-dom";
import { z } from "zod";
import { useSession } from "../app/store/session";
import { Button } from "../shared/ui/Button";
import { Input } from "../shared/ui/Field";
const schema = z.object({
email: z.email("Введите корректный адрес"),
password: z.string().min(6, "Минимум 6 символов"),
});
export function LoginPage() {
const login = useSession((state) => state.login);
const navigate = useNavigate();
const location = useLocation();
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm({
resolver: zodResolver(schema),
defaultValues: { email: "editor@dstu.ru", password: "password" },
});
const onSubmit = async (data) => {
await login(data);
const state = location.state;
navigate(state?.from ?? "/cabinet", { replace: true });
};
return (
<div className="grid min-h-screen bg-paper lg:grid-cols-2">
<div className="hidden bg-primary p-12 text-white lg:flex lg:flex-col lg:justify-between">
<Link className="font-serif text-2xl" to="/">
ДГТУ МЕДИА
</Link>
<blockquote className="max-w-xl font-serif text-4xl leading-tight">
«Знание становится частью культуры, когда им можно делиться».
</blockquote>
<p className="text-sm text-white/50">Информационная система управления медиаконтентом</p>
</div>
<div className="flex items-center justify-center p-5 sm:p-10">
<div className="w-full max-w-md">
<Link className="mb-10 inline-flex items-center gap-2 text-sm text-muted hover:text-primary" to="/">
<ArrowLeft className="size-4" /> Вернуться на портал
</Link>
<div className="grid size-12 place-items-center rounded-md bg-accent-soft text-accent">
<KeyRound className="size-6" />
</div>
<h1 className="mt-6 font-serif text-4xl">Вход в систему</h1>
<p className="mt-3 leading-7 text-muted">
Используйте тестовый адрес роли или учетную запись backend.
</p>
<form className="mt-8 grid gap-5" noValidate onSubmit={handleSubmit(onSubmit)}>
<Input
label="Корпоративная почта"
type="email"
error={errors.email?.message}
{...register("email")}
/>
<Input
label="Пароль"
type="password"
error={errors.password?.message}
{...register("password")}
/>
<Button className="mt-2 w-full" type="submit" loading={isSubmitting}>
Войти
</Button>
</form>
<div className="mt-7 rounded-lg border border-line bg-surface p-4 text-xs leading-6 text-muted">
<b className="text-ink">Тестовые роли:</b> user@dstu.ru, editor@dstu.ru,
moderator@dstu.ru, admin@dstu.ru
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,26 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
import { LoginPage } from "./LoginPage";
describe("LoginPage", () => {
it("показывает ошибки валидации для некорректных данных", async () => {
const user = userEvent.setup();
render(
<MemoryRouter>
<LoginPage />
</MemoryRouter>,
);
const email = screen.getByLabelText("Корпоративная почта");
const password = screen.getByLabelText("Пароль");
await user.clear(email);
await user.type(email, "wrong");
await user.clear(password);
await user.type(password, "123");
await user.click(screen.getByRole("button", { name: "Войти" }));
expect(await screen.findByText("Введите корректный адрес")).toBeInTheDocument();
expect(screen.getByText("Минимум 6 символов")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,282 @@
import { Bookmark, Clock3, Eye, MessageCircle, Send, Share2, Star } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useMemo, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { useSession } from "../app/store/session";
import { commentsApi, contentApi } from "../shared/api/endpoints";
import { normalizeContentItem, normalizeContentList, toEntity, toList } from "../shared/api/normalize";
import { queryKeys } from "../shared/api/queryKeys";
import { Badge } from "../shared/ui/Badge";
import { Button } from "../shared/ui/Button";
import { Textarea } from "../shared/ui/Field";
import { MaterialCard } from "../shared/ui/MaterialCard";
import { ResponsiveImage } from "../shared/ui/ResponsiveImage";
import { EmptyState, ErrorState, Skeleton } from "../shared/ui/States";
import { NotFoundPage } from "./NotFoundPage";
function normalizeComment(comment) {
return {
id: comment.id ?? `${comment.createdAt ?? Date.now()}-${comment.author?.id ?? comment.user?.id ?? "comment"}`,
author: comment.author?.name ?? comment.author ?? comment.user?.name ?? "Гость",
text: comment.text ?? comment.content ?? comment.body ?? "",
createdAt: comment.createdAt ?? comment.date ?? "",
};
}
function formatDate(value) {
if (!value) return "";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString("ru-RU", { day: "numeric", month: "long", year: "numeric" });
}
function RatingBlock({ material, detailKey }) {
const queryClient = useQueryClient();
const initialAverage = Number(material.ratingAverage ?? material.rating ?? 0);
const initialCount = Number(material.ratingCount ?? material.ratingsCount ?? 0);
const currentRating = Number(material.myRating ?? 0);
const [selectedRating, setSelectedRating] = useState(currentRating);
const rateMutation = useMutation({
mutationFn: (rating) => contentApi.update(material.id, { rating }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.contentDetail(detailKey) });
queryClient.invalidateQueries({ queryKey: queryKeys.contentDetail(material.id) });
},
});
const average = initialAverage.toFixed(1);
return (
<section className="rounded-lg border border-line bg-surface p-5">
<div className="flex items-center justify-between gap-4">
<div>
<h2 className="font-serif text-xl">Оцените материал</h2>
<p className="mt-1 text-sm text-muted">Средняя оценка: {average} из 5, оценок: {initialCount}</p>
</div>
<span className="font-serif text-3xl text-primary">{average}</span>
</div>
<div className="mt-4 flex gap-1" role="radiogroup" aria-label="Оценка материала">
{[1, 2, 3, 4, 5].map((value) => (
<button
key={value}
type="button"
className="rounded-md p-1 text-warning transition hover:bg-warning-soft focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
onClick={() => {
setSelectedRating(value);
rateMutation.mutate(value);
}}
aria-label={`Поставить ${value}`}
aria-checked={selectedRating === value}
role="radio"
>
<Star
className="size-6"
fill={value <= (selectedRating || Math.round(initialAverage)) ? "currentColor" : "none"}
/>
</button>
))}
</div>
{rateMutation.isPending && <p className="mt-3 text-sm font-semibold text-muted">Сохраняем оценку...</p>}
{rateMutation.isSuccess && <p className="mt-3 text-sm font-semibold text-success">Спасибо, ваша оценка учтена.</p>}
{rateMutation.isError && <p className="mt-3 text-sm font-semibold text-danger">Не удалось сохранить оценку.</p>}
</section>
);
}
function CommentsBlock({ materialId }) {
const user = useSession((state) => state.user);
const [commentText, setCommentText] = useState("");
const [notice, setNotice] = useState("");
const queryClient = useQueryClient();
const commentsQuery = useQuery({
queryKey: queryKeys.comments(materialId),
queryFn: () => commentsApi.list(materialId),
enabled: Boolean(materialId),
});
const createComment = useMutation({
mutationFn: (text) => commentsApi.create(materialId, { text }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.comments(materialId) });
setCommentText("");
setNotice("Комментарий добавлен.");
},
});
const comments = useMemo(() => toList(commentsQuery.data).map(normalizeComment), [commentsQuery.data]);
const submitComment = (event) => {
event.preventDefault();
const text = commentText.trim();
if (!text) return;
setNotice("");
createComment.mutate(text);
};
return (
<section className="page-shell border-t border-line py-14">
<div className="flex items-center gap-3">
<MessageCircle className="size-6 text-primary" />
<h2 className="font-serif text-3xl">Комментарии</h2>
<Badge>{comments.length}</Badge>
</div>
<form className="mt-6 rounded-lg border border-line bg-surface p-5" onSubmit={submitComment}>
<Textarea
label={user ? "Ваш комментарий" : "Комментарий гостя"}
value={commentText}
onChange={(event) => setCommentText(event.target.value)}
placeholder="Напишите, что думаете о материале..."
/>
<div className="mt-4 flex items-center gap-4">
<Button type="submit" loading={createComment.isPending} icon={<Send className="size-4" />}>
Отправить
</Button>
{notice && <span className="text-sm font-semibold text-success">{notice}</span>}
{createComment.isError && <span className="text-sm font-semibold text-danger">Не удалось отправить комментарий.</span>}
</div>
</form>
<div className="mt-7 grid gap-4">
{commentsQuery.isLoading ? (
[1, 2].map((item) => <Skeleton key={item} className="h-28 w-full" />)
) : commentsQuery.isError ? (
<ErrorState retry={commentsQuery.refetch} />
) : comments.length ? (
comments.map((comment) => (
<article key={comment.id} className="rounded-lg border border-line bg-surface p-5">
<div className="flex items-baseline justify-between gap-4">
<h3 className="font-semibold text-ink">{comment.author}</h3>
<span className="text-xs text-muted">{formatDate(comment.createdAt)}</span>
</div>
<p className="mt-3 leading-7 text-muted">{comment.text}</p>
</article>
))
) : (
<EmptyState title="Комментариев пока нет" text="Оставленные через API комментарии появятся в этом блоке." />
)}
</div>
</section>
);
}
export function MaterialPage() {
const { slug } = useParams();
const contentQuery = useQuery({
queryKey: queryKeys.contentDetail(slug),
queryFn: () => contentApi.get(slug),
enabled: Boolean(slug),
});
const material = normalizeContentItem(toEntity(contentQuery.data));
const materialId = material?.id ?? material?.slug;
const relatedQuery = useQuery({
queryKey: queryKeys.content({ relatedTo: materialId }),
queryFn: () => contentApi.list({ limit: 3, exclude: materialId }),
enabled: Boolean(materialId),
});
const relatedMaterials = normalizeContentList(relatedQuery.data).filter((item) => item.id !== materialId);
if (contentQuery.isLoading) {
return (
<div className="page-shell grid gap-6 py-10">
<Skeleton className="h-80 w-full" />
<Skeleton className="h-96 w-full" />
</div>
);
}
if (contentQuery.isError) {
return (
<div className="page-shell py-16">
<ErrorState retry={contentQuery.refetch} />
</div>
);
}
if (!material?.id) return <NotFoundPage />;
const paragraphs = String(material.content ?? material.body ?? "").split(/\n{2,}/).filter(Boolean);
return (
<article>
<header className="border-b border-line">
<div className="mx-auto max-w-5xl px-4 py-10 sm:px-6 sm:py-16">
<div className="flex flex-wrap items-center gap-3 text-sm">
<Link className="text-muted hover:text-primary" to="/materials">
Материалы
</Link>
<span className="text-line">/</span>
<Badge tone="accent">{material.category}</Badge>
</div>
<h1 className="mt-6 max-w-4xl font-serif text-4xl leading-[1.08] tracking-[-0.025em] sm:text-6xl">
{material.title}
</h1>
<p className="mt-6 max-w-3xl text-lg leading-8 text-muted">{material.excerpt}</p>
<div className="mt-8 flex flex-wrap items-center gap-x-6 gap-y-3 border-t border-line pt-6 text-sm text-muted">
<span className="font-semibold text-ink">{material.author}</span>
<span>{formatDate(material.publishedAt)}</span>
<span className="flex items-center gap-1.5">
<Clock3 className="size-4" /> {material.readingTime} мин
</span>
<span className="flex items-center gap-1.5">
<Eye className="size-4" /> {Number(material.views ?? 0).toLocaleString("ru-RU")}
</span>
</div>
</div>
</header>
<div className="page-shell py-8">
<ResponsiveImage src={material.image} alt="" eager className="aspect-[16/8] w-full rounded-xl object-cover" />
</div>
<div className="page-shell grid gap-10 py-8 lg:grid-cols-[minmax(0,4fr)_minmax(14rem,1fr)]">
<div className="max-w-[68ch]">
{paragraphs.map((paragraph) => (
<p key={paragraph} className="mb-6 font-serif text-xl leading-9 text-ink/90">
{paragraph}
</p>
))}
<div className="mt-10 flex flex-wrap gap-2 border-t border-line pt-6">
{(material.tags ?? []).map((tag) => (
<Badge key={tag}>#{tag}</Badge>
))}
</div>
</div>
<aside className="order-first lg:order-last">
<div className="grid gap-3 lg:sticky lg:top-24">
<RatingBlock material={material} detailKey={slug} />
<div className="grid gap-2 rounded-lg border border-line bg-surface p-3">
<Button variant="secondary" icon={<Bookmark className="size-4" />}>Сохранить</Button>
<Button variant="ghost" icon={<Share2 className="size-4" />}>Поделиться</Button>
</div>
</div>
</aside>
</div>
<CommentsBlock materialId={materialId} />
<section className="page-shell border-t border-line py-14">
<h2 className="font-serif text-3xl">Читайте также</h2>
<div className="mt-7 grid gap-6 md:grid-cols-3">
{relatedQuery.isLoading && [1, 2, 3].map((item) => <Skeleton key={item} className="h-72" />)}
{relatedQuery.isError && (
<div className="md:col-span-3">
<ErrorState retry={relatedQuery.refetch} />
</div>
)}
{relatedQuery.isSuccess && relatedMaterials.length > 0 && relatedMaterials.slice(0, 3).map((item) => (
<MaterialCard key={item.id} material={item} compact />
))}
{relatedQuery.isSuccess && relatedMaterials.length === 0 && (
<div className="md:col-span-3">
<EmptyState title="Похожих материалов нет" text="Backend пока не вернул дополнительные записи." />
</div>
)}
</div>
</section>
</article>
);
}

View File

@@ -0,0 +1,177 @@
import { Search, SlidersHorizontal, X } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { useMemo, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { contentApi, directoriesApi } from "../shared/api/endpoints";
import { normalizeContentList, toList } from "../shared/api/normalize";
import { queryKeys } from "../shared/api/queryKeys";
import { Button } from "../shared/ui/Button";
import { MaterialCard } from "../shared/ui/MaterialCard";
import { EmptyState, ErrorState, Skeleton } from "../shared/ui/States";
const materialTypes = [
["news", "Новость"],
["article", "Статья"],
["research", "Исследование"],
["announcement", "Объявление"],
["video", "Видео"],
];
function normalizeCategories(payload) {
return toList(payload).map((item) => ({
id: item.id ?? item.slug ?? item.name,
name: item.name ?? item.title ?? item.label,
value: item.slug ?? item.id ?? item.name,
}));
}
export function MaterialsPage() {
const [params, setParams] = useSearchParams();
const [mobileFilters, setMobileFilters] = useState(false);
const query = params.get("q") ?? "";
const category = params.get("category") ?? "";
const type = params.get("type") ?? "";
const apiParams = { q: query, category, type };
const { data: contentPayload, isLoading, isError, refetch } = useQuery({
queryKey: queryKeys.content(apiParams),
queryFn: () => contentApi.list(apiParams),
});
const { data: categoriesPayload } = useQuery({
queryKey: queryKeys.categories,
queryFn: directoriesApi.categories,
});
const pageMaterials = normalizeContentList(contentPayload);
const pageCategories = normalizeCategories(categoriesPayload);
const filtered = useMemo(
() =>
pageMaterials.filter((material) => {
const tags = Array.isArray(material.tags) ? material.tags.join(" ") : "";
const matchesQuery = `${material.title} ${material.excerpt} ${tags}`
.toLowerCase()
.includes(query.toLowerCase());
return matchesQuery;
}),
[pageMaterials, query],
);
const update = (key, value) => {
const next = new URLSearchParams(params);
value ? next.set(key, value) : next.delete(key);
setParams(next);
};
const filters = (
<div className="grid gap-5">
<label className="text-sm font-semibold">
Категория
<select
value={category}
onChange={(event) => update("category", event.target.value)}
className="mt-2 w-full rounded-md border border-line bg-white px-3 py-2.5 text-ink outline-none focus:border-primary dark:bg-paper dark:[color-scheme:dark]"
>
<option value="">Все категории</option>
{pageCategories.map((item) => (
<option key={item.id} value={item.value}>
{item.name}
</option>
))}
</select>
</label>
<label className="text-sm font-semibold">
Тип материала
<select
value={type}
onChange={(event) => update("type", event.target.value)}
className="mt-2 w-full rounded-md border border-line bg-white px-3 py-2.5 text-ink outline-none focus:border-primary dark:bg-paper dark:[color-scheme:dark]"
>
<option value="">Все типы</option>
{materialTypes.map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</select>
</label>
<Button variant="ghost" onClick={() => setParams({})} icon={<X className="size-4" />}>
Сбросить фильтры
</Button>
</div>
);
return (
<div className="page-shell py-10">
<div className="border-b border-line pb-8">
<p className="text-xs font-bold uppercase tracking-widest text-accent">Архив редакции</p>
<h1 className="mt-3 font-serif text-4xl sm:text-5xl">Материалы</h1>
<p className="mt-4 max-w-2xl leading-7 text-muted">
Новости университета, исследования, истории людей и полезные объявления загружаются из backend.
</p>
</div>
<form
className="mt-8 flex gap-3"
onSubmit={(event) => {
event.preventDefault();
const data = new FormData(event.currentTarget);
update("q", String(data.get("q") ?? ""));
}}
>
<label className="relative min-w-0 flex-1">
<Search className="absolute left-4 top-1/2 size-5 -translate-y-1/2 text-muted" />
<input
name="q"
defaultValue={query}
placeholder="Название, автор, тема или тег"
className="h-12 w-full rounded-md border border-line bg-surface pl-12 pr-4 outline-none focus:border-primary focus:ring-2 focus:ring-primary/15"
/>
</label>
<Button type="submit">Найти</Button>
<Button
className="lg:hidden"
type="button"
variant="secondary"
icon={<SlidersHorizontal className="size-4" />}
onClick={() => setMobileFilters((value) => !value)}
>
<span className="hidden sm:inline">Фильтры</span>
</Button>
</form>
{mobileFilters && <div className="mt-4 rounded-lg border border-line bg-surface p-5 lg:hidden">{filters}</div>}
<div className="mt-8 grid gap-8 lg:grid-cols-[minmax(13rem,1fr)_minmax(0,4fr)]">
<aside className="hidden lg:block">
<div className="sticky top-24 rounded-lg border border-line bg-surface p-5">
<h2 className="mb-5 font-serif text-xl">Фильтры</h2>
{filters}
</div>
</aside>
<section>
<div className="mb-5 flex items-center justify-between">
<p className="text-sm text-muted">Найдено: {filtered.length}</p>
<select className="rounded-md border border-line bg-surface px-3 py-2 text-sm dark:[color-scheme:dark]">
<option>Сначала новые</option>
<option>По популярности</option>
</select>
</div>
{isLoading ? (
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
{[1, 2, 3, 4, 5, 6].map((item) => (
<Skeleton key={item} className="h-80 w-full" />
))}
</div>
) : isError ? (
<ErrorState retry={refetch} />
) : filtered.length ? (
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
{filtered.map((material) => (
<MaterialCard key={material.id} material={material} />
))}
</div>
) : (
<EmptyState title="Ничего не найдено" text="Попробуйте изменить запрос или убрать часть фильтров." />
)}
</section>
</div>
</div>
);
}

View File

@@ -0,0 +1,60 @@
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
import { vi } from "vitest";
import { renderWithProviders } from "../test/renderWithProviders";
import { MaterialsPage } from "./MaterialsPage";
vi.mock("../shared/api/endpoints", () => ({
contentApi: {
list: vi.fn(async ({ q } = {}) => {
const items = [
{
id: "robot",
slug: "robot",
title: "Команда молодых инженеров представила робота-помощника",
excerpt: "Проект студентов ДГТУ",
tags: ["робот", "инженеры"],
type: "article",
category: "Наука",
},
{
id: "data-school",
slug: "data-school",
title: "Открыт набор в летнюю школу анализа данных",
excerpt: "Образовательная программа",
tags: ["данные"],
type: "announcement",
category: "Образование",
},
];
return q ? items.filter((item) => `${item.title} ${item.excerpt}`.toLowerCase().includes(q.toLowerCase())) : items;
}),
},
directoriesApi: {
categories: vi.fn(async () => [{ id: "science", name: "Наука" }]),
},
}));
describe("MaterialsPage", () => {
it("фильтрует материалы по поисковому запросу", async () => {
const user = userEvent.setup();
renderWithProviders(
<MemoryRouter>
<MaterialsPage />
</MemoryRouter>,
);
expect(await screen.findByText("Команда молодых инженеров представила робота-помощника")).toBeInTheDocument();
const search = screen.getByPlaceholderText("Название, автор, тема или тег");
await user.type(search, "робота-помощника");
await user.click(screen.getByRole("button", { name: "Найти" }));
await waitFor(() => {
expect(screen.getByText("Команда молодых инженеров представила робота-помощника")).toBeInTheDocument();
});
expect(screen.queryByText("Открыт набор в летнюю школу анализа данных")).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,273 @@
import {
ArrowUpRight,
BookOpenText,
Headphones,
MessageCircle,
Music2,
Pause,
Play,
Radio,
Send,
UsersRound,
} from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useSearchParams } from "react-router-dom";
import { directoriesApi } from "../shared/api/endpoints";
import { toList } from "../shared/api/normalize";
import { queryKeys } from "../shared/api/queryKeys";
import { Badge } from "../shared/ui/Badge";
import { Button } from "../shared/ui/Button";
import { EmptyState, ErrorState, Skeleton } from "../shared/ui/States";
import { cn } from "../shared/lib/cn";
const tabs = [
{ id: "radio", label: "Радио", icon: Radio },
{ id: "magazines", label: "Журналы", icon: BookOpenText },
{ id: "social", label: "Соцсети", icon: UsersRound },
];
function normalizeMedia(payload) {
return toList(payload).map((item) => ({
...item,
id: item.id ?? item.slug ?? item.url ?? item.title ?? item.name,
type: String(item.type ?? item.kind ?? item.category ?? "").toLowerCase(),
title: item.title ?? item.name ?? "Медиа",
text: item.text ?? item.description ?? item.summary ?? "",
url: item.url ?? item.link ?? item.href,
handle: item.handle ?? item.username ?? item.slug,
audience: item.audience ?? item.followers ?? item.subscribers,
time: item.time ?? item.startsAt ?? item.startTime,
host: item.host ?? item.author ?? item.speaker,
issue: item.issue ?? item.number ?? item.publishedAt,
}));
}
function filtered(items, aliases) {
return items.filter((item) => aliases.some((alias) => item.type.includes(alias)));
}
function ExternalLink({ href, children }) {
if (!href) {
return (
<Button className="mt-5" variant="secondary" disabled icon={<ArrowUpRight className="size-4" />}>
{children}
</Button>
);
}
return (
<a
className="mt-5 inline-flex h-11 items-center justify-center gap-2 rounded-md border border-line bg-surface px-5 text-sm font-semibold text-ink transition hover:border-primary hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
href={href}
target="_blank"
rel="noreferrer"
>
<ArrowUpRight className="size-4" />
{children}
</a>
);
}
function RadioTab({ items }) {
const [playing, setPlaying] = useState(false);
const [station, ...schedule] = items;
if (!station) {
return <EmptyState title="Радио пока не добавлено" text="Записи типа radio появятся здесь после ответа backend." />;
}
return (
<div className="grid gap-6 lg:grid-cols-[minmax(0,3fr)_minmax(18rem,1fr)]">
<section className="overflow-hidden rounded-xl bg-primary text-white">
<div className="grid gap-8 p-6 sm:p-8 md:grid-cols-[minmax(0,3fr)_minmax(9rem,1fr)] md:items-end lg:p-10">
<div>
<Badge className="bg-white/12 text-white">{station.status ?? "Сейчас в эфире"}</Badge>
<h2 className="mt-5 font-serif text-3xl sm:text-4xl md:text-5xl">{station.title}</h2>
<p className="mt-4 max-w-xl text-sm leading-7 text-white/70 sm:text-base">{station.text}</p>
<div className="mt-7 flex flex-wrap items-center gap-3">
<Button
className="bg-white text-primary hover:bg-paper"
icon={playing ? <Pause className="size-4" /> : <Play className="size-4" />}
onClick={() => setPlaying((value) => !value)}
>
{playing ? "Пауза" : "Слушать эфир"}
</Button>
{station.audience && (
<span className="flex items-center gap-2 text-sm text-white/60">
<span className={cn("size-2 rounded-full bg-accent", playing && "animate-pulse")} />
{station.audience}
</span>
)}
</div>
</div>
<div className="relative mx-auto grid aspect-square w-40 place-items-center rounded-full border border-white/20 md:mx-0">
<div className={cn("absolute inset-4 rounded-full border border-white/15", playing && "animate-spin")} />
<Headphones className="size-16 text-white/85" />
</div>
</div>
<div className="flex h-16 items-end gap-1 border-t border-white/10 px-6 pb-4">
{Array.from({ length: 16 }, (_, index) => (
<span
key={index}
className="flex-1 rounded-t bg-white/25"
style={{ height: playing ? `${24 + ((index * 17) % 58)}%` : "18%" }}
/>
))}
</div>
</section>
<aside className="rounded-xl border border-line bg-surface p-5 sm:p-6">
<div className="flex items-center gap-3">
<Music2 className="size-5 text-accent" />
<h2 className="font-serif text-2xl">Программа дня</h2>
</div>
{schedule.length === 0 ? (
<p className="mt-5 text-sm leading-6 text-muted">Расписание появится после добавления дополнительных записей radio.</p>
) : (
<div className="mt-5 divide-y divide-line">
{schedule.map((show) => (
<article key={show.id} className="py-4 first:pt-0">
<div className="flex gap-4">
<span className="font-semibold text-accent">{show.time}</span>
<div>
<h3 className="font-semibold">{show.title}</h3>
<p className="mt-1 text-xs text-muted">{show.host}</p>
<p className="mt-2 text-sm leading-6 text-muted">{show.text}</p>
</div>
</div>
</article>
))}
</div>
)}
</aside>
</div>
);
}
function MagazinesTab({ items }) {
if (items.length === 0) {
return <EmptyState title="Журналы пока не добавлены" text="Записи типа magazine появятся здесь после ответа backend." />;
}
return (
<div className="grid gap-5 md:grid-cols-2 lg:grid-cols-3">
{items.map((magazine, index) => (
<article key={magazine.id} className="group overflow-hidden rounded-xl border border-line bg-surface">
<div className={cn("relative flex aspect-[4/5] flex-col justify-between p-6 text-white sm:p-8", index % 2 ? "bg-accent" : "bg-primary")}>
<span className="text-xs font-bold uppercase tracking-[0.16em] text-white/60">Журнал ДГТУ Медиа</span>
<div>
<span className="font-serif text-7xl text-white/15">{String(index + 1).padStart(2, "0")}</span>
<h2 className="mt-2 font-serif text-4xl leading-tight">{magazine.title}</h2>
<p className="mt-3 text-sm text-white/60">{magazine.issue}</p>
</div>
</div>
<div className="p-5 sm:p-6">
<p className="leading-7 text-muted">{magazine.text}</p>
<ExternalLink href={magazine.url}>Открыть выпуск</ExternalLink>
</div>
</article>
))}
</div>
);
}
function SocialTab({ items }) {
if (items.length === 0) {
return <EmptyState title="Соцсети пока не добавлены" text="Записи типа social появятся здесь после ответа backend." />;
}
return (
<div className="grid gap-5 md:grid-cols-2 lg:grid-cols-3">
{items.map((channel, index) => {
const Icon = index % 2 ? Send : MessageCircle;
return (
<article key={channel.id} className="rounded-xl border border-line bg-surface p-5 sm:p-6">
<div className="flex items-start justify-between gap-4">
<div className="grid size-12 place-items-center rounded-lg bg-accent-soft text-accent">
<Icon className="size-6" />
</div>
{channel.audience && <Badge>{channel.audience}</Badge>}
</div>
<h2 className="mt-7 font-serif text-3xl">{channel.title}</h2>
{channel.handle && <p className="mt-1 text-sm font-semibold text-primary">{channel.handle}</p>}
<p className="mt-4 leading-7 text-muted">{channel.text}</p>
<ExternalLink href={channel.url}>Перейти</ExternalLink>
</article>
);
})}
</div>
);
}
export function MediaChannelsPage() {
const [params, setParams] = useSearchParams();
const reduceMotion = useReducedMotion();
const mediaQuery = useQuery({
queryKey: queryKeys.media,
queryFn: directoriesApi.media,
});
const media = normalizeMedia(mediaQuery.data);
const active = tabs.some((tab) => tab.id === params.get("tab"))
? params.get("tab")
: "radio";
const radioItems = filtered(media, ["radio", "радио"]);
const magazineItems = filtered(media, ["magazine", "journal", "журнал"]);
const socialItems = filtered(media, ["social", "соц"]);
return (
<div className="page-shell-wide py-9 md:py-12">
<header className="grid gap-6 border-b border-line pb-8 md:grid-cols-2 md:items-end">
<div>
<p className="text-xs font-bold uppercase tracking-widest text-accent">Экосистема ДГТУ Медиа</p>
<h1 className="mt-3 font-serif text-4xl sm:text-5xl md:text-6xl">Медиаканалы</h1>
</div>
<p className="max-w-xl text-base leading-7 text-muted md:justify-self-end md:text-lg">
Радиоэфир, университетские журналы и официальные сообщества загружаются через `/api/media`.
</p>
</header>
<div className="mt-7 flex gap-2 overflow-x-auto pb-2" role="tablist" aria-label="Медиаканалы">
{tabs.map(({ id, label, icon: Icon }) => (
<button
key={id}
type="button"
role="tab"
aria-selected={active === id}
onClick={() => setParams({ tab: id })}
className={cn(
"flex h-11 shrink-0 items-center gap-2 rounded-md border px-4 text-sm font-semibold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent",
active === id
? "border-primary bg-primary text-white"
: "border-line bg-surface text-ink hover:border-primary",
)}
>
<Icon className="size-4" />
{label}
</button>
))}
</div>
<div className="mt-7">
{mediaQuery.isLoading && <Skeleton className="h-96" />}
{mediaQuery.isError && <ErrorState retry={() => mediaQuery.refetch()} />}
{mediaQuery.isSuccess && (
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={active}
initial={reduceMotion ? false : { opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={reduceMotion ? { opacity: 1 } : { opacity: 0, y: -8 }}
transition={{ duration: reduceMotion ? 0 : 0.28, ease: [0.2, 0, 0, 1] }}
>
{active === "radio" && <RadioTab items={radioItems} />}
{active === "magazines" && <MagazinesTab items={magazineItems} />}
{active === "social" && <SocialTab items={socialItems} />}
</motion.div>
</AnimatePresence>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,53 @@
import { motion, useReducedMotion } from "framer-motion";
import { ArrowLeft, Home } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { Button } from "../shared/ui/Button";
export function NotFoundPage() {
const navigate = useNavigate();
const reduceMotion = useReducedMotion();
return (
<div className="grid min-h-[72vh] place-items-center px-4 py-16">
<div className="max-w-2xl text-center">
<svg
viewBox="0 0 520 260"
className="mx-auto w-full max-w-lg"
role="img"
aria-label="Потерянный документ в университетском архиве"
>
<rect x="40" y="50" width="440" height="170" rx="18" fill="#fffdf8" stroke="#d9d8d0" />
<path d="M72 92h130M72 120h90M72 178h120" stroke="#d9d8d0" strokeWidth="10" strokeLinecap="round" />
<motion.g
animate={reduceMotion ? undefined : { y: [0, -9, 0], rotate: [-1, 1, -1] }}
transition={{ duration: 3.2, repeat: Infinity, ease: "easeInOut" }}
>
<path d="M258 38h132l42 42v136H258z" fill="#f6ddd4" stroke="#123c36" strokeWidth="5" />
<path d="M390 38v44h42" fill="none" stroke="#123c36" strokeWidth="5" />
<path d="M288 112h104M288 142h74" stroke="#d65a3a" strokeWidth="9" strokeLinecap="round" />
<circle cx="389" cy="180" r="29" fill="#123c36" />
<path d="m409 201 22 22" stroke="#123c36" strokeWidth="10" strokeLinecap="round" />
<path d="M378 180h22M389 169v22" stroke="#fff" strokeWidth="5" strokeLinecap="round" />
</motion.g>
</svg>
<p className="mt-4 text-xs font-bold uppercase tracking-[0.18em] text-accent">Ошибка 404</p>
<h1 className="mt-3 font-serif text-4xl sm:text-5xl">Такой страницы нет в архиве</h1>
<p className="mx-auto mt-4 max-w-lg leading-7 text-muted">
Возможно, материал переместили, адрес изменился или ссылка устарела.
</p>
<div className="mt-7 flex flex-wrap justify-center gap-3">
<Button icon={<Home className="size-4" />} onClick={() => navigate("/")}>
На главную
</Button>
<Button
variant="secondary"
icon={<ArrowLeft className="size-4" />}
onClick={() => navigate(-1)}
>
Назад
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,174 @@
import { Activity, Eye, FileText, Users } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { adminApi, contentApi } from "../../shared/api/endpoints";
import { normalizeContentList, toEntity, toList } from "../../shared/api/normalize";
import { queryKeys } from "../../shared/api/queryKeys";
import { Badge } from "../../shared/ui/Badge";
import { Button } from "../../shared/ui/Button";
import { EmptyState, ErrorState, Skeleton } from "../../shared/ui/States";
const popularParams = { limit: 4, sort: "popular" };
function number(value) {
return Number(value ?? 0).toLocaleString("ru-RU");
}
function pickChart(summary) {
const values = summary.viewsByDay ?? summary.weeklyViews ?? summary.chart ?? [];
if (!Array.isArray(values)) return [];
return values.map((item) => (typeof item === "number" ? { value: item } : item));
}
export function AdminPage() {
const dashboardQuery = useQuery({
queryKey: queryKeys.adminDashboard,
queryFn: adminApi.dashboard,
});
const usersQuery = useQuery({
queryKey: queryKeys.adminUsers({ limit: 8 }),
queryFn: () => adminApi.users({ limit: 8 }),
});
const popularQuery = useQuery({
queryKey: queryKeys.content(popularParams),
queryFn: () => contentApi.list(popularParams),
});
const summary = toEntity(dashboardQuery.data) ?? {};
const users = toList(usersQuery.data);
const popular = normalizeContentList(popularQuery.data);
const chart = pickChart(summary);
const maxChart = Math.max(...chart.map((item) => Number(item.value ?? item.views ?? 0)), 1);
const stats = [
[Users, "Пользователи", summary.users ?? summary.userCount],
[FileText, "Материалы", summary.materials ?? summary.contentCount],
[Eye, "Просмотры", summary.views ?? summary.viewCount],
[Activity, "На модерации", summary.pending ?? summary.moderationCount],
];
return (
<div>
<p className="text-xs font-bold uppercase tracking-widest text-accent">Администрирование</p>
<h1 className="mt-2 font-serif text-4xl">Обзор системы</h1>
<div className="mt-8 grid gap-5 sm:grid-cols-2 xl:grid-cols-4">
{stats.map(([Icon, label, value]) => {
const ItemIcon = Icon;
return (
<div key={String(label)} className="rounded-lg border border-line bg-surface p-5">
<div className="flex items-center justify-between">
<ItemIcon className="size-5 text-accent" />
<Badge tone="accent">API</Badge>
</div>
{dashboardQuery.isLoading ? (
<Skeleton className="mt-6 h-10 w-24" />
) : (
<p className="mt-6 font-serif text-4xl">{number(value)}</p>
)}
<p className="mt-1 text-sm text-muted">{String(label)}</p>
</div>
);
})}
</div>
{dashboardQuery.isError && (
<div className="mt-6">
<ErrorState retry={() => dashboardQuery.refetch()} />
</div>
)}
<div className="mt-6 grid gap-6 xl:grid-cols-[1.25fr_0.75fr]">
<section className="rounded-lg border border-line bg-surface p-6">
<div className="flex items-center justify-between">
<h2 className="font-serif text-2xl">Просмотры за неделю</h2>
<select className="rounded-md border border-line bg-white px-3 py-2 text-sm text-ink dark:bg-paper dark:[color-scheme:dark]">
<option>7 дней</option>
<option>30 дней</option>
</select>
</div>
{dashboardQuery.isLoading && <Skeleton className="mt-8 h-64" />}
{dashboardQuery.isSuccess && chart.length === 0 && (
<div className="mt-8">
<EmptyState title="Нет данных графика" text="Передайте массив просмотров в dashboard, чтобы заполнить диаграмму." />
</div>
)}
{dashboardQuery.isSuccess && chart.length > 0 && (
<div className="mt-8 flex h-64 items-end gap-3 border-b border-l border-line px-3">
{chart.map((item, index) => {
const value = Number(item.value ?? item.views ?? 0);
return (
<div key={item.date ?? index} className="flex flex-1 flex-col items-center justify-end gap-2">
<div
className="w-full max-w-12 rounded-t-md bg-primary transition hover:bg-accent"
style={{ height: `${Math.max((value / maxChart) * 100, 4)}%` }}
/>
<span className="pb-2 text-xs text-muted">{item.label ?? item.day ?? index + 1}</span>
</div>
);
})}
</div>
)}
</section>
<section className="rounded-lg border border-line bg-surface p-6">
<h2 className="font-serif text-2xl">Популярное</h2>
<div className="mt-5">
{popularQuery.isLoading && <Skeleton className="h-56" />}
{popularQuery.isError && <ErrorState retry={() => popularQuery.refetch()} />}
{popularQuery.isSuccess && popular.length === 0 && (
<EmptyState title="Нет популярных материалов" text="Список заполнится после ответа `/api/content`." />
)}
{popularQuery.isSuccess && popular.length > 0 && (
<div className="divide-y divide-line">
{popular.map((material, index) => (
<div key={material.id} className="flex gap-3 py-4 first:pt-0">
<span className="font-serif text-2xl text-line">{String(index + 1).padStart(2, "0")}</span>
<div>
<p className="font-semibold leading-snug">{material.title}</p>
<p className="mt-1 text-xs text-muted">{number(material.views)} просмотров</p>
</div>
</div>
))}
</div>
)}
</div>
</section>
</div>
<section className="mt-6 overflow-hidden rounded-lg border border-line bg-surface">
<div className="flex items-center justify-between border-b border-line p-5">
<h2 className="font-serif text-2xl">Пользователи</h2>
<Button size="sm" variant="secondary">Все пользователи</Button>
</div>
{usersQuery.isLoading && <div className="p-5"><Skeleton className="h-44" /></div>}
{usersQuery.isError && <div className="p-5"><ErrorState retry={() => usersQuery.refetch()} /></div>}
{usersQuery.isSuccess && users.length === 0 && (
<div className="p-5">
<EmptyState title="Пользователи не найдены" text="Таблица заполнится данными из `/api/admin/users`." />
</div>
)}
{usersQuery.isSuccess && users.length > 0 && (
<div className="overflow-x-auto">
<table className="w-full min-w-[42rem] text-left text-sm">
<thead className="bg-paper text-xs uppercase tracking-wider text-muted">
<tr>
<th className="px-5 py-3">Имя</th>
<th className="px-5 py-3">Почта</th>
<th className="px-5 py-3">Роль</th>
<th className="px-5 py-3">Статус</th>
</tr>
</thead>
<tbody className="divide-y divide-line">
{users.map((user) => (
<tr key={user.id ?? user.email}>
<td className="px-5 py-4 font-semibold">{user.name ?? user.fullName ?? "Без имени"}</td>
<td className="px-5 py-4 text-muted">{user.email}</td>
<td className="px-5 py-4"><Badge>{user.role ?? "user"}</Badge></td>
<td className="px-5 py-4"><Badge tone={user.active === false ? "danger" : "success"}>{user.active === false ? "Заблокирован" : "Активен"}</Badge></td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</div>
);
}

View File

@@ -0,0 +1,121 @@
import { Bell, BookOpen, MessageSquareText, MoveRight } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { Link } from "react-router-dom";
import { useSession } from "../../app/store/session";
import { analyticsApi, notificationsApi } from "../../shared/api/endpoints";
import { queryKeys } from "../../shared/api/queryKeys";
import { toEntity, toList } from "../../shared/api/normalize";
import { Badge } from "../../shared/ui/Badge";
import { Button } from "../../shared/ui/Button";
import { EmptyState, ErrorState, Skeleton } from "../../shared/ui/States";
function firstName(name = "") {
return name.trim().split(/\s+/)[0] || "пользователь";
}
function formatDate(value) {
if (!value) return "";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString("ru-RU", { day: "numeric", month: "long", hour: "2-digit", minute: "2-digit" });
}
export function CabinetHomePage() {
const user = useSession((state) => state.user);
const notificationsQuery = useQuery({
queryKey: queryKeys.notifications,
queryFn: notificationsApi.list,
});
const summaryQuery = useQuery({
queryKey: queryKeys.analyticsSummary,
queryFn: analyticsApi.summary,
});
const notifications = toList(notificationsQuery.data);
const unreadCount = notifications.filter((item) => !item.read && !item.isRead).length;
const summary = toEntity(summaryQuery.data) ?? {};
const stats = [
[BookOpen, summary.savedMaterials ?? summary.materials ?? 0, "Материалов"],
[Bell, unreadCount, "Новых уведомлений"],
[MessageSquareText, summary.comments ?? summary.commentCount ?? 0, "Комментариев"],
];
const canCreate = user?.role === "editor" || user?.role === "admin";
return (
<div>
<p className="text-xs font-bold uppercase tracking-widest text-accent">Личный кабинет</p>
<h1 className="mt-2 font-serif text-4xl">Добрый день, {firstName(user?.name)}</h1>
<p className="mt-3 text-muted">Здесь собраны ваши материалы, уведомления и быстрые действия.</p>
<div className="mt-8 grid gap-5 md:grid-cols-3">
{stats.map(([Icon, value, label]) => {
const ItemIcon = Icon;
return (
<div key={String(label)} className="rounded-lg border border-line bg-surface p-5">
<ItemIcon className="size-5 text-accent" />
{summaryQuery.isLoading ? (
<Skeleton className="mt-6 h-10 w-20" />
) : (
<p className="mt-6 font-serif text-4xl">{Number(value).toLocaleString("ru-RU")}</p>
)}
<p className="mt-1 text-sm text-muted">{String(label)}</p>
</div>
);
})}
</div>
<div className="mt-6 grid gap-6 lg:grid-cols-[1.2fr_0.8fr]">
<section className="rounded-lg border border-line bg-surface p-6">
<div className="flex items-center justify-between gap-4">
<h2 className="font-serif text-2xl">Последние уведомления</h2>
<Badge tone="accent">{unreadCount} новых</Badge>
</div>
<div className="mt-5">
{notificationsQuery.isLoading && (
<div className="space-y-3">
<Skeleton className="h-20" />
<Skeleton className="h-20" />
</div>
)}
{notificationsQuery.isError && <ErrorState retry={() => notificationsQuery.refetch()} />}
{notificationsQuery.isSuccess && notifications.length === 0 && (
<EmptyState title="Уведомлений пока нет" text="Когда backend пришлет новые события, они появятся здесь." />
)}
{notificationsQuery.isSuccess && notifications.length > 0 && (
<div className="divide-y divide-line">
{notifications.slice(0, 5).map((item) => (
<article key={item.id} className="py-4 first:pt-0">
<div className="flex items-start gap-3">
<span className={`mt-1.5 size-2 rounded-full ${item.read || item.isRead ? "bg-line" : "bg-accent"}`} />
<div>
<h3 className="font-semibold">{item.title ?? "Уведомление"}</h3>
<p className="mt-1 text-sm text-muted">{item.text ?? item.message ?? item.body}</p>
<p className="mt-2 text-xs text-muted">{formatDate(item.createdAt ?? item.date)}</p>
</div>
</div>
</article>
))}
</div>
)}
</div>
</section>
<section className="rounded-lg bg-primary p-6 text-white">
<p className="text-xs font-bold uppercase tracking-widest text-white/50">Быстрое действие</p>
<h2 className="mt-4 font-serif text-3xl">
{canCreate ? "Подготовьте новый материал" : "Настройте свой профиль"}
</h2>
<p className="mt-3 text-sm leading-6 text-white/65">
{canCreate
? "Создайте материал и отправьте его на обработку backend."
: "Проверьте данные профиля, которые пришли из учетной записи."}
</p>
<Link to={canCreate ? "/cabinet/editor/new" : "/cabinet/profile"}>
<Button className="mt-6 bg-white text-primary hover:bg-paper" icon={<MoveRight className="size-4" />}>
Продолжить
</Button>
</Link>
</section>
</div>
</div>
);
}

View File

@@ -0,0 +1,104 @@
import { FilePlus2, MoreHorizontal } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { Link } from "react-router-dom";
import { useSession } from "../../app/store/session";
import { contentApi } from "../../shared/api/endpoints";
import { normalizeContentList } from "../../shared/api/normalize";
import { queryKeys } from "../../shared/api/queryKeys";
import { Button } from "../../shared/ui/Button";
import { EmptyState, ErrorState, Skeleton } from "../../shared/ui/States";
import { StatusBadge } from "../../shared/ui/StatusBadge";
const statusFilters = [
["all", "Все"],
["draft", "Черновики"],
["moderation", "На модерации"],
["published", "Опубликованные"],
["returned", "Возвращенные"],
];
function formatDate(value) {
if (!value) return "Нет даты";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString("ru-RU", { day: "numeric", month: "long", hour: "2-digit", minute: "2-digit" });
}
export function EditorPage() {
const user = useSession((state) => state.user);
const queryParams = { mine: true, authorId: user?.id };
const materialsQuery = useQuery({
queryKey: queryKeys.content(queryParams),
queryFn: () => contentApi.list(queryParams),
enabled: Boolean(user?.id),
});
const materials = normalizeContentList(materialsQuery.data);
return (
<div>
<div className="flex flex-wrap items-end justify-between gap-5">
<div>
<p className="text-xs font-bold uppercase tracking-widest text-accent">Редакция</p>
<h1 className="mt-2 font-serif text-4xl">Мои материалы</h1>
<p className="mt-3 text-muted">Черновики, публикации и замечания модераторов загружаются из backend.</p>
</div>
<Link to="/cabinet/editor/new">
<Button icon={<FilePlus2 className="size-4" />}>Новый материал</Button>
</Link>
</div>
<div className="mt-7 flex gap-2 overflow-x-auto pb-2">
{statusFilters.map(([status, label], index) => (
<Button key={status} size="sm" variant={index === 0 ? "primary" : "secondary"}>
{label}
</Button>
))}
</div>
<div className="mt-5 overflow-hidden rounded-lg border border-line bg-surface">
<div className="hidden grid-cols-[minmax(0,4fr)_minmax(8rem,1fr)_minmax(8rem,1fr)_auto] border-b border-line bg-paper px-5 py-3 text-xs font-bold uppercase tracking-wider text-muted md:grid">
<span>Материал</span>
<span>Статус</span>
<span>Обновлен</span>
<span />
</div>
{materialsQuery.isLoading && (
<div className="space-y-3 p-5">
<Skeleton className="h-24" />
<Skeleton className="h-24" />
<Skeleton className="h-24" />
</div>
)}
{materialsQuery.isError && (
<div className="p-5">
<ErrorState retry={() => materialsQuery.refetch()} />
</div>
)}
{materialsQuery.isSuccess && materials.length === 0 && (
<div className="p-5">
<EmptyState title="Материалов пока нет" text="Созданные через backend записи появятся в этом списке." />
</div>
)}
{materialsQuery.isSuccess && materials.map((material) => (
<article
key={material.id}
className="grid gap-4 border-b border-line p-5 last:border-0 md:grid-cols-[minmax(0,4fr)_minmax(8rem,1fr)_minmax(8rem,1fr)_auto] md:items-center"
>
<div>
<p className="text-xs text-muted">{material.type ?? "Материал"} · {material.category}</p>
<h2 className="mt-1 font-serif text-xl">{material.title}</h2>
{(material.moderatorComment || material.reviewComment) && (
<p className="mt-2 text-sm text-danger">{material.moderatorComment ?? material.reviewComment}</p>
)}
</div>
<StatusBadge status={material.status ?? "draft"} />
<span className="text-sm text-muted">{formatDate(material.updatedAt ?? material.publishedAt)}</span>
<button className="grid size-9 place-items-center rounded-md hover:bg-paper" aria-label="Действия">
<MoreHorizontal className="size-5" />
</button>
</article>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,265 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Eye, ImagePlus, Save, Send, Trash2, UploadCloud } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useForm } from "react-hook-form";
import { useNavigate } from "react-router-dom";
import { z } from "zod";
import { contentApi, directoriesApi } from "../../shared/api/endpoints";
import { toList } from "../../shared/api/normalize";
import { queryKeys } from "../../shared/api/queryKeys";
import { Button } from "../../shared/ui/Button";
import { Input, Select, Textarea } from "../../shared/ui/Field";
import { ErrorState, Skeleton } from "../../shared/ui/States";
import { cn } from "../../shared/lib/cn";
const schema = z.object({
title: z.string().min(10, "Минимум 10 символов"),
excerpt: z.string().min(30, "Минимум 30 символов"),
content: z.string().min(80, "Добавьте более подробный текст"),
category: z.string().min(1, "Выберите категорию"),
type: z.string().min(1, "Выберите тип"),
tags: z.string().optional(),
});
const materialTypes = [
["news", "Новость"],
["article", "Статья"],
["research", "Исследование"],
["announcement", "Объявление"],
["video", "Видео"],
];
function formatFileSize(size) {
if (size < 1024 * 1024) return `${Math.round(size / 1024)} КБ`;
return `${(size / 1024 / 1024).toFixed(1)} МБ`;
}
function normalizeCategories(payload) {
return toList(payload).map((item) => ({
id: item.id ?? item.slug ?? item.name,
label: item.name ?? item.title ?? item.label,
value: item.slug ?? item.id ?? item.name,
}));
}
function makePayload(data, photos, status) {
const tags = data.tags
? data.tags.split(",").map((tag) => tag.trim()).filter(Boolean)
: [];
const payload = { ...data, tags, status };
if (photos.length === 0) return payload;
const formData = new FormData();
Object.entries(payload).forEach(([key, value]) => {
if (Array.isArray(value)) {
value.forEach((item) => formData.append(`${key}[]`, item));
} else {
formData.append(key, value ?? "");
}
});
photos.forEach((file) => formData.append("photos", file));
return formData;
}
export function MaterialFormPage() {
const [notice, setNotice] = useState("");
const [dragActive, setDragActive] = useState(false);
const [photos, setPhotos] = useState([]);
const [submitStatus, setSubmitStatus] = useState("moderation");
const inputRef = useRef(null);
const navigate = useNavigate();
const categoriesQuery = useQuery({
queryKey: queryKeys.categories,
queryFn: directoriesApi.categories,
});
const categories = normalizeCategories(categoriesQuery.data);
const createMaterial = useMutation({
mutationFn: ({ data, status }) => contentApi.create(makePayload(data, photos, status)),
onSuccess: (_, variables) => {
setNotice(variables.status === "draft" ? "Черновик сохранен." : "Материал отправлен на модерацию.");
navigate("/cabinet/editor");
},
});
const {
register,
handleSubmit,
formState: { errors },
} = useForm({
resolver: zodResolver(schema),
defaultValues: {
type: "article",
category: "",
title: "",
excerpt: "",
content: "",
tags: "",
},
});
const previews = useMemo(
() => photos.map((file) => ({ file, url: URL.createObjectURL(file) })),
[photos],
);
useEffect(
() => () => {
previews.forEach(({ url }) => URL.revokeObjectURL(url));
},
[previews],
);
const addPhotos = (fileList) => {
const imageFiles = Array.from(fileList).filter((file) => file.type.startsWith("image/"));
setPhotos((current) => {
const known = new Set(current.map((file) => `${file.name}-${file.size}-${file.lastModified}`));
const next = imageFiles.filter((file) => !known.has(`${file.name}-${file.size}-${file.lastModified}`));
return [...current, ...next].slice(0, 8);
});
};
const removePhoto = (fileToRemove) => {
setPhotos((current) => current.filter((file) => file !== fileToRemove));
};
const submit = (data) => {
createMaterial.mutate({ data, status: submitStatus });
};
return (
<div className="max-w-5xl">
<p className="text-xs font-bold uppercase tracking-widest text-accent">Новая публикация</p>
<h1 className="mt-2 font-serif text-4xl">Создание материала</h1>
<form className="mt-8 grid gap-6 lg:grid-cols-[minmax(0,4fr)_minmax(14rem,1fr)]" onSubmit={handleSubmit(submit)}>
<div className="grid gap-5 rounded-lg border border-line bg-surface p-5 sm:p-7">
<div className="grid gap-5 sm:grid-cols-2">
<Select label="Тип материала" {...register("type")}>
{materialTypes.map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</Select>
<div>
{categoriesQuery.isLoading ? (
<Skeleton className="h-16" />
) : categoriesQuery.isError ? (
<ErrorState retry={() => categoriesQuery.refetch()} />
) : (
<Select label="Категория" error={errors.category?.message} {...register("category")}>
<option value="">Выберите категорию</option>
{categories.map((item) => (
<option key={item.id} value={item.value}>{item.label}</option>
))}
</Select>
)}
</div>
</div>
<Input label="Заголовок" error={errors.title?.message} {...register("title")} />
<Textarea label="Краткое описание" error={errors.excerpt?.message} {...register("excerpt")} />
<Textarea
className="min-h-80 font-mono text-sm"
label="Содержание в Markdown"
error={errors.content?.message}
placeholder={"## Подзаголовок\n\nТекст материала..."}
{...register("content")}
/>
<Input label="Теги" placeholder="наука, кампус, студенты" {...register("tags")} />
<section>
<p className="text-sm font-semibold text-ink">Фотографии и обложка</p>
<label
className={cn(
"mt-2 flex cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed bg-paper p-8 text-center transition",
dragActive
? "border-dstu-menu-accent bg-dstu-menu-accent/10 text-ink"
: "border-line text-muted hover:border-primary hover:text-ink",
)}
onDragEnter={(event) => {
event.preventDefault();
setDragActive(true);
}}
onDragOver={(event) => event.preventDefault()}
onDragLeave={(event) => {
if (!event.currentTarget.contains(event.relatedTarget)) setDragActive(false);
}}
onDrop={(event) => {
event.preventDefault();
setDragActive(false);
addPhotos(event.dataTransfer.files);
}}
>
<input
ref={inputRef}
className="sr-only"
type="file"
accept="image/*"
multiple
onChange={(event) => addPhotos(event.target.files ?? [])}
/>
<UploadCloud className="size-8 text-primary" />
<span className="mt-3 font-semibold text-ink">Перетащите фотографии сюда</span>
<span className="mt-1 text-sm">или нажмите, чтобы выбрать файлы</span>
<span className="mt-3 inline-flex items-center gap-2 text-xs text-muted">
<ImagePlus className="size-4" /> JPG, PNG, WEBP, до 8 файлов
</span>
</label>
{previews.length > 0 && (
<div className="mt-4 grid gap-3 sm:grid-cols-2">
{previews.map(({ file, url }, index) => (
<div key={`${file.name}-${file.lastModified}`} className="flex gap-3 rounded-lg border border-line bg-white p-3 dark:bg-paper">
<img src={url} alt="" className="size-20 rounded-md object-cover" />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-ink">
{index === 0 ? "Обложка: " : ""}
{file.name}
</p>
<p className="mt-1 text-xs text-muted">{formatFileSize(file.size)}</p>
<button
type="button"
className="mt-3 inline-flex items-center gap-1 text-xs font-semibold text-danger hover:text-danger/80"
onClick={() => removePhoto(file)}
>
<Trash2 className="size-3.5" /> Удалить
</button>
</div>
</div>
))}
</div>
)}
</section>
</div>
<aside>
<div className="sticky top-24 grid gap-3 rounded-lg border border-line bg-surface p-5">
<h2 className="font-serif text-xl">Публикация</h2>
<p className="text-sm leading-6 text-muted">
Материал будет сохранен через `/api/content`. При наличии фотографий запрос уйдет как FormData.
</p>
<Button
type="submit"
variant="secondary"
loading={createMaterial.isPending && submitStatus === "draft"}
icon={<Save className="size-4" />}
onClick={() => setSubmitStatus("draft")}
>
Сохранить черновик
</Button>
<Button type="button" variant="ghost" icon={<Eye className="size-4" />}>
Предпросмотр
</Button>
<Button
type="submit"
loading={createMaterial.isPending && submitStatus === "moderation"}
icon={<Send className="size-4" />}
onClick={() => setSubmitStatus("moderation")}
>
На модерацию
</Button>
{notice && <p className="text-sm font-semibold text-success">{notice}</p>}
{createMaterial.isError && <p className="text-sm font-semibold text-danger">Не удалось сохранить материал.</p>}
</div>
</aside>
</form>
</div>
);
}

View File

@@ -0,0 +1,138 @@
import { Check, RotateCcw } from "lucide-react";
import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { contentApi } from "../../shared/api/endpoints";
import { normalizeContentList } from "../../shared/api/normalize";
import { queryKeys } from "../../shared/api/queryKeys";
import { Badge } from "../../shared/ui/Badge";
import { Button } from "../../shared/ui/Button";
import { Textarea } from "../../shared/ui/Field";
import { EmptyState, ErrorState, Skeleton } from "../../shared/ui/States";
const moderationParams = { status: "moderation" };
function paragraphs(content = "") {
if (Array.isArray(content)) return content;
return String(content).split(/\n{2,}/).filter(Boolean);
}
export function ModeratorPage() {
const [selectedId, setSelectedId] = useState(null);
const [message, setMessage] = useState("");
const queryClient = useQueryClient();
const materialsQuery = useQuery({
queryKey: queryKeys.content(moderationParams),
queryFn: () => contentApi.list(moderationParams),
});
const materials = normalizeContentList(materialsQuery.data);
const selected = materials.find((item) => item.id === selectedId) ?? materials[0];
useEffect(() => {
if (!selectedId && materials[0]?.id) {
setSelectedId(materials[0].id);
}
}, [materials, selectedId]);
const reviewMutation = useMutation({
mutationFn: ({ id, payload }) => contentApi.update(id, payload),
onSuccess: () => {
setMessage("");
queryClient.invalidateQueries({ queryKey: queryKeys.content(moderationParams) });
},
});
const approve = () => {
if (!selected?.id) return;
reviewMutation.mutate({ id: selected.id, payload: { status: "published", moderatorComment: message.trim() } });
};
const returnToAuthor = () => {
if (!selected?.id || !message.trim()) return;
reviewMutation.mutate({ id: selected.id, payload: { status: "returned", moderatorComment: message.trim() } });
};
return (
<div>
<p className="text-xs font-bold uppercase tracking-widest text-accent">Контроль качества</p>
<h1 className="mt-2 font-serif text-4xl">Очередь модерации</h1>
<div className="mt-8 grid gap-6 xl:grid-cols-[minmax(18rem,1fr)_minmax(0,3fr)]">
<aside className="overflow-hidden rounded-lg border border-line bg-surface">
<div className="border-b border-line p-4">
<b>Ожидают проверки</b>
<Badge className="ml-2" tone="warning">{materials.length}</Badge>
</div>
{materialsQuery.isLoading && (
<div className="space-y-3 p-4">
<Skeleton className="h-20" />
<Skeleton className="h-20" />
</div>
)}
{materialsQuery.isError && (
<div className="p-4">
<ErrorState retry={() => materialsQuery.refetch()} />
</div>
)}
{materialsQuery.isSuccess && materials.length === 0 && (
<div className="p-4">
<EmptyState title="Очередь пуста" text="Материалы со статусом модерации появятся здесь после ответа backend." />
</div>
)}
{materials.map((material) => (
<button
key={material.id}
type="button"
onClick={() => setSelectedId(material.id)}
className={`block w-full border-b border-line p-4 text-left last:border-0 ${
selected?.id === material.id ? "bg-accent-soft" : "hover:bg-paper"
}`}
>
<span className="text-xs text-muted">{material.type ?? "Материал"} · {material.author}</span>
<span className="mt-2 block font-serif text-lg leading-snug">{material.title}</span>
</button>
))}
</aside>
<section className="rounded-lg border border-line bg-surface p-5 sm:p-7">
{!selected && materialsQuery.isSuccess && (
<EmptyState title="Материал не выбран" text="Выберите запись из очереди модерации." />
)}
{selected && (
<>
<div className="flex flex-wrap items-center gap-3">
<Badge tone="warning">На модерации</Badge>
<span className="text-sm text-muted">{selected.author}</span>
</div>
<h2 className="mt-5 font-serif text-3xl">{selected.title}</h2>
<p className="mt-4 text-lg leading-8 text-muted">{selected.excerpt}</p>
<div className="mt-7 border-y border-line py-6">
{paragraphs(selected.content ?? selected.body).map((paragraph) => (
<p key={paragraph} className="mb-4 leading-7 last:mb-0">{paragraph}</p>
))}
</div>
<Textarea
className="mt-6"
label="Комментарий редактору"
value={message}
onChange={(event) => setMessage(event.target.value)}
placeholder="Обязателен при возврате на доработку"
/>
<div className="mt-6 flex flex-wrap gap-3">
<Button icon={<Check className="size-4" />} loading={reviewMutation.isPending} onClick={approve}>
Одобрить
</Button>
<Button
variant="danger"
icon={<RotateCcw className="size-4" />}
loading={reviewMutation.isPending}
disabled={!message.trim()}
onClick={returnToAuthor}
>
Вернуть на доработку
</Button>
</div>
</>
)}
</section>
</div>
</div>
);
}

View File

@@ -0,0 +1,129 @@
import { useRef, useState } from "react";
import { Camera, Info, UploadCloud } from "lucide-react";
import { useSession } from "../../app/store/session";
import { Button } from "../../shared/ui/Button";
import { Input, Textarea } from "../../shared/ui/Field";
import { cn } from "../../shared/lib/cn";
function initials(name = "") {
return name
.split(" ")
.filter(Boolean)
.map((part) => part[0])
.join("")
.slice(0, 2);
}
function readImage(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
export function ProfilePage() {
const user = useSession((state) => state.user) ?? {};
const [dragActive, setDragActive] = useState(false);
const [avatarPreview, setAvatarPreview] = useState(user.avatarUrl ?? user.avatar ?? "");
const [avatarError, setAvatarError] = useState("");
const fileInputRef = useRef(null);
const handleAvatar = async (file) => {
setAvatarError("");
if (!file) return;
if (!file.type.startsWith("image/")) {
setAvatarError("Выберите изображение JPG, PNG или WEBP");
return;
}
if (file.size > 3 * 1024 * 1024) {
setAvatarError("Файл должен быть до 3 МБ");
return;
}
const preview = await readImage(file);
setAvatarPreview(preview);
};
return (
<div className="max-w-3xl">
<p className="text-xs font-bold uppercase tracking-widest text-accent">Настройки</p>
<h1 className="mt-2 font-serif text-4xl">Профиль</h1>
<div className="mt-8 rounded-lg border border-line bg-surface p-5 sm:p-7">
<div className="mb-7 flex gap-3 rounded-lg border border-warning/25 bg-warning-soft p-4 text-sm leading-6 text-warning">
<Info className="mt-0.5 size-5 shrink-0" />
<p>
Данные профиля загружаются из `/api/auth/me`. Endpoint для сохранения профиля в предоставленном API не указан,
поэтому форма не отправляет изменения в БД.
</p>
</div>
<div className="flex flex-col gap-5 border-b border-line pb-7 sm:flex-row sm:items-center">
<label
className={cn(
"group relative grid size-24 shrink-0 cursor-pointer place-items-center overflow-hidden rounded-full bg-primary font-serif text-3xl text-white ring-2 ring-transparent transition",
dragActive && "bg-dstu-menu-accent text-dstu-menu ring-dstu-menu-accent",
)}
onDragEnter={(event) => {
event.preventDefault();
setDragActive(true);
}}
onDragOver={(event) => event.preventDefault()}
onDragLeave={(event) => {
if (!event.currentTarget.contains(event.relatedTarget)) setDragActive(false);
}}
onDrop={(event) => {
event.preventDefault();
setDragActive(false);
handleAvatar(event.dataTransfer.files?.[0]);
}}
title="Предпросмотр фотографии"
>
<input
ref={fileInputRef}
className="sr-only"
type="file"
accept="image/*"
onChange={(event) => handleAvatar(event.target.files?.[0])}
/>
{avatarPreview ? (
<img src={avatarPreview} alt="" className="size-full object-cover" />
) : (
<span>{initials(user.name)}</span>
)}
<span className="absolute inset-0 grid place-items-center bg-ink/45 opacity-0 transition group-hover:opacity-100">
<UploadCloud className="size-6 text-white" />
</span>
</label>
<div>
<Button
type="button"
variant="secondary"
icon={<Camera className="size-4" />}
onClick={() => fileInputRef.current?.click()}
>
Выбрать фотографию
</Button>
<p className="mt-2 text-xs text-muted">JPG, PNG или WEBP, до 3 МБ. Можно перетащить фото на аватар.</p>
{avatarError && <p className="mt-2 text-xs font-semibold text-danger">{avatarError}</p>}
</div>
</div>
<div className="mt-7 grid gap-5 sm:grid-cols-2">
<Input name="name" label="Имя и фамилия" defaultValue={user.name ?? ""} disabled />
<Input label="Электронная почта" defaultValue={user.email ?? ""} disabled />
<Input name="department" label="Подразделение" defaultValue={user.department ?? ""} disabled />
<Input name="phone" label="Телефон" defaultValue={user.phone ?? ""} disabled />
<Textarea
name="bio"
className="sm:col-span-2"
label="О себе"
defaultValue={user.bio ?? ""}
disabled
/>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,45 @@
import axios from "axios";
export const API_BASE_URL = import.meta.env.VITE_API_URL ?? "http://localhost:8000/api";
export const tokenStorage = {
get: () => sessionStorage.getItem("accessToken"),
set: (token) => {
if (token) sessionStorage.setItem("accessToken", token);
},
clear: () => sessionStorage.removeItem("accessToken"),
};
const api = axios.create({
baseURL: API_BASE_URL,
timeout: 12_000,
headers: { Accept: "application/json" },
});
api.interceptors.request.use((config) => {
const token = tokenStorage.get();
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
tokenStorage.clear();
window.dispatchEvent(new CustomEvent("auth:unauthorized"));
}
return Promise.reject({
status: error.response?.status ?? 0,
message: error.response?.data?.message ?? "Не удалось выполнить запрос",
fieldErrors: error.response?.data?.errors ?? error.response?.data?.fieldErrors,
});
},
);
export function unwrap(response) {
return response.data?.data ?? response.data;
}
export { api };

View File

@@ -0,0 +1,61 @@
import { api, unwrap } from "./client";
function cleanParams(params = {}) {
return Object.fromEntries(
Object.entries(params).filter(([, value]) => value !== undefined && value !== null && value !== ""),
);
}
export const authApi = {
register: (payload) => api.post("/auth/register", payload).then(unwrap),
login: (payload) => api.post("/auth/login", payload).then(unwrap),
me: () => api.get("/auth/me").then(unwrap),
logout: () => api.post("/auth/logout").then(unwrap),
changePassword: (payload) => api.post("/auth/change-password", payload).then(unwrap),
};
export const contentApi = {
list: (params) => api.get("/content", { params: cleanParams(params) }).then(unwrap),
get: (id) => api.get(`/content/${id}`).then(unwrap),
create: (payload) => api.post("/content", payload).then(unwrap),
update: (id, payload) => api.patch(`/content/${id}`, payload).then(unwrap),
remove: (id) => api.delete(`/content/${id}`).then(unwrap),
};
export const directoriesApi = {
media: () => api.get("/media").then(unwrap),
events: (params) => api.get("/events", { params: cleanParams(params) }).then(unwrap),
categories: () => api.get("/categories").then(unwrap),
tags: () => api.get("/tags").then(unwrap),
speakers: () => api.get("/speakers").then(unwrap),
};
export const searchApi = {
search: (q, params) => api.get("/search", { params: cleanParams({ ...params, q }) }).then(unwrap),
};
export const subscriptionsApi = {
list: () => api.get("/subscriptions").then(unwrap),
create: (payload) => api.post("/subscriptions", payload).then(unwrap),
};
export const notificationsApi = {
list: () => api.get("/notifications").then(unwrap),
markRead: (id) => api.patch(`/notifications/${id}/read`).then(unwrap),
};
export const commentsApi = {
list: (contentId) => api.get(`/comments/${contentId}`).then(unwrap),
create: (contentId, payload) => api.post(`/comments/${contentId}`, payload).then(unwrap),
};
export const analyticsApi = {
summary: () => api.get("/analytics/summary").then(unwrap),
};
export const adminApi = {
dashboard: () => api.get("/admin/dashboard").then(unwrap),
users: (params) => api.get("/admin/users", { params: cleanParams(params) }).then(unwrap),
roles: () => api.get("/admin/roles").then(unwrap),
audit: (params) => api.get("/admin/audit", { params: cleanParams(params) }).then(unwrap),
};

View File

@@ -0,0 +1,33 @@
export function toList(payload) {
if (Array.isArray(payload)) return payload;
if (Array.isArray(payload?.items)) return payload.items;
if (Array.isArray(payload?.results)) return payload.results;
if (Array.isArray(payload?.content)) return payload.content;
if (Array.isArray(payload?.data)) return payload.data;
return [];
}
export function toEntity(payload) {
return payload?.item ?? payload?.content ?? payload?.data ?? payload;
}
export function normalizeContentItem(item) {
if (!item) return item;
return {
...item,
slug: item.slug ?? item.id,
excerpt: item.excerpt ?? item.summary ?? item.description ?? "",
image: item.image ?? item.coverUrl ?? item.cover ?? item.media?.[0]?.url,
publishedAt: item.publishedAt ?? item.createdAt ?? item.date ?? "",
readingTime: item.readingTime ?? item.readingMinutes ?? 1,
views: item.views ?? item.viewCount ?? 0,
tags: item.tags ?? [],
category: item.category?.name ?? item.category ?? "",
author: item.author?.name ?? item.author ?? item.user?.name ?? "",
};
}
export function normalizeContentList(payload) {
return toList(payload).map(normalizeContentItem);
}

View File

@@ -0,0 +1,19 @@
export const queryKeys = {
me: ["auth", "me"],
content: (params = {}) => ["content", params],
contentDetail: (id) => ["content", id],
media: ["media"],
events: (params = {}) => ["events", params],
categories: ["categories"],
tags: ["tags"],
speakers: ["speakers"],
search: (q, params = {}) => ["search", q, params],
subscriptions: ["subscriptions"],
notifications: ["notifications"],
comments: (contentId) => ["comments", contentId],
analyticsSummary: ["analytics", "summary"],
adminDashboard: ["admin", "dashboard"],
adminUsers: (params = {}) => ["admin", "users", params],
adminRoles: ["admin", "roles"],
adminAudit: (params = {}) => ["admin", "audit", params],
};

View File

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

View File

@@ -0,0 +1,25 @@
import { cn } from "../lib/cn";
export function Badge({
children,
tone = "neutral",
className,
}) {
return (
<span
className={cn(
"inline-flex items-center rounded-full px-2.5 py-1 text-xs font-semibold",
{
"bg-paper text-muted": tone === "neutral",
"bg-accent-soft text-accent-dark": tone === "accent",
"bg-success-soft text-success": tone === "success",
"bg-warning-soft text-warning": tone === "warning",
"bg-danger-soft text-danger": tone === "danger",
},
className,
)}
>
{children}
</span>
);
}

View File

@@ -0,0 +1,38 @@
import { LoaderCircle } from "lucide-react";
import { cn } from "../lib/cn";
export function Button({
className,
variant = "primary",
size = "md",
loading,
icon,
children,
disabled,
...props
}) {
return (
<button
className={cn(
"inline-flex items-center justify-center gap-2 rounded-md font-semibold transition duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-55",
{
"bg-primary text-white hover:bg-primary-strong": variant === "primary",
"bg-accent text-white hover:bg-accent-dark": variant === "accent",
"border border-line bg-surface text-ink hover:border-primary hover:text-primary":
variant === "secondary",
"text-ink hover:bg-paper": variant === "ghost",
"bg-danger text-white hover:bg-danger/90": variant === "danger",
"h-9 px-3 text-sm": size === "sm",
"h-11 px-5 text-sm": size === "md",
"h-13 px-6 text-base": size === "lg",
},
className,
)}
disabled={disabled || loading}
{...props}
>
{loading ? <LoaderCircle className="size-4 animate-spin" /> : icon}
{children}
</button>
);
}

View File

@@ -0,0 +1,20 @@
import dstuLogo from "../../../IMG_4963.png";
import { cn } from "../lib/cn";
export function DstuLogo({ className, imageClassName }) {
return (
<span
className={cn(
"grid shrink-0 place-items-center overflow-hidden rounded-md bg-white shadow-sm ring-1 ring-black/10",
className,
)}
aria-hidden="true"
>
<img
src={dstuLogo}
alt=""
className={cn("size-full object-cover object-center", imageClassName)}
/>
</span>
);
}

View File

@@ -0,0 +1,54 @@
import { cn } from "../lib/cn";
const control =
"mt-2 w-full rounded-md border border-line bg-white px-3.5 py-2.5 text-sm text-ink outline-none transition placeholder:text-muted/70 focus:border-primary focus:ring-2 focus:ring-primary/15 aria-invalid:border-danger aria-invalid:ring-danger/15 dark:bg-paper dark:[color-scheme:dark]";
export function Input({
label,
error,
className,
...props
}) {
return (
<label className="block text-sm font-semibold text-ink">
{label}
<input className={cn(control, className)} aria-invalid={!!error} {...props} />
{error && <span className="mt-1.5 block text-xs text-danger">{error}</span>}
</label>
);
}
export function Textarea({
label,
error,
className,
...props
}) {
return (
<label className="block text-sm font-semibold text-ink">
{label}
<textarea
className={cn(control, "min-h-28 resize-y", className)}
aria-invalid={!!error}
{...props}
/>
{error && <span className="mt-1.5 block text-xs text-danger">{error}</span>}
</label>
);
}
export function Select({
label,
className,
children,
...props
}) {
return (
<label className="block text-sm font-semibold text-ink">
{label}
<select className={cn(control, className)} {...props}>
{children}
</select>
</label>
);
}

View File

@@ -0,0 +1,56 @@
import { ArrowUpRight, Clock3, Eye } from "lucide-react";
import { Link } from "react-router-dom";
import { Badge } from "./Badge";
import { ResponsiveImage } from "./ResponsiveImage";
export function MaterialCard({
material,
compact = false,
}) {
return (
<article className="group overflow-hidden rounded-lg border border-line bg-surface transition duration-200 hover:-translate-y-0.5 hover:border-primary/40 hover:shadow-soft">
{!compact && (
<Link to={`/materials/${material.slug}`} viewTransition>
<ResponsiveImage
src={material.image}
alt=""
className="aspect-[16/10] w-full object-cover transition duration-300 group-hover:scale-[1.02]"
/>
</Link>
)}
<div className={compact ? "p-4" : "p-5"}>
<div className="flex items-center justify-between gap-3">
<Badge tone={material.type === "Исследование" ? "accent" : "neutral"}>
{material.type}
</Badge>
<span className="text-xs text-muted">{material.category}</span>
</div>
<h3
className={`mt-4 font-serif leading-tight text-ink ${
compact ? "text-xl" : "text-2xl"
}`}
>
<Link
className="transition hover:text-primary"
to={`/materials/${material.slug}`}
viewTransition
>
{material.title}
</Link>
</h3>
{!compact && <p className="mt-3 line-clamp-3 text-sm leading-6 text-muted">{material.excerpt}</p>}
<div className="mt-5 flex items-center justify-between gap-4 border-t border-line pt-4 text-xs text-muted">
<span className="flex items-center gap-1.5">
<Clock3 className="size-3.5" />
{material.readingTime} мин
</span>
<span className="flex items-center gap-1.5">
<Eye className="size-3.5" />
{material.views.toLocaleString("ru-RU")}
</span>
<ArrowUpRight className="size-4 text-primary transition group-hover:translate-x-0.5 group-hover:-translate-y-0.5" />
</div>
</div>
</article>
);
}

View File

@@ -0,0 +1,49 @@
import { motion, useReducedMotion } from "framer-motion";
import { useEffect } from "react";
export function PageTransition({ children, routeKey, className = "" }) {
const reduceMotion = useReducedMotion();
useEffect(() => {
window.scrollTo({ top: 0, behavior: "instant" });
}, [routeKey]);
return (
<motion.div
key={routeKey}
className={className}
initial={
reduceMotion
? false
: {
opacity: 0,
y: 22,
scale: 0.992,
filter: "blur(0.3125rem)",
}
}
animate={{
opacity: 1,
y: 0,
scale: 1,
filter: "blur(0)",
}}
exit={
reduceMotion
? { opacity: 1 }
: {
opacity: 0,
y: -12,
scale: 0.996,
filter: "blur(0.1875rem)",
}
}
transition={{
duration: reduceMotion ? 0 : 0.4,
ease: [0.2, 0, 0, 1],
}}
>
{children}
</motion.div>
);
}

View File

@@ -0,0 +1,35 @@
export function ResponsiveImage({
src,
alt,
className,
eager = false,
}) {
if (!src) {
return (
<div
role="img"
aria-label={alt}
className={`grid place-items-center bg-paper text-sm text-muted ${className ?? ""}`}
>
Нет изображения
</div>
);
}
const sized = (width) => (src.includes("w=") ? src.replace(/w=\d+/, `w=${width}`) : src);
return (
<img
src={sized(900)}
srcSet={`${sized(480)} 480w, ${sized(900)} 900w, ${sized(1400)} 1400w`}
sizes={eager ? "(max-width: 48rem) 100vw, 60vw" : "auto, (max-width: 48rem) 100vw, 40vw"}
loading={eager ? "eager" : "lazy"}
fetchPriority={eager ? "high" : "auto"}
decoding="async"
width="1400"
height="900"
alt={alt}
className={className}
/>
);
}

View File

@@ -0,0 +1,36 @@
import { AlertTriangle, Inbox, RefreshCw } from "lucide-react";
import { Button } from "./Button";
export function Skeleton({ className = "" }) {
return <div className={`animate-pulse rounded-md bg-line/60 ${className}`} />;
}
export function EmptyState({ title, text }) {
return (
<div className="rounded-lg border border-dashed border-line bg-surface p-10 text-center">
<Inbox className="mx-auto mb-4 size-8 text-muted" />
<h3 className="font-serif text-xl">{title}</h3>
<p className="mx-auto mt-2 max-w-md text-sm text-muted">{text}</p>
</div>
);
}
export function ErrorState({ retry }) {
return (
<div className="rounded-lg border border-danger/25 bg-danger-soft p-8 text-center">
<AlertTriangle className="mx-auto mb-3 size-7 text-danger" />
<h3 className="font-semibold">Не удалось загрузить данные</h3>
<p className="mt-1 text-sm text-muted">Проверьте соединение и попробуйте еще раз.</p>
{retry && (
<Button
className="mt-4"
variant="secondary"
icon={<RefreshCw className="size-4" />}
onClick={retry}
>
Повторить
</Button>
)}
</div>
);
}

View File

@@ -0,0 +1,31 @@
import { Badge } from "./Badge";
const labels = {
draft: "Черновик",
moderation: "На модерации",
pending: "На модерации",
approved: "Одобрен",
published: "Опубликован",
returned: "Возвращен",
archived: "В архиве",
};
const tones = {
draft: "neutral",
moderation: "warning",
pending: "warning",
approved: "success",
published: "success",
returned: "danger",
archived: "neutral",
Черновик: "neutral",
"На модерации": "warning",
Одобрен: "success",
Опубликован: "success",
Возвращен: "danger",
"В архиве": "neutral",
};
export function StatusBadge({ status = "draft" }) {
return <Badge tone={tones[status]}>{labels[status] ?? status}</Badge>;
}

View File

@@ -0,0 +1,54 @@
import { Moon, Sun } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { useEffect, useState } from "react";
function getInitialTheme() {
if (typeof document === "undefined") return false;
return document.documentElement.classList.contains("dark");
}
export function ThemeToggle({ className = "" }) {
const [dark, setDark] = useState(getInitialTheme);
const reduceMotion = useReducedMotion();
useEffect(() => {
document.documentElement.classList.toggle("dark", dark);
localStorage.setItem("theme", dark ? "dark" : "light");
const metaTheme = document.querySelector('meta[name="theme-color"]');
metaTheme?.setAttribute("content", dark ? "#101614" : "#f5f2ea");
}, [dark]);
return (
<motion.button
type="button"
className={`relative grid size-10 place-items-center overflow-hidden rounded-md text-muted transition hover:bg-surface hover:text-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent dark:hover:bg-surface ${className}`}
onClick={() => setDark((value) => !value)}
aria-label={dark ? "Включить светлую тему" : "Включить темную тему"}
title={dark ? "Светлая тема" : "Темная тема"}
whileTap={reduceMotion ? undefined : { scale: 0.92 }}
>
<motion.span
className="absolute inset-1 rounded-full bg-accent-soft"
initial={false}
animate={{
scale: dark ? 1 : 0,
opacity: dark ? 1 : 0,
}}
transition={{ duration: reduceMotion ? 0 : 0.2 }}
/>
<AnimatePresence initial={false} mode="wait">
<motion.span
key={dark ? "sun" : "moon"}
className="relative z-10"
initial={reduceMotion ? false : { opacity: 0, rotate: -35, scale: 0.65 }}
animate={{ opacity: 1, rotate: 0, scale: 1 }}
exit={reduceMotion ? undefined : { opacity: 0, rotate: 35, scale: 0.65 }}
transition={{ duration: reduceMotion ? 0 : 0.18 }}
>
{dark ? <Sun className="size-5 text-accent" /> : <Moon className="size-5" />}
</motion.span>
</AnimatePresence>
</motion.button>
);
}

143
apps/web/src/styles.css Normal file
View File

@@ -0,0 +1,143 @@
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
@theme {
--color-primary: #123c36;
--color-primary-strong: #082b27;
--color-dstu-menu: #2d323e;
--color-dstu-menu-accent: #1ee2e7;
--color-dstu-menu-line: #e67e22;
--color-accent: #d65a3a;
--color-accent-dark: #a84028;
--color-accent-soft: #f6ddd4;
--color-paper: #f5f2ea;
--color-surface: #fffdf8;
--color-ink: #17201e;
--color-muted: #66706d;
--color-line: #d9d8d0;
--color-success: #247a5a;
--color-success-soft: #dceee7;
--color-warning: #a96616;
--color-warning-soft: #f5e8d0;
--color-danger: #b43d3d;
--color-danger-soft: #f6dddd;
--font-sans: Inter, Arial, sans-serif;
--font-serif: Georgia, Cambria, serif;
--radius-md: 0.375rem;
--radius-lg: 0.75rem;
--radius-xl: 1.25rem;
--shadow-soft: 0 1.125rem 3.75rem rgb(18 60 54 / 0.08);
}
:root.dark {
--color-primary: #28685e;
--color-primary-strong: #071f1c;
--color-accent: #ef7858;
--color-accent-dark: #ff987d;
--color-accent-soft: #47251f;
--color-paper: #101614;
--color-surface: #18201e;
--color-ink: #edf2ef;
--color-muted: #a4afab;
--color-line: #34403c;
--color-success: #6fc29f;
--color-success-soft: #19372c;
--color-warning: #e9ad5c;
--color-warning-soft: #3c2c17;
--color-danger: #f08282;
--color-danger-soft: #422222;
--shadow-soft: 0 1.125rem 3.75rem rgb(0 0 0 / 0.28);
color-scheme: dark;
}
:root {
color: var(--color-ink);
background: var(--color-paper);
font-family: var(--font-sans);
font-synthesis: none;
text-rendering: optimizeLegibility;
}
* {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
margin: 0;
min-width: 20rem;
min-height: 100vh;
background: var(--color-paper);
transition:
color 180ms ease,
background-color 180ms ease;
}
body,
header,
main,
footer,
aside,
section,
article,
div,
input,
textarea,
select,
button {
transition-property: color, background-color, border-color;
transition-duration: 180ms;
transition-timing-function: cubic-bezier(0.2, 0, 0, 1);
}
button,
input,
textarea,
select {
font: inherit;
}
a {
color: inherit;
text-decoration: none;
}
.page-shell {
width: min(100% - clamp(2rem, 6vw, 6rem), 80rem);
margin-inline: auto;
}
.page-shell-wide {
width: min(100% - clamp(2rem, 5vw, 5rem), 90rem);
margin-inline: auto;
}
::selection {
color: white;
background: var(--color-accent);
}
@view-transition {
navigation: auto;
}
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 180ms;
animation-timing-function: cubic-bezier(0.2, 0, 0, 1);
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}

View File

@@ -0,0 +1,15 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render } from "@testing-library/react";
export function renderWithProviders(ui) {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
refetchOnWindowFocus: false,
},
},
});
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);
}

View File

@@ -0,0 +1,15 @@
import "@testing-library/jest-dom/vitest";
Object.defineProperty(window, "matchMedia", {
writable: true,
value: (query) => ({
matches: false,
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
}),
});

View File

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

View File

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

View File

@@ -1,40 +0,0 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: 'class',
content: ['./index.html', './src/**/*.{ts,tsx}'],
theme: {
extend: {
colors: {
university: {
50: '#eef7ff',
100: '#d9edff',
200: '#bce0ff',
300: '#8ecaff',
400: '#59a9ff',
500: '#3388f3',
600: '#1d6fd8',
700: '#1558b0',
800: '#11519c',
900: '#123f73'
},
ink: '#071426'
},
fontFamily: {
sans: [
'Golos Text',
'Inter',
'system-ui',
'-apple-system',
'BlinkMacSystemFont',
'Segoe UI',
'sans-serif'
]
},
boxShadow: {
soft: '0 24px 80px rgba(7, 20, 38, 0.12)',
card: '0 14px 40px rgba(17, 81, 156, 0.12)'
}
}
},
plugins: []
};

View File

@@ -1,21 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"],
"references": []
}

16
apps/web/vite.config.js Normal file
View File

@@ -0,0 +1,16 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
port: 5173,
},
test: {
globals: true,
environment: "jsdom",
setupFiles: "./src/test/setup.js",
css: true,
},
});

View File

@@ -1,15 +0,0 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': {
target: process.env.VITE_GATEWAY_URL ?? 'http://localhost:3000',
changeOrigin: true
}
}
}
});

5468
package-lock.json generated

File diff suppressed because it is too large Load Diff