feat: Implement dark mode support across the application
- Added ThemeContext to manage theme state and toggle functionality. - Updated components to support dark mode styles, including header, dashboard, and home page. - Enhanced CSS for smooth transitions between light and dark themes. - Modified authentication context to handle async login operations. - Improved user experience by preserving theme preference in local storage. - Refactored login and register pages to handle OAuth tokens and errors more gracefully.
This commit is contained in:
98
.github/copilot-instructions.md
vendored
98
.github/copilot-instructions.md
vendored
@@ -1,72 +1,38 @@
|
|||||||
# Copilot Instructions for Ospabhost 8.1
|
# Copilot Instructions for Ospabhost
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
- **Monorepo**: Backend (Express + TypeScript + Prisma) and Frontend (React + Vite + TypeScript).
|
||||||
|
- **Backend Entry**: `ospabhost/backend/src/index.ts` - Express server with CORS, rate limiting, WebSocket, and module routes (`/api/*`).
|
||||||
|
- **Modules**: Organized in `ospabhost/backend/src/modules/*` (auth, storage, ticket, blog, etc.), each exporting routes and services.
|
||||||
|
- **Database**: MySQL via Prisma (`ospabhost/backend/prisma/schema.prisma`), models like User, StorageBucket, Ticket with string statuses (`pending`, `approved`, `active`).
|
||||||
|
- **Frontend Dashboard**: `ospabhost/frontend/src/pages/dashboard/mainpage.tsx` - dynamic sidebar tabs based on user roles (`operator`, `isAdmin`).
|
||||||
|
|
||||||
## Архитектура и основные компоненты
|
## Key Patterns & Conventions
|
||||||
- **Монорепозиторий**: две части — `backend` (Express, TypeScript, Prisma) и `frontend` (React, Vite, TypeScript).
|
- **API Routes**: All backend routes prefixed with `/api/`, authenticated via JWT in localStorage.
|
||||||
- **Backend**:
|
- **Status Fields**: Use string enums (e.g., Check: `pending`/`approved`/`rejected`; Ticket: `open`/`in_progress`/`resolved`).
|
||||||
- Точка входа: `backend/src/index.ts` (Express, маршруты `/api/*`, CORS, логирование, WebSocket).
|
- **MinIO Integration**: S3-compatible storage via MinIO SDK, configs from `.env`, buckets per user with quotas.
|
||||||
- Модули: `backend/src/modules/*` — домены (auth, admin, ticket, check, blog, notification, user, session, qr-auth, storage, payment, account, sitemap), каждый экспортирует маршруты и сервисы.
|
- **Localization**: ru/en support via `useTranslation` hook, URLs like `/en/dashboard`.
|
||||||
- Интеграция с MinIO: для S3-совместимого хранилища, параметры из `.env`.
|
- **WebSocket**: Real-time updates via `/ws`, initialized in `ospabhost/backend/src/websocket/server.ts`.
|
||||||
- ORM: Prisma, схема — `backend/prisma/schema.prisma`, миграции и seed-скрипты — в `backend/prisma/`.
|
- **Security**: Rate limiting (1000 req/15min global, 10 req/15min auth), CORS from `PUBLIC_APP_ORIGIN`, helmet middleware.
|
||||||
- Статические файлы чеков: `backend/uploads/checks` (доступны по `/uploads/checks`).
|
- **Static Files**: Check uploads in `ospabhost/backend/uploads/checks`, served at `/uploads/checks`.
|
||||||
- **Frontend**:
|
- **Notifications**: Email/push via `web-push`, templates in `ospabhost/backend/src/modules/notification/email.service.ts`.
|
||||||
- SPA на React + Vite, точка входа: `frontend/src/main.tsx`.
|
|
||||||
- Страницы: `frontend/src/pages/*`, компоненты: `frontend/src/components/*`.
|
|
||||||
- Авторизация: `frontend/src/context/authcontext.tsx` (контекст, хуки).
|
|
||||||
- Дашборд: `frontend/src/pages/dashboard/mainpage.tsx` — реализует сайдбар, вкладки, загрузку данных пользователя, обработку токена, обновление данных через кастомное событие `userDataUpdate`.
|
|
||||||
- Локализация: поддержка ru/en через `useTranslation` и `LocaleProvider`.
|
|
||||||
|
|
||||||
## Ключевые паттерны и конвенции
|
## Development Workflows
|
||||||
- **API**: все маршруты backend — с префиксом `/api/`.
|
- **Backend**: `npm run dev` (ts-node-dev hot-reload), `npm run build` (TypeScript), PM2 for production (`npm run pm2:start`).
|
||||||
- **Модули backend**: каждый домен — отдельная папка, экспортирует маршруты и сервисы.
|
- **Frontend**: `npm run dev` (Vite), `npm run build`, `npm run preview`.
|
||||||
- **Работа с MinIO**: все операции S3 через MinIO SDK, параметры берутся из `.env`.
|
- **Database**: Prisma migrations in `ospabhost/backend/prisma/migrations/`, seed scripts for plans/promocodes.
|
||||||
- **Статусные поля**: для Check, Ticket, Post, StorageBucket — строковые статусы (`pending`, `approved`, `open`, `published`, `active` и др.).
|
- **OAuth**: Google/GitHub/VK/Yandex via Passport.js, configs in `ospabhost/backend/src/modules/auth/passport.config.ts`.
|
||||||
- **Пароли**: генерируются через `generateSecurePassword` (для консольных учётных данных S3).
|
- **Blog**: Rich text via Quill, statuses `draft`/`published`/`archived`.
|
||||||
- **Описание тарифа**: для StoragePlan — цена за GB, трафик, операции.
|
|
||||||
- **Frontend**: авторизация через контекст, проверка токена, автоматический logout при ошибке 401.
|
|
||||||
- **Дашборд**: вкладки и права оператора/админа определяются по полям `operator` и `isAdmin` в userData, обновление данных через событие `userDataUpdate`.
|
|
||||||
- **Уведомления**: email и push через web-push, шаблоны в `notification/email.service.ts`.
|
|
||||||
- **QR-аутентификация**: временные коды с TTL 60 сек, статусы `pending`, `confirmed`, `expired`.
|
|
||||||
|
|
||||||
## Сборка, запуск и workflow
|
## Integration Points
|
||||||
- **Backend**:
|
- **Frontend ↔ Backend**: Axios API client, JWT auth, WebSocket for real-time.
|
||||||
- `npm run dev` — запуск с hot-reload (ts-node-dev).
|
- **MinIO**: All S3 ops through `storage.service.ts`, console credentials generated weekly.
|
||||||
- `npm run build` — компиляция TypeScript.
|
- **Push Notifications**: Web Push API, subscriptions in PushSubscription model.
|
||||||
- `npm start` — запуск собранного кода.
|
- **QR Auth**: Temporary codes (60s TTL), statuses `pending`/`confirmed`/`expired`.
|
||||||
- PM2: `npm run pm2:start`, `pm2:restart`, etc. для production.
|
|
||||||
- **Frontend**:
|
|
||||||
- `npm run dev` — запуск Vite dev server.
|
|
||||||
- `npm run build` — сборка TypeScript + Vite.
|
|
||||||
- `npm run preview` — предпросмотр production-сборки.
|
|
||||||
- `npm run lint` — проверка ESLint.
|
|
||||||
|
|
||||||
## Интеграции и взаимодействие
|
## Examples
|
||||||
- **Frontend ↔ Backend**: через REST API (`/api/*`), авторизация через JWT-токен в localStorage, WebSocket для real-time обновлений.
|
- Add new module: Create `ospabhost/backend/src/modules/newmodule/` with `routes.ts` and `service.ts`, import in `index.ts`.
|
||||||
- **Backend ↔ MinIO**: для S3 хранилища, параметры из `.env`.
|
- Update user data: Dispatch `userDataUpdate` event to refresh dashboard.
|
||||||
- **OAuth**: поддержка Google, GitHub, VK, Yandex через Passport.js.
|
- Storage bucket: Create via `StorageBucket` model, link to `StoragePlan` for pricing.
|
||||||
- **Push-уведомления**: через web-push API, подписки в PushSubscription.
|
|
||||||
- **Prisma**: миграции и seed-скрипты — в `backend/prisma/`.
|
|
||||||
|
|
||||||
## Внешние зависимости
|
Reference: `ospabhost/backend/src/index.ts`, `ospabhost/backend/prisma/schema.prisma`, `ospabhost/frontend/src/pages/dashboard/mainpage.tsx`.
|
||||||
- **Backend**: express, prisma, axios, bcrypt, jsonwebtoken, multer, dotenv, minio, web-push, passport, ws.
|
|
||||||
- **Frontend**: react, react-dom, react-router-dom, tailwindcss, axios, quill, recharts, xterm.
|
|
||||||
|
|
||||||
## Примеры ключевых файлов
|
|
||||||
- `backend/src/index.ts` — точка входа, маршрутизация, WebSocket.
|
|
||||||
- `backend/src/modules/storage/storage.service.ts` — интеграция с MinIO.
|
|
||||||
- `backend/prisma/schema.prisma` — схема данных (User, StorageBucket, Ticket, etc.).
|
|
||||||
- `frontend/src/pages/dashboard/mainpage.tsx` — дашборд, обработка токена, сайдбар, вкладки.
|
|
||||||
- `frontend/src/context/authcontext.tsx` — авторизация, JWT, logout.
|
|
||||||
|
|
||||||
## Особенности и conventions
|
|
||||||
- **CORS**: разрешены origins из `.env` (PUBLIC_APP_ORIGIN, etc.).
|
|
||||||
- **Логирование**: каждый запрос логируется с датой и методом через `logger`.
|
|
||||||
- **Статические файлы**: чеки доступны по `/uploads/checks`.
|
|
||||||
- **Пароли для S3 консоли**: генерируются еженедельно, хэшируются.
|
|
||||||
- **Frontend**: сайдбар и вкладки строятся динамически, права по userData, локализация через `useTranslation`.
|
|
||||||
- **Блог**: Rich Text через Quill, статусы `draft`, `published`, `archived`.
|
|
||||||
- **Тикеты**: автоназначение операторам, внутренние комментарии.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
_Обновляйте этот файл при изменении архитектуры, workflow или паттернов. Для уточнения разделов — дайте обратную связь!_
|
|
||||||
@@ -45,8 +45,10 @@ GITHUB_CLIENT_SECRET=623db1b4285637d328689857f3fc8ae19d84b7f1
|
|||||||
|
|
||||||
YANDEX_CLIENT_ID=d8a889ea467f4d699d1854ac7a4f9b48
|
YANDEX_CLIENT_ID=d8a889ea467f4d699d1854ac7a4f9b48
|
||||||
YANDEX_CLIENT_SECRET=e599f43f50274344b3bd9a007692c36b
|
YANDEX_CLIENT_SECRET=e599f43f50274344b3bd9a007692c36b
|
||||||
VK_CLIENT_ID=your_vk_client_id_here
|
|
||||||
|
VK_CLIENT_ID=54255963
|
||||||
VK_CLIENT_SECRET=your_vk_client_secret_here
|
VK_CLIENT_SECRET=your_vk_client_secret_here
|
||||||
|
|
||||||
# OAuth Callback URL
|
# OAuth Callback URL
|
||||||
OAUTH_CALLBACK_URL=https://api.ospab.host/api/auth
|
OAUTH_CALLBACK_URL=https://api.ospab.host/api/auth
|
||||||
|
|
||||||
|
|||||||
@@ -51,17 +51,4 @@ router.get(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// VKontakte OAuth
|
|
||||||
router.get('/vkontakte', passport.authenticate('vkontakte', { scope: ['email'] }));
|
|
||||||
|
|
||||||
router.get(
|
|
||||||
'/vkontakte/callback',
|
|
||||||
passport.authenticate('vkontakte', { session: false, failureRedirect: `${FRONTEND_URL}/login?error=auth_failed` }),
|
|
||||||
(req: Request, res: Response) => {
|
|
||||||
const user = req.user as AuthenticatedUser;
|
|
||||||
const token = jwt.sign({ id: user.id }, JWT_SECRET, { expiresIn: '24h' });
|
|
||||||
res.redirect(`${FRONTEND_URL}/login?token=${token}`);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import passport from 'passport';
|
|||||||
import { Strategy as GoogleStrategy } from 'passport-google-oauth20';
|
import { Strategy as GoogleStrategy } from 'passport-google-oauth20';
|
||||||
import { Strategy as GitHubStrategy } from 'passport-github';
|
import { Strategy as GitHubStrategy } from 'passport-github';
|
||||||
import { Strategy as YandexStrategy } from 'passport-yandex';
|
import { Strategy as YandexStrategy } from 'passport-yandex';
|
||||||
import { Strategy as VKontakteStrategy } from 'passport-vkontakte';
|
|
||||||
import { prisma } from '../../prisma/client';
|
import { prisma } from '../../prisma/client';
|
||||||
const JWT_SECRET = process.env.JWT_SECRET || 'your_super_secret_key';
|
const JWT_SECRET = process.env.JWT_SECRET || 'your_super_secret_key';
|
||||||
const OAUTH_CALLBACK_URL = process.env.OAUTH_CALLBACK_URL || 'https://api.ospab.host/api/auth';
|
const OAUTH_CALLBACK_URL = process.env.OAUTH_CALLBACK_URL || 'https://api.ospab.host/api/auth';
|
||||||
@@ -123,43 +122,6 @@ if (process.env.YANDEX_CLIENT_ID && process.env.YANDEX_CLIENT_SECRET) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// VKontakte OAuth
|
|
||||||
if (process.env.VK_CLIENT_ID && process.env.VK_CLIENT_SECRET) {
|
|
||||||
passport.use(
|
|
||||||
new VKontakteStrategy(
|
|
||||||
{
|
|
||||||
clientID: process.env.VK_CLIENT_ID,
|
|
||||||
clientSecret: process.env.VK_CLIENT_SECRET,
|
|
||||||
callbackURL: `${OAUTH_CALLBACK_URL}/vkontakte/callback`,
|
|
||||||
profileFields: ['email', 'city', 'bdate']
|
|
||||||
},
|
|
||||||
async (accessToken: string, refreshToken: string, params: any, profile: any, done: any) => {
|
|
||||||
try {
|
|
||||||
// VK возвращает email в params, а не в profile
|
|
||||||
const email = params.email || profile.emails?.[0]?.value;
|
|
||||||
|
|
||||||
if (!email) {
|
|
||||||
throw new Error('Email не предоставлен ВКонтакте. Убедитесь, что вы разрешили доступ к email.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const oauthProfile: OAuthProfile = {
|
|
||||||
id: profile.id,
|
|
||||||
displayName: profile.displayName || `${profile.name?.givenName || ''} ${profile.name?.familyName || ''}`.trim(),
|
|
||||||
emails: [{ value: email }],
|
|
||||||
provider: 'vkontakte'
|
|
||||||
};
|
|
||||||
|
|
||||||
const user = await findOrCreateUser(oauthProfile);
|
|
||||||
return done(null, user);
|
|
||||||
} catch (error) {
|
|
||||||
return done(error as Error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
passport.serializeUser((user: any, done) => {
|
passport.serializeUser((user: any, done) => {
|
||||||
done(null, user.id);
|
done(null, user.id);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export const uploadImage = async (req: Request, res: Response) => {
|
|||||||
return res.status(200).json({
|
return res.status(200).json({
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
url: `https://ospab.host:5000${imageUrl}`,
|
url: `https://api.ospab.host${imageUrl}`,
|
||||||
filename: req.file.filename
|
filename: req.file.filename
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,6 @@ VITE_CARD_NUMBER="2204 2402 3323 3354"
|
|||||||
|
|
||||||
VITE_TURNSTILE_SITE_KEY=0x4AAAAAAB7306voAK0Pjx8O
|
VITE_TURNSTILE_SITE_KEY=0x4AAAAAAB7306voAK0Pjx8O
|
||||||
|
|
||||||
# API URLs (с портом 5000)
|
# API URLs
|
||||||
VITE_API_URL=https://ospab.host:5000
|
VITE_API_URL=https://api.ospab.host
|
||||||
VITE_SOCKET_URL=wss://ospab.host:5000
|
VITE_SOCKET_URL=wss://api.ospab.host
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { FaGithub } from 'react-icons/fa';
|
import { FaGithub } from 'react-icons/fa';
|
||||||
|
import { FaSun, FaMoon } from 'react-icons/fa';
|
||||||
import logo from '../assets/logo.svg';
|
import logo from '../assets/logo.svg';
|
||||||
import { useTranslation } from '../i18n';
|
import { useTranslation } from '../i18n';
|
||||||
import { useLocalePath } from '../middleware';
|
import { useLocalePath } from '../middleware';
|
||||||
|
import { useTheme } from '../context/ThemeContext';
|
||||||
|
|
||||||
const Footer = () => {
|
const Footer = () => {
|
||||||
const currentYear = new Date().getFullYear();
|
const currentYear = new Date().getFullYear();
|
||||||
const { t, locale, setLocale } = useTranslation();
|
const { t, locale, setLocale } = useTranslation();
|
||||||
const localePath = useLocalePath();
|
const localePath = useLocalePath();
|
||||||
|
const { theme, toggleTheme } = useTheme();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer className="bg-gray-800 text-white py-12">
|
<footer className="bg-gray-800 dark:bg-gray-950 text-white py-12 transition-colors duration-200">
|
||||||
<div className="container mx-auto px-6">
|
<div className="container mx-auto px-6">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 text-center md:text-left">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 text-center md:text-left">
|
||||||
{/* About Section */}
|
{/* About Section */}
|
||||||
@@ -57,33 +60,44 @@ const Footer = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-8 pt-8 border-t border-gray-700 flex flex-col md:flex-row justify-between items-center gap-4">
|
<div className="mt-8 pt-8 border-t border-gray-700 dark:border-gray-800 flex flex-col md:flex-row justify-between items-center gap-4">
|
||||||
<p className="text-sm text-gray-400">
|
<p className="text-sm text-gray-400">
|
||||||
© {currentYear} ospab.host. {locale === 'en' ? 'All rights reserved.' : 'Все права защищены.'}
|
© {currentYear} ospab.host. {locale === 'en' ? 'All rights reserved.' : 'Все права защищены.'}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Language Switcher */}
|
<div className="flex items-center gap-4">
|
||||||
<div className="flex items-center gap-2">
|
{/* Theme Switcher */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setLocale('ru')}
|
onClick={toggleTheme}
|
||||||
className={`px-3 py-1 rounded-full text-sm transition-colors ${
|
className="p-2 rounded-full text-gray-400 hover:text-white hover:bg-gray-700 dark:hover:bg-gray-800 transition-colors"
|
||||||
locale === 'ru'
|
aria-label={theme === 'light' ? 'Включить тёмную тему' : 'Включить светлую тему'}
|
||||||
? 'bg-ospab-primary text-white'
|
|
||||||
: 'text-gray-400 hover:text-white hover:bg-gray-700'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
RU
|
{theme === 'light' ? <FaMoon className="text-xl" /> : <FaSun className="text-xl" />}
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setLocale('en')}
|
|
||||||
className={`px-3 py-1 rounded-full text-sm transition-colors ${
|
|
||||||
locale === 'en'
|
|
||||||
? 'bg-ospab-primary text-white'
|
|
||||||
: 'text-gray-400 hover:text-white hover:bg-gray-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
EN
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* Language Switcher */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setLocale('ru')}
|
||||||
|
className={`px-3 py-1 rounded-full text-sm transition-colors ${
|
||||||
|
locale === 'ru'
|
||||||
|
? 'bg-ospab-primary text-white'
|
||||||
|
: 'text-gray-400 hover:text-white hover:bg-gray-700 dark:hover:bg-gray-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
RU
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setLocale('en')}
|
||||||
|
className={`px-3 py-1 rounded-full text-sm transition-colors ${
|
||||||
|
locale === 'en'
|
||||||
|
? 'bg-ospab-primary text-white'
|
||||||
|
: 'text-gray-400 hover:text-white hover:bg-gray-700 dark:hover:bg-gray-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
EN
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,24 +18,24 @@ const Header = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="static bg-white shadow-md">
|
<header className="static bg-white dark:bg-gray-900 shadow-md transition-colors duration-200">
|
||||||
<div className="container mx-auto px-4 py-4">
|
<div className="container mx-auto px-4 py-4">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Link to={localePath('/')} className="flex items-center">
|
<Link to={localePath('/')} className="flex items-center">
|
||||||
<img src={logo} alt="Logo" className="h-10 lg:h-14 w-auto mr-2" width="56" height="56" />
|
<img src={logo} alt="Logo" className="h-12 lg:h-16 w-auto mr-2 rounded-lg bg-white dark:bg-white p-1 shadow-md" width="64" height="64" />
|
||||||
<span className="font-mono text-xl lg:text-2xl text-gray-800 font-bold">ospab.host</span>
|
<span className="font-mono text-xl lg:text-2xl text-gray-800 dark:text-white font-bold">ospab.host</span>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Desktop Menu */}
|
{/* Desktop Menu */}
|
||||||
<div className="hidden md:flex items-center space-x-4">
|
<div className="hidden md:flex items-center space-x-4">
|
||||||
<Link to={localePath('/tariffs')} className="text-gray-600 hover:text-ospab-primary transition-colors">{t('nav.tariffs')}</Link>
|
<Link to={localePath('/tariffs')} className="text-gray-600 dark:text-gray-300 hover:text-ospab-primary dark:hover:text-ospab-primary transition-colors">{t('nav.tariffs')}</Link>
|
||||||
<Link to={localePath('/blog')} className="text-gray-600 hover:text-ospab-primary transition-colors">{t('nav.blog')}</Link>
|
<Link to={localePath('/blog')} className="text-gray-600 dark:text-gray-300 hover:text-ospab-primary dark:hover:text-ospab-primary transition-colors">{t('nav.blog')}</Link>
|
||||||
<Link to={localePath('/about')} className="text-gray-600 hover:text-ospab-primary transition-colors">{t('nav.about')}</Link>
|
<Link to={localePath('/about')} className="text-gray-600 dark:text-gray-300 hover:text-ospab-primary dark:hover:text-ospab-primary transition-colors">{t('nav.about')}</Link>
|
||||||
{isLoggedIn ? (
|
{isLoggedIn ? (
|
||||||
<>
|
<>
|
||||||
<Link to={localePath('/dashboard')} className="text-gray-600 hover:text-ospab-primary transition-colors">{t('nav.dashboard')}</Link>
|
<Link to={localePath('/dashboard')} className="text-gray-600 dark:text-gray-300 hover:text-ospab-primary dark:hover:text-ospab-primary transition-colors">{t('nav.dashboard')}</Link>
|
||||||
<NotificationBell />
|
<NotificationBell />
|
||||||
<button
|
<button
|
||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
@@ -46,7 +46,7 @@ const Header = () => {
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Link to={localePath('/login')} className="text-gray-600 hover:text-ospab-primary transition-colors">{t('nav.login')}</Link>
|
<Link to={localePath('/login')} className="text-gray-600 dark:text-gray-300 hover:text-ospab-primary dark:hover:text-ospab-primary transition-colors">{t('nav.login')}</Link>
|
||||||
<Link
|
<Link
|
||||||
to={localePath('/register')}
|
to={localePath('/register')}
|
||||||
className="px-4 py-2 rounded-full text-white font-bold transition-colors transform hover:scale-105 bg-ospab-primary hover:bg-ospab-accent"
|
className="px-4 py-2 rounded-full text-white font-bold transition-colors transform hover:scale-105 bg-ospab-primary hover:bg-ospab-accent"
|
||||||
@@ -60,7 +60,7 @@ const Header = () => {
|
|||||||
{/* Mobile Menu Button */}
|
{/* Mobile Menu Button */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
||||||
className="md:hidden p-2 text-gray-800"
|
className="md:hidden p-2 text-gray-800 dark:text-white"
|
||||||
aria-label={isMobileMenuOpen ? t('common.closeMenu') : t('common.openMenu')}
|
aria-label={isMobileMenuOpen ? t('common.closeMenu') : t('common.openMenu')}
|
||||||
aria-expanded={isMobileMenuOpen}
|
aria-expanded={isMobileMenuOpen}
|
||||||
>
|
>
|
||||||
@@ -76,24 +76,24 @@ const Header = () => {
|
|||||||
|
|
||||||
{/* Mobile Menu */}
|
{/* Mobile Menu */}
|
||||||
{isMobileMenuOpen && (
|
{isMobileMenuOpen && (
|
||||||
<div className="md:hidden mt-4 pb-4 space-y-2 border-t border-gray-200 pt-4">
|
<div className="md:hidden mt-4 pb-4 space-y-2 border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||||
<Link
|
<Link
|
||||||
to={localePath('/tariffs')}
|
to={localePath('/tariffs')}
|
||||||
className="block py-2 text-gray-600 hover:text-ospab-primary transition-colors"
|
className="block py-2 text-gray-600 dark:text-gray-300 hover:text-ospab-primary dark:hover:text-ospab-primary transition-colors"
|
||||||
onClick={() => setIsMobileMenuOpen(false)}
|
onClick={() => setIsMobileMenuOpen(false)}
|
||||||
>
|
>
|
||||||
{t('nav.tariffs')}
|
{t('nav.tariffs')}
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
to={localePath('/blog')}
|
to={localePath('/blog')}
|
||||||
className="block py-2 text-gray-600 hover:text-ospab-primary transition-colors"
|
className="block py-2 text-gray-600 dark:text-gray-300 hover:text-ospab-primary dark:hover:text-ospab-primary transition-colors"
|
||||||
onClick={() => setIsMobileMenuOpen(false)}
|
onClick={() => setIsMobileMenuOpen(false)}
|
||||||
>
|
>
|
||||||
{t('nav.blog')}
|
{t('nav.blog')}
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
to={localePath('/about')}
|
to={localePath('/about')}
|
||||||
className="block py-2 text-gray-600 hover:text-ospab-primary transition-colors"
|
className="block py-2 text-gray-600 dark:text-gray-300 hover:text-ospab-primary dark:hover:text-ospab-primary transition-colors"
|
||||||
onClick={() => setIsMobileMenuOpen(false)}
|
onClick={() => setIsMobileMenuOpen(false)}
|
||||||
>
|
>
|
||||||
{t('nav.about')}
|
{t('nav.about')}
|
||||||
|
|||||||
106
ospabhost/frontend/src/context/ThemeContext.tsx
Normal file
106
ospabhost/frontend/src/context/ThemeContext.tsx
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import { createContext, useState, useEffect, useContext } from 'react';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
type Theme = 'light' | 'dark';
|
||||||
|
|
||||||
|
interface ThemeContextType {
|
||||||
|
theme: Theme;
|
||||||
|
toggleTheme: () => void;
|
||||||
|
setTheme: (theme: Theme) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ThemeContext = createContext<ThemeContextType>({
|
||||||
|
theme: 'light',
|
||||||
|
toggleTheme: () => {},
|
||||||
|
setTheme: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
interface ThemeProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ThemeProvider = ({ children }: ThemeProviderProps) => {
|
||||||
|
const [theme, setThemeState] = useState<Theme>('light');
|
||||||
|
const [isInitialized, setIsInitialized] = useState(false);
|
||||||
|
|
||||||
|
// Инициализация темы при загрузке
|
||||||
|
useEffect(() => {
|
||||||
|
// Проверяем сохраненную тему
|
||||||
|
const savedTheme = localStorage.getItem('theme') as Theme | null;
|
||||||
|
|
||||||
|
if (savedTheme) {
|
||||||
|
// Если есть сохраненная тема, используем её
|
||||||
|
setThemeState(savedTheme);
|
||||||
|
applyTheme(savedTheme);
|
||||||
|
} else {
|
||||||
|
// Иначе проверяем системные настройки браузера
|
||||||
|
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
|
const systemTheme = prefersDark ? 'dark' : 'light';
|
||||||
|
setThemeState(systemTheme);
|
||||||
|
applyTheme(systemTheme);
|
||||||
|
localStorage.setItem('theme', systemTheme);
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsInitialized(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Слушаем изменения системной темы
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isInitialized) return;
|
||||||
|
|
||||||
|
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
|
|
||||||
|
const handleChange = (e: MediaQueryListEvent) => {
|
||||||
|
// Обновляем только если нет сохраненной темы или пользователь не менял тему вручную
|
||||||
|
const savedTheme = localStorage.getItem('theme');
|
||||||
|
if (!savedTheme) {
|
||||||
|
const newTheme = e.matches ? 'dark' : 'light';
|
||||||
|
setThemeState(newTheme);
|
||||||
|
applyTheme(newTheme);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
mediaQuery.addEventListener('change', handleChange);
|
||||||
|
return () => mediaQuery.removeEventListener('change', handleChange);
|
||||||
|
}, [isInitialized]);
|
||||||
|
|
||||||
|
const applyTheme = (newTheme: Theme) => {
|
||||||
|
const root = document.documentElement;
|
||||||
|
const body = document.body;
|
||||||
|
|
||||||
|
if (newTheme === 'dark') {
|
||||||
|
root.classList.add('dark');
|
||||||
|
body.classList.add('dark');
|
||||||
|
} else {
|
||||||
|
root.classList.remove('dark');
|
||||||
|
body.classList.remove('dark');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setTheme = (newTheme: Theme) => {
|
||||||
|
setThemeState(newTheme);
|
||||||
|
localStorage.setItem('theme', newTheme);
|
||||||
|
applyTheme(newTheme);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleTheme = () => {
|
||||||
|
const newTheme = theme === 'light' ? 'dark' : 'light';
|
||||||
|
setTheme(newTheme);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeContext.Provider value={{ theme, toggleTheme, setTheme }}>
|
||||||
|
{children}
|
||||||
|
</ThemeContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useTheme = () => {
|
||||||
|
const context = useContext(ThemeContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useTheme must be used within ThemeProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ThemeContext;
|
||||||
@@ -10,7 +10,7 @@ interface AuthContextType {
|
|||||||
isInitialized: boolean;
|
isInitialized: boolean;
|
||||||
userData: UserData | null;
|
userData: UserData | null;
|
||||||
setUserData: (data: UserData | null) => void;
|
setUserData: (data: UserData | null) => void;
|
||||||
login: (token: string) => void;
|
login: (token: string) => Promise<void>;
|
||||||
logout: () => void;
|
logout: () => void;
|
||||||
refreshUser: () => Promise<void>;
|
refreshUser: () => Promise<void>;
|
||||||
}
|
}
|
||||||
@@ -21,7 +21,7 @@ const AuthContext = createContext<AuthContextType>({
|
|||||||
isInitialized: false,
|
isInitialized: false,
|
||||||
userData: null,
|
userData: null,
|
||||||
setUserData: () => {},
|
setUserData: () => {},
|
||||||
login: () => {},
|
login: async () => {},
|
||||||
logout: () => {},
|
logout: () => {},
|
||||||
refreshUser: async () => {},
|
refreshUser: async () => {},
|
||||||
});
|
});
|
||||||
@@ -55,11 +55,18 @@ export const AuthProvider = ({ children }: AuthProviderProps) => {
|
|||||||
tickets: fetchedUser.tickets ?? [],
|
tickets: fetchedUser.tickets ?? [],
|
||||||
});
|
});
|
||||||
setIsLoggedIn(true);
|
setIsLoggedIn(true);
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
console.warn('[Auth] bootstrap failed, clearing token', error);
|
console.warn('[Auth] bootstrap failed', error);
|
||||||
localStorage.removeItem('access_token');
|
// Очищаем токен только при ошибках авторизации (401), но не при сетевых ошибках
|
||||||
setIsLoggedIn(false);
|
if (error?.response?.status === 401) {
|
||||||
setUserData(null);
|
console.warn('[Auth] Invalid token, clearing');
|
||||||
|
localStorage.removeItem('access_token');
|
||||||
|
setIsLoggedIn(false);
|
||||||
|
setUserData(null);
|
||||||
|
} else {
|
||||||
|
// При других ошибках (сеть, сервер) сохраняем токен и пробуем позже
|
||||||
|
console.warn('[Auth] Network/server error, keeping token for retry');
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsInitialized(true);
|
setIsInitialized(true);
|
||||||
}
|
}
|
||||||
@@ -87,10 +94,10 @@ export const AuthProvider = ({ children }: AuthProviderProps) => {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const login = (token: string) => {
|
const login = async (token: string) => {
|
||||||
localStorage.setItem('access_token', token);
|
localStorage.setItem('access_token', token);
|
||||||
// После установки токена немедленно валидируем пользователя
|
// После установки токена немедленно валидируем пользователя
|
||||||
bootstrapSession();
|
await bootstrapSession();
|
||||||
};
|
};
|
||||||
|
|
||||||
const logout = () => {
|
const logout = () => {
|
||||||
|
|||||||
@@ -1,7 +1,30 @@
|
|||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
/* ===== ТЕМНАЯ ТЕМА ===== */
|
||||||
|
|
||||||
|
/* Глобальные стили для темной темы */
|
||||||
|
@layer base {
|
||||||
|
html {
|
||||||
|
@apply transition-colors duration-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
@apply bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Плавная анимация переключения темы */
|
||||||
|
* {
|
||||||
|
transition-property: background-color, border-color, color;
|
||||||
|
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
transition-duration: 200ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Исключения для элементов, которые не должны анимироваться */
|
||||||
|
*:where(.animate-*, [class*="animate-"], [class*="transition-"]) {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
/* ===== МОБИЛЬНАЯ АДАПТАЦИЯ ===== */
|
/* ===== МОБИЛЬНАЯ АДАПТАЦИЯ ===== */
|
||||||
|
|
||||||
/* Улучшенный перенос слов для мобильных устройств */
|
/* Улучшенный перенос слов для мобильных устройств */
|
||||||
|
|||||||
@@ -2,9 +2,12 @@ import { StrictMode } from 'react'
|
|||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
|
import { ThemeProvider } from './context/ThemeContext'
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<ThemeProvider>
|
||||||
|
<App />
|
||||||
|
</ThemeProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
// frontend/src/pages/dashboard/mainpage.tsx
|
// frontend/src/pages/dashboard/mainpage.tsx
|
||||||
import { useState, useEffect, useContext } from 'react';
|
import { useState, useEffect, useContext } from 'react';
|
||||||
import { Routes, Route, Link, useNavigate, useLocation } from 'react-router-dom';
|
import { Routes, Route, Link, useLocation } from 'react-router-dom';
|
||||||
import { isAxiosError } from 'axios';
|
|
||||||
import apiClient from '../../utils/apiClient';
|
|
||||||
import AuthContext from '../../context/authcontext';
|
import AuthContext from '../../context/authcontext';
|
||||||
import { useTranslation } from '../../i18n';
|
import { useTranslation } from '../../i18n';
|
||||||
|
import { useTheme } from '../../context/ThemeContext';
|
||||||
|
import { FaSun, FaMoon } from 'react-icons/fa';
|
||||||
|
|
||||||
// Импортируем компоненты для вкладок
|
// Импортируем компоненты для вкладок
|
||||||
import Summary from './summary';
|
import Summary from './summary';
|
||||||
@@ -27,10 +27,10 @@ import NewTicketPage from './tickets/new';
|
|||||||
const Dashboard = () => {
|
const Dashboard = () => {
|
||||||
const [loading, setLoading] = useState<boolean>(true);
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState<boolean>(false);
|
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState<boolean>(false);
|
||||||
const navigate = useNavigate();
|
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { userData, setUserData, logout, refreshUser, isInitialized } = useContext(AuthContext);
|
const { userData, refreshUser, isInitialized } = useContext(AuthContext);
|
||||||
const { locale, setLocale } = useTranslation();
|
const { locale, setLocale } = useTranslation();
|
||||||
|
const { theme, toggleTheme } = useTheme();
|
||||||
const isEn = locale === 'en';
|
const isEn = locale === 'en';
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState('summary');
|
const [activeTab, setActiveTab] = useState('summary');
|
||||||
@@ -43,40 +43,16 @@ const Dashboard = () => {
|
|||||||
}, [location.pathname]);
|
}, [location.pathname]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchData = async () => {
|
// Просто снимаем флаг загрузки, так как AuthContext уже загрузил данные пользователя
|
||||||
try {
|
if (isInitialized) {
|
||||||
const token = localStorage.getItem('access_token');
|
setLoading(false);
|
||||||
if (!token) {
|
}
|
||||||
console.log('Токен не найден, перенаправляем на логин');
|
}, [isInitialized]);
|
||||||
logout();
|
|
||||||
navigate('/login');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await refreshUser();
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Ошибка загрузки данных:', err);
|
|
||||||
if (isAxiosError(err) && err.response?.status === 401) {
|
|
||||||
logout();
|
|
||||||
navigate('/login');
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
fetchData();
|
|
||||||
}, [logout, navigate]);
|
|
||||||
|
|
||||||
// Функция для обновления userData из API
|
// Функция для обновления userData из API
|
||||||
const updateUserData = async () => {
|
const updateUserData = async () => {
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('access_token');
|
await refreshUser();
|
||||||
if (!token) return;
|
|
||||||
const userRes = await apiClient.get('/api/auth/me');
|
|
||||||
setUserData({
|
|
||||||
user: userRes.data.user,
|
|
||||||
balance: userRes.data.user.balance ?? 0,
|
|
||||||
tickets: userRes.data.user.tickets ?? [],
|
|
||||||
});
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Ошибка обновления userData:', err);
|
console.error('Ошибка обновления userData:', err);
|
||||||
}
|
}
|
||||||
@@ -126,13 +102,13 @@ const Dashboard = () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen bg-gray-50">
|
<div className="flex min-h-screen bg-gray-50 dark:bg-gray-900 transition-colors duration-200">
|
||||||
{/* Mobile Menu Button */}
|
{/* Mobile Menu Button */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
||||||
className="lg:hidden fixed top-4 left-4 z-50 p-2 bg-white rounded-lg shadow-lg"
|
className="lg:hidden fixed top-4 left-4 z-50 p-2 bg-white dark:bg-gray-800 rounded-lg shadow-lg"
|
||||||
>
|
>
|
||||||
<svg className="w-6 h-6 text-gray-800" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-6 h-6 text-gray-800 dark:text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
{isMobileMenuOpen ? (
|
{isMobileMenuOpen ? (
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
) : (
|
) : (
|
||||||
@@ -144,27 +120,27 @@ const Dashboard = () => {
|
|||||||
{/* Sidebar - теперь адаптивный */}
|
{/* Sidebar - теперь адаптивный */}
|
||||||
<div className={`
|
<div className={`
|
||||||
fixed lg:static inset-y-0 left-0 z-40
|
fixed lg:static inset-y-0 left-0 z-40
|
||||||
w-64 bg-white shadow-xl flex flex-col
|
w-64 bg-white dark:bg-gray-800 shadow-xl flex flex-col
|
||||||
transform transition-transform duration-300 ease-in-out
|
transform transition-transform duration-300 ease-in-out
|
||||||
${isMobileMenuOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}
|
${isMobileMenuOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}
|
||||||
`}>
|
`}>
|
||||||
<div className="p-6 border-b border-gray-200">
|
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
<h2 className="text-xl font-bold text-gray-800 break-words">
|
<h2 className="text-xl font-bold text-gray-800 dark:text-white break-words">
|
||||||
{isEn ? 'Hello' : 'Привет'}, {userData?.user?.username || (isEn ? 'Guest' : 'Гость')}!
|
{isEn ? 'Hello' : 'Привет'}, {userData?.user?.username || (isEn ? 'Guest' : 'Гость')}!
|
||||||
</h2>
|
</h2>
|
||||||
<div className="flex gap-2 mt-2">
|
<div className="flex gap-2 mt-2">
|
||||||
{isOperator && (
|
{isOperator && (
|
||||||
<span className="inline-block px-2 py-1 bg-blue-100 text-blue-800 text-xs font-semibold rounded-full">
|
<span className="inline-block px-2 py-1 bg-blue-100 dark:bg-blue-900/50 text-blue-800 dark:text-blue-300 text-xs font-semibold rounded-full">
|
||||||
{isEn ? 'Operator' : 'Оператор'}
|
{isEn ? 'Operator' : 'Оператор'}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<span className="inline-block px-2 py-1 bg-red-100 text-red-800 text-xs font-semibold rounded-full">
|
<span className="inline-block px-2 py-1 bg-red-100 dark:bg-red-900/50 text-red-800 dark:text-red-300 text-xs font-semibold rounded-full">
|
||||||
{isEn ? 'Super Admin' : 'Супер Админ'}
|
{isEn ? 'Super Admin' : 'Супер Админ'}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 text-sm text-gray-600">
|
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||||
{isEn ? 'Balance' : 'Баланс'}: <span className="font-semibold text-ospab-primary">₽{userData?.balance ?? 0}</span>
|
{isEn ? 'Balance' : 'Баланс'}: <span className="font-semibold text-ospab-primary">₽{userData?.balance ?? 0}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -176,7 +152,7 @@ const Dashboard = () => {
|
|||||||
to={tab.to}
|
to={tab.to}
|
||||||
onClick={() => setIsMobileMenuOpen(false)}
|
onClick={() => setIsMobileMenuOpen(false)}
|
||||||
className={`flex items-center py-3 px-4 rounded-xl font-semibold transition-colors duration-200 ${
|
className={`flex items-center py-3 px-4 rounded-xl font-semibold transition-colors duration-200 ${
|
||||||
activeTab === tab.key ? 'bg-ospab-primary text-white shadow-lg' : 'text-gray-600 hover:bg-gray-100'
|
activeTab === tab.key ? 'bg-ospab-primary text-white shadow-lg' : 'text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{tab.label}
|
{tab.label}
|
||||||
@@ -184,8 +160,8 @@ const Dashboard = () => {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{isOperator && (
|
{isOperator && (
|
||||||
<div className="mt-8 pt-6 border-t border-gray-200">
|
<div className="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700">
|
||||||
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3 px-4">
|
<p className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3 px-4">
|
||||||
{isEn ? 'Admin Panel' : 'Админ панель'}
|
{isEn ? 'Admin Panel' : 'Админ панель'}
|
||||||
</p>
|
</p>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
@@ -195,7 +171,7 @@ const Dashboard = () => {
|
|||||||
to={tab.to}
|
to={tab.to}
|
||||||
onClick={() => setIsMobileMenuOpen(false)}
|
onClick={() => setIsMobileMenuOpen(false)}
|
||||||
className={`flex items-center py-3 px-4 rounded-xl font-semibold transition-colors duration-200 ${
|
className={`flex items-center py-3 px-4 rounded-xl font-semibold transition-colors duration-200 ${
|
||||||
activeTab === tab.key ? 'bg-ospab-primary text-white shadow-lg' : 'text-gray-600 hover:bg-gray-100'
|
activeTab === tab.key ? 'bg-ospab-primary text-white shadow-lg' : 'text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{tab.label}
|
{tab.label}
|
||||||
@@ -205,8 +181,8 @@ const Dashboard = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<div className="mt-8 pt-6 border-t border-gray-200">
|
<div className="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700">
|
||||||
<p className="text-xs font-semibold text-red-500 uppercase tracking-wider mb-3 px-4">
|
<p className="text-xs font-semibold text-red-500 dark:text-red-400 uppercase tracking-wider mb-3 px-4">
|
||||||
{isEn ? 'Super Admin' : 'Супер Админ'}
|
{isEn ? 'Super Admin' : 'Супер Админ'}
|
||||||
</p>
|
</p>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
@@ -216,7 +192,7 @@ const Dashboard = () => {
|
|||||||
to={tab.to}
|
to={tab.to}
|
||||||
onClick={() => setIsMobileMenuOpen(false)}
|
onClick={() => setIsMobileMenuOpen(false)}
|
||||||
className={`flex items-center py-3 px-4 rounded-xl font-semibold transition-colors duration-200 ${
|
className={`flex items-center py-3 px-4 rounded-xl font-semibold transition-colors duration-200 ${
|
||||||
activeTab === tab.key ? 'bg-red-600 text-white shadow-lg' : 'text-red-600 hover:bg-red-50'
|
activeTab === tab.key ? 'bg-red-600 text-white shadow-lg' : 'text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{tab.label}
|
{tab.label}
|
||||||
@@ -227,13 +203,13 @@ const Dashboard = () => {
|
|||||||
)}
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
{/* Language Switcher */}
|
{/* Language Switcher */}
|
||||||
<div className="p-4 border-t border-gray-200 flex justify-center gap-2">
|
<div className="p-4 border-t border-gray-200 dark:border-gray-700 flex justify-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => setLocale('ru')}
|
onClick={() => setLocale('ru')}
|
||||||
className={`px-3 py-1 rounded-full text-sm font-medium transition-colors ${
|
className={`px-3 py-1 rounded-full text-sm font-medium transition-colors ${
|
||||||
locale === 'ru'
|
locale === 'ru'
|
||||||
? 'bg-ospab-primary text-white'
|
? 'bg-ospab-primary text-white'
|
||||||
: 'text-gray-500 hover:bg-gray-100'
|
: 'text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
RU
|
RU
|
||||||
@@ -243,15 +219,25 @@ const Dashboard = () => {
|
|||||||
className={`px-3 py-1 rounded-full text-sm font-medium transition-colors ${
|
className={`px-3 py-1 rounded-full text-sm font-medium transition-colors ${
|
||||||
locale === 'en'
|
locale === 'en'
|
||||||
? 'bg-ospab-primary text-white'
|
? 'bg-ospab-primary text-white'
|
||||||
: 'text-gray-500 hover:bg-gray-100'
|
: 'text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
EN
|
EN
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-6 pt-2 border-t border-gray-200 text-xs text-gray-500 text-center">
|
{/* Theme Switcher */}
|
||||||
<p>© 2025 ospab.host</p>
|
<div className="px-4 pb-2 flex justify-center">
|
||||||
<p className="mt-1">{isEn ? 'Version' : 'Версия'} 1.0.0</p>
|
<button
|
||||||
|
onClick={toggleTheme}
|
||||||
|
className="p-2 rounded-full text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||||
|
aria-label={isEn ? 'Toggle theme' : 'Переключить тему'}
|
||||||
|
>
|
||||||
|
{theme === 'dark' ? <FaSun size={18} /> : <FaMoon size={18} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="px-6 pb-6 pt-2 border-t border-gray-200 dark:border-gray-700 text-xs text-gray-500 dark:text-gray-400 text-center">
|
||||||
|
<p>© 2026 ospab.host</p>
|
||||||
|
<p className="mt-1">{isEn ? 'Version' : 'Версия'} 2.0.0</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -8,24 +8,24 @@ const HomePage = () => {
|
|||||||
const localePath = useLocalePath();
|
const localePath = useLocalePath();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 text-gray-800 overflow-hidden">
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 text-gray-800 dark:text-gray-100 overflow-hidden transition-colors duration-200">
|
||||||
{/* Hero Section */}
|
{/* Hero Section */}
|
||||||
<section className="relative bg-white pt-24 pb-32 overflow-hidden">
|
<section className="relative bg-white dark:bg-gray-900 pt-24 pb-32 overflow-hidden transition-colors duration-200">
|
||||||
{/* Light gradient background */}
|
{/* Light gradient background */}
|
||||||
<div className="absolute inset-0">
|
<div className="absolute inset-0">
|
||||||
<div className="absolute inset-0 bg-gradient-to-br from-blue-50 via-white to-indigo-50" />
|
<div className="absolute inset-0 bg-gradient-to-br from-blue-50 via-white to-indigo-50 dark:from-gray-900 dark:via-gray-900 dark:to-gray-800" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Soft mesh gradient - only blue tones */}
|
{/* Soft mesh gradient - only blue tones */}
|
||||||
<div className="absolute top-0 left-0 w-[600px] h-[600px] bg-blue-400/15 rounded-full blur-[100px] -translate-x-1/4 -translate-y-1/4" />
|
<div className="absolute top-0 left-0 w-[600px] h-[600px] bg-blue-400/15 dark:bg-blue-600/10 rounded-full blur-[100px] -translate-x-1/4 -translate-y-1/4" />
|
||||||
<div className="absolute bottom-0 right-0 w-[500px] h-[500px] bg-indigo-400/15 rounded-full blur-[100px] translate-x-1/4 translate-y-1/4" />
|
<div className="absolute bottom-0 right-0 w-[500px] h-[500px] bg-indigo-400/15 dark:bg-indigo-600/10 rounded-full blur-[100px] translate-x-1/4 translate-y-1/4" />
|
||||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[400px] h-[400px] bg-sky-200/30 rounded-full blur-[80px]" />
|
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[400px] h-[400px] bg-sky-200/30 dark:bg-sky-800/20 rounded-full blur-[80px]" />
|
||||||
|
|
||||||
<div className="container mx-auto text-center px-4 relative z-10">
|
<div className="container mx-auto text-center px-4 relative z-10">
|
||||||
<h1 className="text-5xl md:text-6xl font-extrabold leading-tight tracking-tighter text-gray-900 animate-fade-in-down">
|
<h1 className="text-5xl md:text-6xl font-extrabold leading-tight tracking-tighter text-gray-900 dark:text-white animate-fade-in-down">
|
||||||
{t('home.hero.title')}
|
{t('home.hero.title')}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-6 text-lg md:text-xl max-w-2xl mx-auto text-gray-600 animate-fade-in-up animation-delay-200" style={{ opacity: 0 }}>
|
<p className="mt-6 text-lg md:text-xl max-w-2xl mx-auto text-gray-600 dark:text-gray-300 animate-fade-in-up animation-delay-200" style={{ opacity: 0 }}>
|
||||||
{t('home.hero.description')}
|
{t('home.hero.description')}
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-10 flex flex-col sm:flex-row justify-center space-y-4 sm:space-y-0 sm:space-x-4 animate-fade-in-up animation-delay-400" style={{ opacity: 0 }}>
|
<div className="mt-10 flex flex-col sm:flex-row justify-center space-y-4 sm:space-y-0 sm:space-x-4 animate-fade-in-up animation-delay-400" style={{ opacity: 0 }}>
|
||||||
@@ -37,7 +37,7 @@ const HomePage = () => {
|
|||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
to={localePath('/login')}
|
to={localePath('/login')}
|
||||||
className="px-8 py-4 rounded-full text-gray-700 font-bold text-lg border-2 border-gray-300 hover:bg-gray-100 hover:border-gray-400 btn-hover"
|
className="px-8 py-4 rounded-full text-gray-700 dark:text-gray-200 font-bold text-lg border-2 border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-800 hover:border-gray-400 dark:hover:border-gray-500 btn-hover"
|
||||||
>
|
>
|
||||||
{t('nav.login')}
|
{t('nav.login')}
|
||||||
</Link>
|
</Link>
|
||||||
@@ -46,40 +46,40 @@ const HomePage = () => {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Features Section */}
|
{/* Features Section */}
|
||||||
<section className="py-20 px-4 bg-white">
|
<section className="py-20 px-4 bg-white dark:bg-gray-900 transition-colors duration-200">
|
||||||
<div className="container mx-auto">
|
<div className="container mx-auto">
|
||||||
<h2 className="text-3xl md:text-4xl font-bold text-center mb-12 text-gray-900 animate-fade-in-up">{t('home.features.title')}</h2>
|
<h2 className="text-3xl md:text-4xl font-bold text-center mb-12 text-gray-900 dark:text-white animate-fade-in-up">{t('home.features.title')}</h2>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||||
<div className="bg-gray-50 p-8 rounded-2xl shadow-lg card-hover animate-slide-up" style={{ opacity: 0, animationDelay: '0.1s' }}>
|
<div className="bg-gray-50 dark:bg-gray-800 p-8 rounded-2xl shadow-lg card-hover animate-slide-up" style={{ opacity: 0, animationDelay: '0.1s' }}>
|
||||||
<div className="flex justify-center mb-4">
|
<div className="flex justify-center mb-4">
|
||||||
<div className="p-4 bg-blue-100 rounded-2xl">
|
<div className="p-4 bg-blue-100 dark:bg-blue-900/50 rounded-2xl">
|
||||||
<FaDatabase className="text-4xl text-blue-600" />
|
<FaDatabase className="text-4xl text-blue-600 dark:text-blue-400" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-2xl font-bold text-center text-gray-900">{t('home.features.s3Compatible.title')}</h3>
|
<h3 className="text-2xl font-bold text-center text-gray-900 dark:text-white">{t('home.features.s3Compatible.title')}</h3>
|
||||||
<p className="mt-2 text-center text-gray-600">
|
<p className="mt-2 text-center text-gray-600 dark:text-gray-300">
|
||||||
{t('home.features.s3Compatible.description')}
|
{t('home.features.s3Compatible.description')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-gray-50 p-8 rounded-2xl shadow-lg card-hover animate-slide-up" style={{ opacity: 0, animationDelay: '0.25s' }}>
|
<div className="bg-gray-50 dark:bg-gray-800 p-8 rounded-2xl shadow-lg card-hover animate-slide-up" style={{ opacity: 0, animationDelay: '0.25s' }}>
|
||||||
<div className="flex justify-center mb-4">
|
<div className="flex justify-center mb-4">
|
||||||
<div className="p-4 bg-blue-100 rounded-2xl">
|
<div className="p-4 bg-blue-100 dark:bg-blue-900/50 rounded-2xl">
|
||||||
<FaCloud className="text-4xl text-blue-600" />
|
<FaCloud className="text-4xl text-blue-600 dark:text-blue-400" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-2xl font-bold text-center text-gray-900">{t('home.features.speed.title')}</h3>
|
<h3 className="text-2xl font-bold text-center text-gray-900 dark:text-white">{t('home.features.speed.title')}</h3>
|
||||||
<p className="mt-2 text-center text-gray-600">
|
<p className="mt-2 text-center text-gray-600 dark:text-gray-300">
|
||||||
{t('home.features.speed.description')}
|
{t('home.features.speed.description')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-gray-50 p-8 rounded-2xl shadow-lg card-hover animate-slide-up" style={{ opacity: 0, animationDelay: '0.4s' }}>
|
<div className="bg-gray-50 dark:bg-gray-800 p-8 rounded-2xl shadow-lg card-hover animate-slide-up" style={{ opacity: 0, animationDelay: '0.4s' }}>
|
||||||
<div className="flex justify-center mb-4">
|
<div className="flex justify-center mb-4">
|
||||||
<div className="p-4 bg-blue-100 rounded-2xl">
|
<div className="p-4 bg-blue-100 dark:bg-blue-900/50 rounded-2xl">
|
||||||
<FaShieldAlt className="text-4xl text-blue-600" />
|
<FaShieldAlt className="text-4xl text-blue-600 dark:text-blue-400" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-2xl font-bold text-center text-gray-900">{t('home.features.reliability.title')}</h3>
|
<h3 className="text-2xl font-bold text-center text-gray-900 dark:text-white">{t('home.features.reliability.title')}</h3>
|
||||||
<p className="mt-2 text-center text-gray-600">
|
<p className="mt-2 text-center text-gray-600 dark:text-gray-300">
|
||||||
{t('home.features.reliability.description')}
|
{t('home.features.reliability.description')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -27,31 +27,36 @@ const LoginPage = () => {
|
|||||||
|
|
||||||
// Redirect if logged in
|
// Redirect if logged in
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isLoggedIn) {
|
const handleOAuthLogin = async () => {
|
||||||
navigate(localePath('/dashboard'), { replace: true });
|
if (isLoggedIn) {
|
||||||
}
|
navigate(localePath('/dashboard'), { replace: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Handle OAuth token from URL & QR param
|
// Handle OAuth token from URL & QR param
|
||||||
const params = new URLSearchParams(location.search);
|
const params = new URLSearchParams(location.search);
|
||||||
const token = params.get('token');
|
const token = params.get('token');
|
||||||
const authError = params.get('error');
|
const authError = params.get('error');
|
||||||
const qrParam = params.get('qr');
|
const qrParam = params.get('qr');
|
||||||
|
|
||||||
if (token) {
|
if (token) {
|
||||||
login(token);
|
await login(token);
|
||||||
navigate(localePath('/dashboard'), { replace: true });
|
navigate(localePath('/dashboard'), { replace: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (authError) {
|
if (authError) {
|
||||||
setError(locale === 'en'
|
setError(locale === 'en'
|
||||||
? 'Social login error. Please try again.'
|
? 'Social login error. Please try again.'
|
||||||
: 'Ошибка авторизации через социальную сеть. Попробуйте снова.');
|
: 'Ошибка авторизации через социальную сеть. Попробуйте снова.');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (qrParam === '1' || qrParam === 'true') {
|
if (qrParam === '1' || qrParam === 'true') {
|
||||||
setLoginMethod('qr');
|
setLoginMethod('qr');
|
||||||
// allow QR component to generate immediately
|
// allow QR component to generate immediately
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
handleOAuthLogin();
|
||||||
}, [isLoggedIn, navigate, location, login, localePath, locale]);
|
}, [isLoggedIn, navigate, location, login, localePath, locale]);
|
||||||
|
|
||||||
const handleLogin = async (e: React.FormEvent) => {
|
const handleLogin = async (e: React.FormEvent) => {
|
||||||
@@ -67,12 +72,12 @@ const LoginPage = () => {
|
|||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await axios.post(`${API_URL}/api/auth/login`, {
|
const response = await axios.post(`${API_URL}/api/auth/login`, {
|
||||||
email: email,
|
email: email,
|
||||||
password: password,
|
password: password,
|
||||||
turnstileToken: turnstileToken,
|
turnstileToken: turnstileToken,
|
||||||
});
|
});
|
||||||
login(response.data.token);
|
await login(response.data.token);
|
||||||
// Return to original page if redirected
|
// Return to original page if redirected
|
||||||
type LocationState = { from?: { pathname?: string } };
|
type LocationState = { from?: { pathname?: string } };
|
||||||
const state = location.state as LocationState | null;
|
const state = location.state as LocationState | null;
|
||||||
@@ -102,19 +107,19 @@ const LoginPage = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center p-4 transition-colors duration-200">
|
||||||
<div className="bg-white p-8 md:p-10 rounded-3xl shadow-2xl w-full max-w-md text-center">
|
<div className="bg-white dark:bg-gray-800 p-8 md:p-10 rounded-3xl shadow-2xl w-full max-w-md text-center">
|
||||||
<h1 className="text-3xl font-bold text-gray-800 mb-6">{t('auth.login.title')}</h1>
|
<h1 className="text-3xl font-bold text-gray-800 dark:text-white mb-6">{t('auth.login.title')}</h1>
|
||||||
|
|
||||||
{/* Login method toggle */}
|
{/* Login method toggle */}
|
||||||
<div className="flex mb-6 bg-gray-100 rounded-full p-1">
|
<div className="flex mb-6 bg-gray-100 dark:bg-gray-700 rounded-full p-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setLoginMethod('password')}
|
onClick={() => setLoginMethod('password')}
|
||||||
className={`flex-1 py-2 px-4 rounded-full text-sm font-medium transition-colors ${
|
className={`flex-1 py-2 px-4 rounded-full text-sm font-medium transition-colors ${
|
||||||
loginMethod === 'password'
|
loginMethod === 'password'
|
||||||
? 'bg-white text-gray-900 shadow'
|
? 'bg-white dark:bg-gray-600 text-gray-900 dark:text-white shadow'
|
||||||
: 'text-gray-600 hover:text-gray-900'
|
: 'text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{t('auth.login.password')}
|
{t('auth.login.password')}
|
||||||
@@ -124,8 +129,8 @@ const LoginPage = () => {
|
|||||||
onClick={() => setLoginMethod('qr')}
|
onClick={() => setLoginMethod('qr')}
|
||||||
className={`flex-1 py-2 px-4 rounded-full text-sm font-medium transition-colors ${
|
className={`flex-1 py-2 px-4 rounded-full text-sm font-medium transition-colors ${
|
||||||
loginMethod === 'qr'
|
loginMethod === 'qr'
|
||||||
? 'bg-white text-gray-900 shadow'
|
? 'bg-white dark:bg-gray-600 text-gray-900 dark:text-white shadow'
|
||||||
: 'text-gray-600 hover:text-gray-900'
|
: 'text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{locale === 'en' ? 'QR Code' : 'QR-код'}
|
{locale === 'en' ? 'QR Code' : 'QR-код'}
|
||||||
@@ -140,7 +145,7 @@ const LoginPage = () => {
|
|||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
placeholder={t('auth.login.email')}
|
placeholder={t('auth.login.email')}
|
||||||
className="w-full px-5 py-3 mb-4 border border-gray-300 rounded-full focus:outline-none focus:ring-2 focus:ring-ospab-primary"
|
className="w-full px-5 py-3 mb-4 border border-gray-300 dark:border-gray-600 rounded-full focus:outline-none focus:ring-2 focus:ring-ospab-primary bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-400"
|
||||||
required
|
required
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
/>
|
/>
|
||||||
@@ -149,7 +154,7 @@ const LoginPage = () => {
|
|||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
placeholder={t('auth.login.password')}
|
placeholder={t('auth.login.password')}
|
||||||
className="w-full px-5 py-3 mb-6 border border-gray-300 rounded-full focus:outline-none focus:ring-2 focus:ring-ospab-primary"
|
className="w-full px-5 py-3 mb-6 border border-gray-300 dark:border-gray-600 rounded-full focus:outline-none focus:ring-2 focus:ring-ospab-primary bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-400"
|
||||||
required
|
required
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
/>
|
/>
|
||||||
@@ -181,8 +186,8 @@ const LoginPage = () => {
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mt-4 p-3 bg-red-50 border border-red-200 rounded-lg">
|
<div className="mt-4 p-3 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-lg">
|
||||||
<p className="text-sm text-red-600">{error}</p>
|
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -194,14 +199,14 @@ const LoginPage = () => {
|
|||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div className="absolute inset-0 flex items-center">
|
<div className="absolute inset-0 flex items-center">
|
||||||
<div className="w-full border-t border-gray-300"></div>
|
<div className="w-full border-t border-gray-300 dark:border-gray-600"></div>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative flex justify-center text-sm">
|
<div className="relative flex justify-center text-sm">
|
||||||
<span className="px-2 bg-white text-gray-500">{t('auth.login.orContinueWith')}</span>
|
<span className="px-2 bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400">{t('auth.login.orContinueWith')}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6 grid grid-cols-4 gap-3">
|
<div className="mt-6 grid grid-cols-3 gap-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleOAuthLogin('google')}
|
onClick={() => handleOAuthLogin('google')}
|
||||||
@@ -228,15 +233,6 @@ const LoginPage = () => {
|
|||||||
>
|
>
|
||||||
<img src="/yandex.png" alt="" className="h-6 w-6" />
|
<img src="/yandex.png" alt="" className="h-6 w-6" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleOAuthLogin('vkontakte')}
|
|
||||||
className="flex items-center justify-center h-12 rounded-full border border-gray-300 bg-white shadow-sm hover:bg-gray-50 transition"
|
|
||||||
aria-label={locale === 'en' ? 'Sign in with VK' : 'Войти через VK'}
|
|
||||||
>
|
|
||||||
<img src="/vk.svg" alt="VK" className="h-6 w-6" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -99,20 +99,24 @@ const RegisterPage = () => {
|
|||||||
|
|
||||||
// Handle OAuth token from URL
|
// Handle OAuth token from URL
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const params = new URLSearchParams(location.search);
|
const handleOAuthLogin = async () => {
|
||||||
const token = params.get('token');
|
const params = new URLSearchParams(location.search);
|
||||||
const authError = params.get('error');
|
const token = params.get('token');
|
||||||
|
const authError = params.get('error');
|
||||||
|
|
||||||
if (token) {
|
if (token) {
|
||||||
login(token);
|
await login(token);
|
||||||
navigate(localePath('/dashboard'), { replace: true });
|
navigate(localePath('/dashboard'), { replace: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (authError) {
|
if (authError) {
|
||||||
setError(locale === 'en'
|
setError(locale === 'en'
|
||||||
? 'Social login error. Please try again.'
|
? 'Social login error. Please try again.'
|
||||||
: 'Ошибка авторизации через социальную сеть. Попробуйте снова.');
|
: 'Ошибка авторизации через социальную сеть. Попробуйте снова.');
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
handleOAuthLogin();
|
||||||
}, [location, login, navigate, localePath, locale]);
|
}, [location, login, navigate, localePath, locale]);
|
||||||
|
|
||||||
const handleRegister = async (e: React.FormEvent) => {
|
const handleRegister = async (e: React.FormEvent) => {
|
||||||
@@ -288,7 +292,7 @@ const RegisterPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6 grid grid-cols-4 gap-3">
|
<div className="mt-6 grid grid-cols-3 gap-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleOAuthLogin('google')}
|
onClick={() => handleOAuthLogin('google')}
|
||||||
@@ -315,15 +319,6 @@ const RegisterPage = () => {
|
|||||||
>
|
>
|
||||||
<img src="/yandex.png" alt="" className="h-6 w-6" />
|
<img src="/yandex.png" alt="" className="h-6 w-6" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleOAuthLogin('vkontakte')}
|
|
||||||
className="flex items-center justify-center h-12 rounded-full border border-gray-300 bg-white shadow-sm hover:bg-gray-50 transition"
|
|
||||||
aria-label={locale === 'en' ? 'Sign up with VK' : 'Регистрация через VK'}
|
|
||||||
>
|
|
||||||
<img src="/vk.svg" alt="VK" className="h-6 w-6" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export default {
|
|||||||
"./index.html",
|
"./index.html",
|
||||||
"./src/**/*.{js,ts,jsx,tsx}",
|
"./src/**/*.{js,ts,jsx,tsx}",
|
||||||
],
|
],
|
||||||
|
darkMode: 'class',
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
colors: {
|
colors: {
|
||||||
|
|||||||
Reference in New Issue
Block a user