diff --git a/.env.example b/.env.example
index 075c1d5..b91993a 100644
--- a/.env.example
+++ b/.env.example
@@ -17,9 +17,11 @@ FCM_ENABLED=false
FCM_TOPIC=route_alerts
FCM_ANDROID_CHANNEL_ID=route_alerts
FIREBASE_PROJECT_ID=
-# Standard Application Default Credentials path. Store the JSON file outside
-# the repository and leave this empty while FCM_ENABLED=false.
-GOOGLE_APPLICATION_CREDENTIALS=
+# Directory on the Docker host (VM/LXC inside Proxmox), mounted read-only.
+FIREBASE_SECRETS_DIR=/opt/polotsk-transit/secrets
+# Path inside the backend container. For a direct Node.js launch, replace it
+# with the absolute host path to the JSON file.
+GOOGLE_APPLICATION_CREDENTIALS=/run/secrets/firebase-service-account.json
# Bootstrap admin
BOOTSTRAP_ADMIN=true
diff --git a/.gitignore b/.gitignore
index 0624a34..ea7eef0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -38,6 +38,9 @@ redis_data/
# SSL certificates
nginx/ssl/
+# Runtime secrets
+secrets/
+
# Temp files
*.tmp
.cache/
diff --git a/backend/src/services/alertNotificationService.js b/backend/src/services/alertNotificationService.js
index 5b14f8a..b482904 100644
--- a/backend/src/services/alertNotificationService.js
+++ b/backend/src/services/alertNotificationService.js
@@ -1,6 +1,5 @@
const {
DEFAULT_FCM_TOPIC,
- DEFAULT_FCM_ANDROID_CHANNEL_ID,
getFirebaseConfig,
getFirebaseMessaging
} = require('../config/firebase');
@@ -91,14 +90,9 @@ function isAlertActive(alert = {}) {
function buildAlertCreatedMessage(alert, options = {}) {
const alertId = getAlertId(alert);
const topic = options.topic || DEFAULT_FCM_TOPIC;
- const channelId = options.androidChannelId || options.channelId || DEFAULT_FCM_ANDROID_CHANNEL_ID;
return {
topic,
- notification: {
- title: getNotificationTitle(alert),
- body: getNotificationBody(alert)
- },
data: {
event: 'alert_created',
alertId,
@@ -106,14 +100,12 @@ function buildAlertCreatedMessage(alert, options = {}) {
routeId: getRouteId(alert),
routeNumber: getRouteNumber(alert),
startTime: toIsoString(firstDefined(alert.start_time, alert.startTime)),
- endTime: toIsoString(firstDefined(alert.end_time, alert.endTime))
+ endTime: toIsoString(firstDefined(alert.end_time, alert.endTime)),
+ title: getNotificationTitle(alert),
+ body: getNotificationBody(alert)
},
android: {
- priority: 'high',
- notification: {
- channelId,
- tag: `alert_${alertId}`
- }
+ priority: 'high'
}
};
}
@@ -131,14 +123,12 @@ class AlertNotificationService {
this.enabled = options.enabled === undefined ? firebaseConfig.enabled : options.enabled;
this.topic = options.topic || firebaseConfig.topic;
- this.androidChannelId = options.androidChannelId || options.channelId || firebaseConfig.androidChannelId;
this.logger = options.logger || getDefaultLogger();
this.getMessaging = options.getMessaging || options.messagingFactory || (() => (
options.messaging || getFirebaseMessaging({
...firebaseConfig,
enabled: this.enabled,
- topic: this.topic,
- androidChannelId: this.androidChannelId
+ topic: this.topic
})
));
}
@@ -160,10 +150,7 @@ class AlertNotificationService {
throw new Error('Firebase Messaging is unavailable');
}
- const message = buildAlertCreatedMessage(alert, {
- topic: this.topic,
- androidChannelId: this.androidChannelId
- });
+ const message = buildAlertCreatedMessage(alert, { topic: this.topic });
const messageId = await messaging.send(message);
const normalizedMessageId = toDataString(messageId);
diff --git a/backend/test/alertNotificationService.test.js b/backend/test/alertNotificationService.test.js
index ed263b5..30bca0c 100644
--- a/backend/test/alertNotificationService.test.js
+++ b/backend/test/alertNotificationService.test.js
@@ -18,7 +18,7 @@ function createLogger() {
};
}
-test('sends an exact alert-created notification and string-only data payload', async () => {
+test('sends an exact data-only alert-created message with string-only data', async () => {
const messages = [];
const service = new AlertNotificationService({
enabled: true,
@@ -52,10 +52,6 @@ test('sends an exact alert-created notification and string-only data payload', a
assert.equal(messages.length, 1);
assert.deepEqual(messages[0], {
topic: 'route_alerts',
- notification: {
- title: 'Автобус задерживается',
- body: 'Задержка около 15 минут'
- },
data: {
event: 'alert_created',
alertId: '42',
@@ -63,16 +59,19 @@ test('sends an exact alert-created notification and string-only data payload', a
routeId: '7',
routeNumber: '12',
startTime: '2026-07-11T08:30:00.000Z',
- endTime: '2026-07-11T09:30:00.000Z'
+ endTime: '2026-07-11T09:30:00.000Z',
+ title: 'Автобус задерживается',
+ body: 'Задержка около 15 минут'
},
android: {
- priority: 'high',
- notification: {
- channelId: 'route_alerts',
- tag: 'alert_42'
- }
+ priority: 'high'
}
});
+ assert.equal('notification' in messages[0], false);
+ assert.equal('notification' in messages[0].android, false);
+ assert.equal(messages[0].android.priority, 'high');
+ assert.equal(messages[0].data.title, 'Автобус задерживается');
+ assert.equal(messages[0].data.body, 'Задержка около 15 минут');
assert.ok(Object.values(messages[0].data).every((value) => typeof value === 'string'));
});
@@ -92,6 +91,10 @@ test('serializes a null route id and missing optional values as empty strings',
assert.equal(message.data.routeNumber, '');
assert.equal(message.data.startTime, '');
assert.equal(message.data.endTime, '');
+ assert.equal(message.data.title, 'Новость');
+ assert.equal(message.data.body, 'Важная информация');
+ assert.equal('notification' in message, false);
+ assert.equal('notification' in message.android, false);
});
test('does not initialize messaging or send for an inactive alert', async () => {
@@ -207,8 +210,8 @@ test('uses the requested Russian title fallbacks and a route-aware body fallback
route_number: '5'
});
- assert.equal(message.notification.title, expectedTitle);
- assert.equal(message.notification.body, 'Актуальная информация по маршруту №5');
+ assert.equal(message.data.title, expectedTitle);
+ assert.equal(message.data.body, 'Актуальная информация по маршруту №5');
}
const withoutRoute = buildAlertCreatedMessage({
@@ -217,7 +220,7 @@ test('uses the requested Russian title fallbacks and a route-aware body fallback
title: '',
message: ''
});
- assert.equal(withoutRoute.notification.body, 'Актуальная информация о движении транспорта');
+ assert.equal(withoutRoute.data.body, 'Актуальная информация о движении транспорта');
});
test('reads disabled-by-default Firebase configuration and environment overrides', () => {
diff --git a/docker-compose.yml b/docker-compose.yml
index e52ba3b..8054d8c 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -59,7 +59,7 @@ services:
FCM_TOPIC: "${FCM_TOPIC:-route_alerts}"
FCM_ANDROID_CHANNEL_ID: "${FCM_ANDROID_CHANNEL_ID:-route_alerts}"
FIREBASE_PROJECT_ID: "${FIREBASE_PROJECT_ID:-}"
- GOOGLE_APPLICATION_CREDENTIALS: "${GOOGLE_APPLICATION_CREDENTIALS:-}"
+ GOOGLE_APPLICATION_CREDENTIALS: "${GOOGLE_APPLICATION_CREDENTIALS:-/run/secrets/firebase-service-account.json}"
ports:
- "3000:3000"
depends_on:
@@ -73,6 +73,7 @@ services:
volumes:
- ./backend/logs:/app/logs
- avatars_data:/app/public/uploads/avatars
+ - ${FIREBASE_SECRETS_DIR:-./secrets}:/run/secrets:ro
frontend:
build:
diff --git a/docs/fcm-alert-notifications.md b/docs/fcm-alert-notifications.md
index 22b7536..671b795 100644
--- a/docs/fcm-alert-notifications.md
+++ b/docs/fcm-alert-notifications.md
@@ -155,45 +155,48 @@ GOOGLE_APPLICATION_CREDENTIALS=/absolute/private/path/firebase-service-account.j
После этого запустите `npm run dev`. Не копируйте JSON в `backend/`, не
добавляйте его в `.env` в виде текста и не коммитьте файл.
-### Локальный запуск в Docker Compose
+### Запуск в Docker Compose на Proxmox
-Значение `GOOGLE_APPLICATION_CREDENTIALS` внутри контейнера должно указывать на
-путь внутри контейнера, поэтому одного host-пути в корневом `.env` недостаточно.
-Создайте локальный, не коммитящийся файл
-`docker-compose.firebase.override.yml`:
-
-```yaml
-services:
- backend:
- environment:
- FCM_ENABLED: "true"
- FIREBASE_PROJECT_ID: ridebus-development
- GOOGLE_APPLICATION_CREDENTIALS: /run/secrets/firebase-service-account.json
- volumes:
- - /absolute/private/path/firebase-service-account.json:/run/secrets/firebase-service-account.json:ro
-```
-
-Запуск:
+Docker должен работать внутри VM или LXC-контейнера Proxmox. На этом Docker-
+хосте создайте закрытый каталог и положите в него service account JSON:
```bash
-docker compose \
- -f docker-compose.yml \
- -f docker-compose.firebase.override.yml \
- up -d --build backend
+sudo install -d -m 700 /opt/polotsk-transit/secrets
+sudo install -m 600 /private/path/firebase-service-account.json \
+ /opt/polotsk-transit/secrets/firebase-service-account.json
```
-Основной `docker-compose.yml` передаёт все FCM-переменные, но не требует и не
-монтирует credential-файл. Поэтому обычный запуск с `FCM_ENABLED=false`
-остается рабочим без Firebase.
+В корневом `.env` проекта задайте:
+
+```env
+FCM_ENABLED=true
+FCM_TOPIC=route_alerts
+FCM_ANDROID_CHANNEL_ID=route_alerts
+FIREBASE_PROJECT_ID=ridebus-production
+FIREBASE_SECRETS_DIR=/opt/polotsk-transit/secrets
+GOOGLE_APPLICATION_CREDENTIALS=/run/secrets/firebase-service-account.json
+```
+
+Основной `docker-compose.yml` монтирует `FIREBASE_SECRETS_DIR` в backend как
+read-only `/run/secrets`. Запуск отдельного override-файла больше не требуется:
+
+```bash
+docker compose up -d --build backend
+docker compose logs -f backend
+```
+
+При `FCM_ENABLED=false` Firebase не инициализируется, поэтому отсутствие JSON
+не мешает работе приложения.
## Формирование сообщения
-FCM получает одновременно видимый `notification` payload и строковый `data`
-payload. Android priority всегда `high`. Все значения в `data`, включая ID и
-даты, преобразуются в строки; отсутствующие значения передаются как пустые
-строки.
+FCM получает только строковый `data` payload. Верхнеуровневого `notification`
+и `android.notification` нет, поэтому Android-клиент сам создаёт уведомление и
+может добавить локальные действия, включая кнопку «Прочитано». Android
+priority всегда `high`. Все значения в `data`, включая ID, даты, заголовок и
+текст, являются строками; отсутствующие значения передаются как пустые строки.
-`notification.title` берётся из `alert.title`. Если заголовок отсутствует или
+`data.title` берётся из `alert.title`. Если заголовок отсутствует или
состоит только из пробелов, используется fallback по типу:
| `alert_type` | Fallback title |
@@ -203,7 +206,7 @@ payload. Android priority всегда `high`. Все значения в `data`
| `detour` | `Изменение маршрута` |
| `info` | `Информация о маршруте` |
-`notification.body` содержит `alert.message`. Для пустого сообщения
+`data.body` содержит `alert.message`. Для пустого сообщения
используется безопасный текст с номером маршрута, а если маршрут не указан —
общий текст об обновлении движения.
@@ -212,10 +215,6 @@ payload. Android priority всегда `high`. Все значения в `data`
```json
{
"topic": "route_alerts",
- "notification": {
- "title": "Задержка движения",
- "body": "Автобусы задерживаются примерно на 15 минут"
- },
"data": {
"event": "alert_created",
"alertId": "42",
@@ -223,14 +222,12 @@ payload. Android priority всегда `high`. Все значения в `data`
"routeId": "7",
"routeNumber": "5",
"startTime": "2026-07-11T09:00:00.000Z",
- "endTime": "2026-07-11T11:00:00.000Z"
+ "endTime": "2026-07-11T11:00:00.000Z",
+ "title": "Задержка движения",
+ "body": "Автобусы задерживаются примерно на 15 минут"
},
"android": {
- "priority": "high",
- "notification": {
- "channelId": "route_alerts",
- "tag": "alert_42"
- }
+ "priority": "high"
}
}
```
@@ -295,5 +292,5 @@ curl -i -X POST http://localhost:3000/api/v1/alerts \
вернуть `201`, а лог должен содержать `alertId`, Firebase code и причину без
credentials или токенов.
-Поведение Android при foreground/background зависит от клиентской реализации,
-но серверный контракт всегда содержит и `notification`, и `data` payload.
+Поведение Android при foreground/background полностью определяется клиентской
+обработкой data-only сообщения.
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 15b0b84..7f073df 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -1,9 +1,8 @@
-import { useState } from 'react';
-import { BrowserRouter as Router, Routes, Route, Link, useLocation, Navigate } from 'react-router-dom';
-import { Bus, MapPin, Calendar, Settings, Gift, Menu, X, AlertCircle, Users, UserCircle, LogOut } from 'lucide-react';
+import { BrowserRouter as Router, Routes, Route, useLocation, Navigate } from 'react-router-dom';
import { AuthProvider, useAuth } from './contexts/AuthContext';
import { ProtectedRoute } from './components/ProtectedRoute';
-import { Avatar } from './components/AvatarUpload';
+import AppLayout from './components/AppLayout';
+import { PageLoader } from './components/AnimatedComponents';
// Pages
import LoginPage from './pages/LoginPage';
@@ -28,7 +27,9 @@ function App() {
}
function AppContent() {
- const { isAuthenticated } = useAuth();
+ const { isAuthenticated, loading } = useAuth();
+
+ if (loading) return ;
if (!isAuthenticated) {
return (
@@ -40,15 +41,9 @@ function AppContent() {
}
return (
-
+
+
+
);
}
@@ -76,206 +71,4 @@ function AnimatedRoutes() {
);
}
-function Navigation() {
- const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
- const [showUserMenu, setShowUserMenu] = useState(false);
- const { user, logout, isAdmin } = useAuth();
-
- return (
-
- );
-}
-
-function MobileBottomNav() {
- const location = useLocation();
- const { isAdmin } = useAuth();
-
- const navItems = [
- { to: '/', icon: Settings, label: 'Главная' },
- { to: '/routes', icon: Bus, label: 'Маршруты' },
- { to: '/stops', icon: MapPin, label: 'Остановки' },
- { to: '/alerts', icon: AlertCircle, label: 'Уведомл.' },
- isAdmin
- ? { to: '/users', icon: Users, label: 'Пользов.' }
- : { to: '/profile', icon: UserCircle, label: 'Профиль' },
- ];
-
- return (
-
-
- {navItems.map(({ to, icon: Icon, label }) => {
- const isActive = location.pathname === to ||
- (to !== '/' && location.pathname.startsWith(to));
-
- return (
-
-
-
{label}
- {isActive && (
-
- )}
-
- );
- })}
-
-
- );
-}
-
-function NavLink({ to, icon, children }) {
- const location = useLocation();
- const isActive = location.pathname === to ||
- (to !== '/' && location.pathname.startsWith(to));
-
- return (
-
- {icon}
- {children}
-
- );
-}
-
-function MobileNavLink({ to, icon, children, onClick, delay = 0 }) {
- const location = useLocation();
- const isActive = location.pathname === to ||
- (to !== '/' && location.pathname.startsWith(to));
-
- return (
-
- {icon}
- {children}
-
- );
-}
-
-export default App;
\ No newline at end of file
+export default App;
diff --git a/frontend/src/assets/dashboard-background.png b/frontend/src/assets/dashboard-background.png
new file mode 100644
index 0000000..82e6c05
Binary files /dev/null and b/frontend/src/assets/dashboard-background.png differ
diff --git a/frontend/src/assets/logo-with-text.svg b/frontend/src/assets/logo-with-text.svg
new file mode 100644
index 0000000..c457564
--- /dev/null
+++ b/frontend/src/assets/logo-with-text.svg
@@ -0,0 +1,26 @@
+
+
+
diff --git a/frontend/src/components/AppLayout.jsx b/frontend/src/components/AppLayout.jsx
new file mode 100644
index 0000000..81a997e
--- /dev/null
+++ b/frontend/src/components/AppLayout.jsx
@@ -0,0 +1,82 @@
+import { useEffect, useRef, useState } from 'react';
+import { Link, NavLink, useLocation } from 'react-router-dom';
+import { Bus, Menu, X, LayoutDashboard, MapPin, CalendarDays, Gift, Bell, Users, User } from 'lucide-react';
+import { useAuth } from '../contexts/AuthContext';
+import BrandLogo from './BrandLogo';
+import ProfileMenu from './ProfileMenu';
+
+const navigationGroups = [
+ { label: 'Обзор', items: [{ to: '/', label: 'Главная', icon: LayoutDashboard }] },
+ { label: 'Транспорт', items: [
+ { to: '/routes', label: 'Маршруты', icon: Bus },
+ { to: '/stops', label: 'Остановки', icon: MapPin },
+ { to: '/schedules', label: 'Расписания', icon: CalendarDays },
+ { to: '/holidays', label: 'Праздники', icon: Gift },
+ ] },
+ { label: 'Управление', items: [
+ { to: '/alerts', label: 'Уведомления', icon: Bell },
+ { to: '/users', label: 'Пользователи', icon: Users, adminOnly: true },
+ { to: '/profile', label: 'Профиль', icon: User },
+ ] },
+];
+
+export default function AppLayout({ children }) {
+ const { isAdmin } = useAuth();
+ const { pathname } = useLocation();
+ const [menuOpen, setMenuOpen] = useState(false);
+ const menuButton = useRef(null);
+
+ useEffect(() => { setMenuOpen(false); }, [pathname]);
+
+ function closeMenu() {
+ setMenuOpen(false);
+ menuButton.current?.focus();
+ }
+
+ return (
+
+ );
+}
diff --git a/frontend/src/components/BrandLogo.jsx b/frontend/src/components/BrandLogo.jsx
new file mode 100644
index 0000000..af25154
--- /dev/null
+++ b/frontend/src/components/BrandLogo.jsx
@@ -0,0 +1,5 @@
+import logo from '../assets/logo-with-text.svg';
+
+export default function BrandLogo({ className = '' }) {
+ return
;
+}
diff --git a/frontend/src/components/ProfileMenu.jsx b/frontend/src/components/ProfileMenu.jsx
new file mode 100644
index 0000000..6197b4c
--- /dev/null
+++ b/frontend/src/components/ProfileMenu.jsx
@@ -0,0 +1,117 @@
+import { useEffect, useLayoutEffect, useRef, useState } from 'react';
+import { createPortal } from 'react-dom';
+import { Link, useLocation } from 'react-router-dom';
+import { ChevronDown, LogOut, User } from 'lucide-react';
+import { useAuth } from '../contexts/AuthContext';
+import { Avatar } from './AvatarUpload';
+
+export default function ProfileMenu() {
+ const { user, isAdmin, logout } = useAuth();
+ const { pathname } = useLocation();
+ const [open, setOpen] = useState(false);
+ const [position, setPosition] = useState({});
+ const trigger = useRef(null);
+ const menu = useRef(null);
+ const focusLast = useRef(false);
+
+ function close(restoreFocus = false) {
+ setOpen(false);
+ if (restoreFocus) trigger.current?.focus();
+ }
+
+ useEffect(() => { setOpen(false); }, [pathname]);
+
+ useLayoutEffect(() => {
+ if (!open) return;
+ function placeMenu() {
+ const rect = trigger.current.getBoundingClientRect();
+ if (!rect.width) { setOpen(false); return; }
+ const width = Math.min(Math.max(rect.width, 248), window.innerWidth - 32);
+ const height = menu.current?.offsetHeight || 164;
+ setPosition({
+ width,
+ left: Math.max(16, Math.min(rect.left, window.innerWidth - width - 16)),
+ top: rect.bottom + height + 8 < window.innerHeight
+ ? rect.bottom + 8 : Math.max(8, rect.top - height - 8),
+ });
+ }
+ placeMenu();
+ window.addEventListener('resize', placeMenu);
+ window.addEventListener('scroll', placeMenu, true);
+ return () => {
+ window.removeEventListener('resize', placeMenu);
+ window.removeEventListener('scroll', placeMenu, true);
+ };
+ }, [open]);
+
+ useEffect(() => {
+ if (!open) return;
+ const items = menu.current.querySelectorAll('[role="menuitem"]');
+ items[focusLast.current ? items.length - 1 : 0]?.focus();
+ function dismiss(event) {
+ if (!menu.current?.contains(event.target) && !trigger.current?.contains(event.target)) close();
+ }
+ document.addEventListener('pointerdown', dismiss);
+ document.addEventListener('focusin', dismiss);
+ return () => {
+ document.removeEventListener('pointerdown', dismiss);
+ document.removeEventListener('focusin', dismiss);
+ };
+ }, [open]);
+
+ function onMenuKeyDown(event) {
+ const items = [...menu.current.querySelectorAll('[role="menuitem"]')];
+ const current = items.indexOf(document.activeElement);
+ if (event.key === 'Escape') {
+ event.preventDefault();
+ event.stopPropagation();
+ close(true);
+ } else if (['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) {
+ event.preventDefault();
+ const next = event.key === 'Home' ? 0 : event.key === 'End' ? items.length - 1
+ : (current + (event.key === 'ArrowDown' ? 1 : -1) + items.length) % items.length;
+ items[next]?.focus();
+ } else if (event.key === 'Tab') {
+ // Return to the trigger's position in the document tab order before moving on.
+ close(true);
+ }
+ }
+
+ return (
+ <>
+
+ {open && createPortal(
+ , document.body
+ )}
+ >
+ );
+}
diff --git a/frontend/src/components/RouteBadge.jsx b/frontend/src/components/RouteBadge.jsx
new file mode 100644
index 0000000..0e14661
--- /dev/null
+++ b/frontend/src/components/RouteBadge.jsx
@@ -0,0 +1,7 @@
+export default function RouteBadge({ route }) {
+ return (
+
+ {route.route_number}
+
+ );
+}
diff --git a/frontend/src/index.css b/frontend/src/index.css
index e26f6e2..4a85875 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -2,9 +2,80 @@
@tailwind components;
@tailwind utilities;
+:root {
+ --control-radius: 8px;
+ --page-title-size: 38px;
+ --page-title-line-height: 48px;
+}
+@media (max-width: 1199px) {
+ :root { --page-title-size: 30px; --page-title-line-height: 40px; }
+}
+@media (max-width: 767px) {
+ :root { --page-title-size: 28px; --page-title-line-height: 36px; }
+}
+
+/* Dodo IS reference: 264px sidebar, neutral surfaces, 8–12px corners. */
+@layer components {
+ .app-shell { min-height: 100vh; }
+ .app-sidebar {
+ @apply sticky top-0 z-40 flex flex-col;
+ background: #f0f0f0;
+ }
+ .sidebar-brand-row { @apply flex items-center justify-between px-4 py-3; }
+ .sidebar-brand { @apply flex items-center gap-2 text-lg font-bold text-gray-900; }
+ .brand-logo { display: block; width: 168px; height: 34px; object-fit: contain; }
+ .brand-mark { @apply flex h-8 w-8 shrink-0 items-center justify-center rounded-lg; background: #ff6900; }
+ .sidebar-menu-button { @apply flex h-11 w-11 items-center justify-center rounded-lg hover:bg-black/[0.04] lg:hidden; }
+ .sidebar-content { @apply hidden min-h-0 flex-col px-4 pb-4; max-height: calc(100dvh - 68px); overflow-y: auto; }
+ .sidebar-content.is-open { display: flex; }
+ .sidebar-account { @apply flex items-center justify-between gap-3 rounded-xl bg-white px-3 py-2 text-sm; min-height: 56px; }
+ .sidebar-group-title { @apply px-3 pb-2 pt-5 text-xs font-normal uppercase tracking-wide text-gray-500; }
+ .sidebar-link { @apply flex min-h-[44px] items-center gap-3 rounded-lg px-3 py-3 text-sm text-gray-600 transition-colors hover:bg-black/[0.04]; }
+ .sidebar-link.is-active { @apply bg-black/[0.04] font-semibold text-gray-900; }
+ .sidebar-footer { @apply mt-auto pt-8; }
+ .app-main { @apply min-w-0 px-4 py-6 sm:px-6 sm:py-8; }
+ .app-content { @apply mx-auto w-full; max-width: 1206px; }
+ .app-main.app-main-wide { padding: 24px; }
+ .app-main-wide .app-content { max-width: 1608px; }
+ .profile-menu { position: fixed; z-index: 60; padding: 8px; background: #fff; border-radius: 12px; box-shadow: 0 12px 32px #00000026, 0 2px 8px #0000000d; }
+ .profile-menu-item { @apply flex w-full items-center gap-4 px-4 py-4 text-left text-sm text-gray-900 hover:bg-gray-50; border-radius: var(--control-radius); min-height: 56px; }
+ .profile-menu-item svg { flex-shrink: 0; }
+ .profile-menu-divider { margin: 4px 16px 4px 56px; height: 1px; background: #f0f0f0; }
+ .route-badge { display: flex; flex: 0 0 auto; align-items: center; justify-content: center; min-width: 48px; max-width: 96px; min-height: 48px; padding: 8px; border-radius: var(--control-radius); color: #fff; font-size: 16px; font-weight: 600; line-height: 24px; overflow-wrap: anywhere; }
+ .skip-link { @apply fixed left-4 top-4 z-50 -translate-y-24 rounded-lg bg-white px-4 py-3 text-primary-600 focus:translate-y-0; }
+ .segmented-control { @apply flex w-fit max-w-full flex-wrap gap-1 rounded-lg bg-black/[0.04] p-1; }
+ .segmented-option { @apply min-h-[44px] rounded-md px-3 py-2 text-sm text-gray-600 transition-colors hover:text-gray-900; }
+ .segmented-option[aria-pressed="true"] { @apply bg-white font-medium text-gray-900 shadow-sm; }
+ .stat-card { @apply flex h-full flex-col; }
+ .stat-card-icon { @apply flex h-10 w-10 items-center justify-center rounded-lg border border-gray-200 text-gray-600; }
+ .stat-card-link { @apply mt-auto flex items-center justify-between border-t border-gray-100 pt-3 text-sm text-primary-600; }
+ .dashboard-intro { @apply flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between; }
+ .login-surface { @apply flex min-h-screen items-center justify-center p-4; background: #f5f5f5; }
+ .login-card { @apply rounded-xl bg-white p-6 sm:p-8; }
+ @media (max-width: 767px) {
+ .app-main.app-main-wide { padding: 24px 16px; }
+ }
+ @media (min-width: 1024px) {
+ .app-sidebar { position: fixed; inset: 0 auto 0 0; width: 264px; padding: 16px; }
+ .sidebar-brand-row { padding: 8px 8px 24px; }
+ .sidebar-content { display: flex; flex: 1; max-height: none; padding: 0; }
+ .app-main { margin-left: 264px; padding: 56px 40px; }
+ }
+ @media (min-width: 1536px) {
+ .app-main { padding-inline: 80px; }
+ }
+}
+
@layer base {
+ button { border-radius: var(--control-radius); }
+ :where(a, button, input, select, textarea):focus-visible {
+ outline: 2px solid #0958d9;
+ outline-offset: 3px;
+ }
+ button:disabled { cursor: not-allowed; }
body {
- @apply bg-gray-50 text-gray-900;
+ @apply text-gray-900;
+ background: #f5f5f5;
-webkit-tap-highlight-color: transparent;
}
@@ -22,18 +93,18 @@
@layer components {
.btn-primary {
- @apply bg-primary-600 text-white px-4 py-2.5 rounded-lg font-medium;
+ @apply bg-primary-500 text-white px-4 py-2.5 rounded-lg font-medium;
@apply transition-all duration-200 ease-out;
- @apply hover:bg-primary-700 hover:shadow-md hover:-translate-y-0.5;
- @apply active:scale-95 active:translate-y-0;
+ @apply hover:bg-primary-700;
+ @apply active:scale-[0.98];
@apply text-sm sm:text-base;
}
.btn-secondary {
- @apply bg-gray-200 text-gray-700 px-4 py-2.5 rounded-lg font-medium;
+ @apply bg-black/[0.04] text-gray-900 px-4 py-2.5 rounded-lg font-medium;
@apply transition-all duration-200 ease-out;
- @apply hover:bg-gray-300 hover:shadow-sm hover:-translate-y-0.5;
- @apply active:scale-95 active:translate-y-0;
+ @apply hover:bg-black/[0.08];
+ @apply active:scale-[0.98];
@apply text-sm sm:text-base;
}
@@ -41,17 +112,19 @@
@apply bg-red-600 text-white px-4 py-2.5 rounded-lg font-medium;
@apply transition-all duration-200 ease-out;
@apply hover:bg-red-700 hover:shadow-md hover:-translate-y-0.5;
- @apply active:scale-95 active:translate-y-0;
+ @apply active:scale-[0.98];
@apply text-sm sm:text-base;
}
+ .btn-primary, .btn-secondary, .btn-danger, .input { border-radius: var(--control-radius); }
+
.card {
- @apply bg-white rounded-lg shadow-sm border border-gray-200 p-4 sm:p-6;
+ @apply bg-white rounded-xl border border-transparent p-4 sm:p-6;
@apply transition-all duration-300 ease-out;
}
.card-hover {
- @apply hover:shadow-lg hover:-translate-y-1 hover:border-gray-300;
+ @apply hover:border-gray-200;
}
.input {
@@ -72,7 +145,11 @@
/* Page titles */
.page-title {
- @apply text-2xl sm:text-3xl font-bold text-gray-900;
+ @apply font-semibold text-gray-900;
+ font-size: var(--page-title-size);
+ line-height: var(--page-title-line-height);
+ letter-spacing: -0.6px;
+ overflow-wrap: anywhere;
}
/* Modal styles with animations */
@@ -258,7 +335,7 @@
}
.quick-action-icon {
- @apply w-10 h-10 rounded-full flex items-center justify-center mb-1;
+ @apply w-10 h-10 rounded-lg flex items-center justify-center mb-1;
@apply transition-transform duration-200;
}
diff --git a/frontend/src/pages/Dashboard.css b/frontend/src/pages/Dashboard.css
new file mode 100644
index 0000000..c14eee5
--- /dev/null
+++ b/frontend/src/pages/Dashboard.css
@@ -0,0 +1,97 @@
+/* Main content of Figma 5:4392; shared application navigation stays in AppLayout. */
+.app-main.app-main-dashboard { padding: 24px; }
+.app-main-dashboard .app-content { max-width: 1608px; }
+.dashboard-page { display: flex; flex-direction: column; gap: 24px; color: rgba(0, 0, 0, .88); }
+.dashboard-heading { display: flex; align-items: center; justify-content: space-between; gap: 24px; margin-bottom: 12px; }
+.dashboard-date { color: #818181; }
+.dashboard-refresh { display: flex; align-items: center; justify-content: center; flex-shrink: 0; gap: 8px; min-height: 48px; font-size: 14px; font-weight: 400; }
+.dashboard-summary { position: relative; overflow: hidden; isolation: isolate; border-radius: 16px; background: #2082c4; padding: 20px 24px 24px; }
+.dashboard-summary-art { position: absolute; z-index: -1; width: 67.16%; height: 275%; max-width: none; object-fit: fill; left: 16.42%; top: -87.4%; pointer-events: none; }
+.dashboard-summary-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 28px; color: white; }
+.dashboard-summary-heading h2 { font-size: 20px; font-weight: 600; line-height: 28px; }
+.dashboard-summary-heading > span { font-size: 12px; color: #fff; background: #2082c4; padding: 2px 6px; border-radius: 4px; }
+.dashboard-summary-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 20px; }
+.dashboard-summary-card { background: white; border-radius: 12px; padding: 16px 20px; min-width: 0; min-height: 180px; }
+.dashboard-summary-card-heading { display: flex; justify-content: space-between; align-items: center; gap: 16px; padding-bottom: 16px; }
+.dashboard-summary-card-heading h3, .dashboard-muted { color: #737373; font-size: 12px; font-weight: 400; line-height: 18px; }
+.dashboard-summary-card-heading p { font-size: 20px; line-height: 28px; font-variant-numeric: tabular-nums; }
+.dashboard-summary-icon { display: flex; flex-shrink: 0; align-items: center; justify-content: center; width: 48px; height: 48px; border-radius: 12px; background: rgba(0, 0, 0, .06); }
+.dashboard-summary-card-body { border-top: 1px solid #f0f0f0; padding-top: 14px; font-size: 13px; line-height: 20px; }
+.dashboard-compact-metrics > div { display: flex; justify-content: space-between; gap: 12px; }
+.dashboard-compact-metrics > div + div { margin-top: 4px; }
+.dashboard-inline-link { display: inline-flex; align-items: center; margin-top: 4px; color: #0958d9; }
+.dashboard-panel-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 24px; }
+.dashboard-panel.card { display: flex; flex-direction: column; min-width: 0; padding: 0; min-height: 345px; overflow: hidden; }
+.dashboard-panel h2, .dashboard-network h2 { padding: 24px 24px 12px; font-size: 20px; font-weight: 600; line-height: 28px; }
+.dashboard-panel-body { padding: 24px; flex: 1; display: flex; flex-direction: column; justify-content: center; }
+.dashboard-panel-footer { display: flex; align-items: center; min-height: 64px; padding: 16px 24px; border-top: 1px solid #f0f0f0; font-size: 14px; color: #0958d9; }
+.dashboard-panel-footer:hover { background: #fafafa; }
+.dashboard-total { display: flex; flex-direction: column; }
+.dashboard-total strong { font-size: 30px; font-weight: 600; line-height: 40px; font-variant-numeric: tabular-nums; }
+.dashboard-metric-row { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; margin-top: 16px; }
+.dashboard-metric-row dd { font-size: 16px; line-height: 24px; }
+.dashboard-detail-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 16px; padding: 9px 16px; border: 1px solid #ededed; border-radius: 8px; font-size: 14px; }
+.dashboard-detail-row svg { flex-shrink: 0; }
+.dashboard-detail-row:hover { background: #fafafa; }
+.dashboard-empty { flex: 1; display: flex; flex-direction: column; justify-content: center; align-items: center; text-align: center; gap: 20px; padding: 16px; min-height: 168px; font-size: 14px; color: #737373; }
+.dashboard-empty svg { color: #bfbfbf; flex-shrink: 0; }
+.dashboard-network { display: grid; grid-template-columns: minmax(220px, 25%) minmax(0, 1fr); background: #fff; border-radius: 12px; min-height: 304px; }
+.dashboard-network-summary { border-right: 1px solid #f0f0f0; }
+.dashboard-network-summary dl { padding: 8px 24px 24px; display: grid; gap: 14px; }
+.dashboard-network-summary dd { font-size: 16px; line-height: 24px; }
+.dashboard-chart { position: relative; min-width: 0; padding: 32px 24px 20px; display: flex; flex-direction: column; }
+.dashboard-chart-plot { height: 200px; display: flex; align-items: stretch; position: relative; }
+.dashboard-chart-guides { display: flex; flex-direction: column; justify-content: space-between; position: absolute; inset: 0 0 34px; }
+.dashboard-chart-guides span { border-top: 1px solid #f0f0f0; width: 100%; }
+.dashboard-chart-column { position: relative; flex: 1; min-width: 0; display: flex; flex-direction: column; justify-content: flex-end; }
+.dashboard-chart-bars { display: flex; align-items: flex-end; justify-content: center; gap: 8px; height: calc(100% - 34px); }
+.dashboard-chart-bar { position: relative; width: min(28%, 36px); background: #1677ff; border-radius: 5px 5px 0 0; }
+.dashboard-chart-bar span { position: absolute; left: 50%; bottom: 100%; transform: translateX(-50%); font-size: 12px; line-height: 20px; }
+.dashboard-chart-label { font-size: 12px; text-align: center; line-height: 16px; min-height: 34px; padding-top: 10px; overflow-wrap: anywhere; }
+.dashboard-chart-legend { display: flex; flex-wrap: wrap; gap: 12px 24px; margin-top: 18px; font-size: 12px; color: #737373; }
+.dashboard-chart-legend span { display: inline-flex; align-items: center; gap: 8px; }
+.dashboard-chart-legend i { width: 8px; height: 8px; border-radius: 50%; background: #1677ff; }
+.dashboard-chart-bar.is-secondary, .dashboard-chart-legend .is-secondary { background: #faad14; }
+.dashboard-route-list, .dashboard-holiday-list { display: flex; flex-direction: column; gap: 12px; }
+.dashboard-route-row, .dashboard-holiday-row { display: flex; align-items: center; gap: 12px; font-size: 14px; padding: 4px 0; }
+.dashboard-route-row:hover { color: #0958d9; }
+.dashboard-route-number { flex-shrink: 0; display: flex; align-items: center; justify-content: center; min-width: 40px; max-width: 96px; min-height: 40px; padding: 4px 6px; border-radius: 8px; color: #fff; font-weight: 600; overflow-wrap: anywhere; }
+.dashboard-calendar-date { flex-shrink: 0; width: 48px; min-height: 52px; display: flex; flex-direction: column; align-items: center; justify-content: center; border-radius: 8px; background: #f5f5f5; }
+.dashboard-calendar-date strong { font-size: 20px; font-weight: 600; }
+.dashboard-calendar-date > span { font-size: 12px; color: #737373; }
+.dashboard-actions { display: flex; flex-wrap: wrap; gap: 12px; }
+.dashboard-action { display: flex; align-items: center; justify-content: center; gap: 8px; min-height: 44px; padding: 10px 16px; background: #fff; border: 1px solid #d9d9d9; border-radius: 8px; font-size: 14px; }
+.dashboard-action:hover { border-color: #1677ff; color: #0958d9; }
+.dashboard-notice { display: flex; align-items: center; gap: 12px; padding: 14px 16px; border-radius: 8px; font-size: 14px; }
+.dashboard-notice svg { flex-shrink: 0; }
+.dashboard-notice-error { background: #fff1f0; color: #a8071a; border: 1px solid #ffccc7; }
+.dashboard-notice button { text-decoration: underline; margin-left: auto; min-height: 44px; }
+@media (max-width: 1199px) {
+ .dashboard-summary-grid { grid-template-columns: 1fr; gap: 12px; }
+ .dashboard-summary-card { min-height: 0; }
+ .dashboard-summary-art { width: 110%; left: 0; height: 100%; top: 0; object-fit: cover; }
+ .dashboard-network { grid-template-columns: 1fr; }
+ .dashboard-network-summary { border-right: 0; border-bottom: 1px solid #f0f0f0; }
+ .dashboard-network-summary dl { grid-template-columns: repeat(3, minmax(0, 1fr)); }
+}
+@media (max-width: 767px) {
+ .app-main.app-main-dashboard { padding: 24px 16px; }
+ .dashboard-heading { flex-direction: column; align-items: flex-start; gap: 16px; margin-bottom: 0; }
+ .dashboard-date { display: block; }
+ .dashboard-refresh { align-self: flex-start; }
+ .dashboard-summary { padding: 16px; }
+ .dashboard-summary-heading { align-items: flex-start; flex-direction: column; gap: 4px; margin-bottom: 20px; }
+ .dashboard-summary-heading > span { padding-left: 0; }
+ .dashboard-summary-card { padding: 16px; }
+ .dashboard-panel-grid { grid-template-columns: 1fr; }
+ .dashboard-panel h2, .dashboard-network h2 { padding: 20px 20px 8px; }
+ .dashboard-panel-body { padding: 20px; }
+ .dashboard-panel-footer { padding-inline: 20px; }
+ .dashboard-metric-row { gap: 12px; }
+ .dashboard-network-summary dl { padding-inline: 20px; gap: 12px; }
+ .dashboard-chart { padding-inline: 16px; }
+ .dashboard-chart-bars { gap: 4px; }
+ .dashboard-notice { flex-wrap: wrap; }
+ .dashboard-notice > span { flex: 1; min-width: 160px; }
+ .dashboard-actions { flex-direction: column; }
+}
diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx
index 1ed7371..1038990 100644
--- a/frontend/src/pages/Dashboard.jsx
+++ b/frontend/src/pages/Dashboard.jsx
@@ -1,554 +1,226 @@
-import { useState, useEffect } from 'react';
+import { useCallback, useEffect, useRef, useState } from 'react';
import { Link } from 'react-router-dom';
-import {
- Bus, MapPin, Calendar, Activity, Clock, TrendingUp,
- ArrowRight, AlertCircle, Gift, RefreshCw, ChevronRight,
- Zap, BarChart3, PieChart
-} from 'lucide-react';
-import { routesApi, stopsApi, syncApi, holidaysApi, schedulesApi, alertsApi } from '../services/api';
-import { PageLoader, PulsingDot } from '../components/AnimatedComponents';
+import { Bus, MapPin, Calendar, Activity, AlertCircle, Gift, RefreshCw, ChevronRight, Info } from 'lucide-react';
+import { routesApi, stopsApi, syncApi, holidaysApi, alertsApi } from '../services/api';
+import { PageLoader } from '../components/AnimatedComponents';
import { AlertList } from '../components/AlertComponents';
+import dashboardBackground from '../assets/dashboard-background.png';
+import './Dashboard.css';
+
+const TIME_ZONE = 'Europe/Minsk';
+const numberFormat = new Intl.NumberFormat('ru-RU');
+const routeTypes = [
+ { key: 'bus', label: 'Автобусы' },
+ { key: 'minibus', label: 'Маршрутки' },
+ { key: 'trolleybus', label: 'Троллейбусы' },
+ { key: 'tram', label: 'Трамваи' },
+];
+const dataSources = [
+ ['routes', () => routesApi.getAll()],
+ ['stops', () => stopsApi.getAll()],
+ ['sync', () => syncApi.getStatus()],
+ ['holidays', () => holidaysApi.getUpcoming(3)],
+ ['alerts', () => alertsApi.getActive()],
+];
+const formatCount = value => value == null ? '—' : numberFormat.format(value);
+function formatDate(value, options) {
+ if (!value) return 'Нет данных';
+ const date = new Date(value);
+ return Number.isNaN(date.getTime()) ? 'Нет данных' : date.toLocaleString('ru-RU', { timeZone: TIME_ZONE, ...options });
+}
export default function Dashboard() {
- const [stats, setStats] = useState({
- routes: 0,
- stops: 0,
- schedules: 0,
- lastSync: null
- });
+ const [data, setData] = useState({ routes: null, stops: null, sync: null, holidays: null, alerts: null });
+ const [failed, setFailed] = useState([]);
const [loading, setLoading] = useState(true);
- const [routes, setRoutes] = useState([]);
- const [upcomingHolidays, setUpcomingHolidays] = useState([]);
- const [activeAlerts, setActiveAlerts] = useState([]);
- const [refreshing, setRefreshing] = useState(false);
+ const [loaded, setLoaded] = useState(false);
+ const [updatedAt, setUpdatedAt] = useState(null);
+ const [today, setToday] = useState(() => new Date());
+ const requestId = useRef(0);
+
+ const loadData = useCallback(async () => {
+ const id = ++requestId.current;
+ setLoading(true);
+ const results = await Promise.allSettled(dataSources.map(([, fetch]) => fetch()));
+ if (id !== requestId.current) return;
+ const errors = [];
+ const received = {};
+ results.forEach((result, index) => {
+ const key = dataSources[index][0];
+ if (result.status === 'fulfilled') received[key] = result.value.data;
+ else errors.push(key);
+ });
+ // Preserve the last successful snapshot if a refresh fails.
+ setData(previous => ({ ...previous, ...received }));
+ setFailed(errors);
+ if (!errors.length) setUpdatedAt(new Date());
+ setLoaded(true);
+ setLoading(false);
+ }, []);
useEffect(() => {
loadData();
- }, []);
+ const timer = setInterval(() => setToday(new Date()), 60_000);
+ return () => { requestId.current += 1; clearInterval(timer); };
+ }, [loadData]);
- const loadData = async () => {
- try {
- const [routesRes, stopsRes, syncRes, holidaysRes, alertsRes] = await Promise.all([
- routesApi.getAll(),
- stopsApi.getAll(),
- syncApi.getStatus(),
- holidaysApi.getUpcoming(3).catch(() => ({ data: [] })),
- alertsApi.getActive().catch(() => ({ data: [] }))
- ]);
-
- setRoutes(routesRes.data);
- setUpcomingHolidays(holidaysRes.data);
- setActiveAlerts(alertsRes.data);
-
- setStats({
- routes: routesRes.data.length,
- stops: stopsRes.data.length,
- schedules: syncRes.data.database.schedules_count,
- lastSync: syncRes.data.database.last_change
- });
- } catch (error) {
- console.error('Error loading stats:', error);
- } finally {
- setLoading(false);
- }
- };
-
- const handleRefresh = async () => {
- setRefreshing(true);
- await loadData();
- setTimeout(() => setRefreshing(false), 500);
- };
-
- if (loading) {
- return ;
- }
-
- // Calculate schedule coverage
- const routesWithSchedules = routes.filter(r => r.schedules_count > 0).length;
- const scheduleCoverage = routes.length > 0
- ? Math.round((routesWithSchedules / routes.length) * 100)
- : 0;
-
- // Calculate total trips today (estimated)
- const estimatedTripsToday = stats.schedules * 15; // Примерная оценка
+ const routes = data.routes || [];
+ const stops = data.stops || [];
+ const alerts = data.alerts || [];
+ const holidays = data.holidays || [];
+ const withStops = routes.filter(route => Number(route.stops_count) > 0).length;
+ const assignedStops = stops.filter(stop => Number(stop.routes_count) > 0).length;
+ const multipleRoutes = stops.filter(stop => Number(stop.routes_count) > 1).length;
+ const ready = data.sync?.status === 'ready' && failed.length === 0;
+ const dayLabel = today.toLocaleDateString('ru-RU', { timeZone: TIME_ZONE, weekday: 'short', day: 'numeric', month: 'long' });
return (
-
- {/* Header */}
-
-
-
Dashboard
-
- Обзор системы общественного транспорта Полоцка
-
-
-