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:
2026-01-05 20:11:22 +03:00
parent 7a7d3151e8
commit 9bbf88a8f7
17 changed files with 366 additions and 318 deletions

View File

@@ -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`).
## Архитектура и основные компоненты
- **Монорепозиторий**: две части — `backend` (Express, TypeScript, Prisma) и `frontend` (React, Vite, TypeScript).
- **Backend**:
- Точка входа: `backend/src/index.ts` (Express, маршруты `/api/*`, CORS, логирование, WebSocket).
- Модули: `backend/src/modules/*` — домены (auth, admin, ticket, check, blog, notification, user, session, qr-auth, storage, payment, account, sitemap), каждый экспортирует маршруты и сервисы.
- Интеграция с MinIO: для S3-совместимого хранилища, параметры из `.env`.
- ORM: Prisma, схема — `backend/prisma/schema.prisma`, миграции и seed-скрипты — в `backend/prisma/`.
- Статические файлы чеков: `backend/uploads/checks` (доступны по `/uploads/checks`).
- **Frontend**:
- 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`.
## Key Patterns & Conventions
- **API Routes**: All backend routes prefixed with `/api/`, authenticated via JWT in localStorage.
- **Status Fields**: Use string enums (e.g., Check: `pending`/`approved`/`rejected`; Ticket: `open`/`in_progress`/`resolved`).
- **MinIO Integration**: S3-compatible storage via MinIO SDK, configs from `.env`, buckets per user with quotas.
- **Localization**: ru/en support via `useTranslation` hook, URLs like `/en/dashboard`.
- **WebSocket**: Real-time updates via `/ws`, initialized in `ospabhost/backend/src/websocket/server.ts`.
- **Security**: Rate limiting (1000 req/15min global, 10 req/15min auth), CORS from `PUBLIC_APP_ORIGIN`, helmet middleware.
- **Static Files**: Check uploads in `ospabhost/backend/uploads/checks`, served at `/uploads/checks`.
- **Notifications**: Email/push via `web-push`, templates in `ospabhost/backend/src/modules/notification/email.service.ts`.
## Ключевые паттерны и конвенции
- **API**: все маршруты backend — с префиксом `/api/`.
- **Модули backend**: каждый домен — отдельная папка, экспортирует маршруты и сервисы.
- **Работа с MinIO**: все операции S3 через MinIO SDK, параметры берутся из `.env`.
- **Статусные поля**: для Check, Ticket, Post, StorageBucket — строковые статусы (`pending`, `approved`, `open`, `published`, `active` и др.).
- **Пароли**: генерируются через `generateSecurePassword` (для консольных учётных данных S3).
- **Описание тарифа**: для StoragePlan — цена за GB, трафик, операции.
- **Frontend**: авторизация через контекст, проверка токена, автоматический logout при ошибке 401.
- **Дашборд**: вкладки и права оператора/админа определяются по полям `operator` и `isAdmin` в userData, обновление данных через событие `userDataUpdate`.
- **Уведомления**: email и push через web-push, шаблоны в `notification/email.service.ts`.
- **QR-аутентификация**: временные коды с TTL 60 сек, статусы `pending`, `confirmed`, `expired`.
## Development Workflows
- **Backend**: `npm run dev` (ts-node-dev hot-reload), `npm run build` (TypeScript), PM2 for production (`npm run pm2:start`).
- **Frontend**: `npm run dev` (Vite), `npm run build`, `npm run preview`.
- **Database**: Prisma migrations in `ospabhost/backend/prisma/migrations/`, seed scripts for plans/promocodes.
- **OAuth**: Google/GitHub/VK/Yandex via Passport.js, configs in `ospabhost/backend/src/modules/auth/passport.config.ts`.
- **Blog**: Rich text via Quill, statuses `draft`/`published`/`archived`.
## Сборка, запуск и workflow
- **Backend**:
- `npm run dev` — запуск с hot-reload (ts-node-dev).
- `npm run build` — компиляция TypeScript.
- `npm start` — запуск собранного кода.
- 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.
## Integration Points
- **Frontend ↔ Backend**: Axios API client, JWT auth, WebSocket for real-time.
- **MinIO**: All S3 ops through `storage.service.ts`, console credentials generated weekly.
- **Push Notifications**: Web Push API, subscriptions in PushSubscription model.
- **QR Auth**: Temporary codes (60s TTL), statuses `pending`/`confirmed`/`expired`.
## Интеграции и взаимодействие
- **Frontend ↔ Backend**: через REST API (`/api/*`), авторизация через JWT-токен в localStorage, WebSocket для real-time обновлений.
- **Backend ↔ MinIO**: для S3 хранилища, параметры из `.env`.
- **OAuth**: поддержка Google, GitHub, VK, Yandex через Passport.js.
- **Push-уведомления**: через web-push API, подписки в PushSubscription.
- **Prisma**: миграции и seed-скрипты — в `backend/prisma/`.
## Examples
- Add new module: Create `ospabhost/backend/src/modules/newmodule/` with `routes.ts` and `service.ts`, import in `index.ts`.
- Update user data: Dispatch `userDataUpdate` event to refresh dashboard.
- Storage bucket: Create via `StorageBucket` model, link to `StoragePlan` for pricing.
## Внешние зависимости
- **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 или паттернов. Для уточнения разделов — дайте обратную связь!_
Reference: `ospabhost/backend/src/index.ts`, `ospabhost/backend/prisma/schema.prisma`, `ospabhost/frontend/src/pages/dashboard/mainpage.tsx`.

View File

@@ -45,8 +45,10 @@ GITHUB_CLIENT_SECRET=623db1b4285637d328689857f3fc8ae19d84b7f1
YANDEX_CLIENT_ID=d8a889ea467f4d699d1854ac7a4f9b48
YANDEX_CLIENT_SECRET=e599f43f50274344b3bd9a007692c36b
VK_CLIENT_ID=your_vk_client_id_here
VK_CLIENT_ID=54255963
VK_CLIENT_SECRET=your_vk_client_secret_here
# OAuth Callback URL
OAUTH_CALLBACK_URL=https://api.ospab.host/api/auth

View File

@@ -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;

View File

@@ -5,7 +5,6 @@ import passport from 'passport';
import { Strategy as GoogleStrategy } from 'passport-google-oauth20';
import { Strategy as GitHubStrategy } from 'passport-github';
import { Strategy as YandexStrategy } from 'passport-yandex';
import { Strategy as VKontakteStrategy } from 'passport-vkontakte';
import { prisma } from '../../prisma/client';
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';
@@ -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) => {
done(null, user.id);
});

View File

@@ -19,7 +19,7 @@ export const uploadImage = async (req: Request, res: Response) => {
return res.status(200).json({
success: true,
data: {
url: `https://ospab.host:5000${imageUrl}`,
url: `https://api.ospab.host${imageUrl}`,
filename: req.file.filename
}
});

View File

@@ -3,6 +3,6 @@ VITE_CARD_NUMBER="2204 2402 3323 3354"
VITE_TURNSTILE_SITE_KEY=0x4AAAAAAB7306voAK0Pjx8O
# API URLs (с портом 5000)
VITE_API_URL=https://ospab.host:5000
VITE_SOCKET_URL=wss://ospab.host:5000
# API URLs
VITE_API_URL=https://api.ospab.host
VITE_SOCKET_URL=wss://api.ospab.host

View File

@@ -1,16 +1,19 @@
import { Link } from 'react-router-dom';
import { FaGithub } from 'react-icons/fa';
import { FaSun, FaMoon } from 'react-icons/fa';
import logo from '../assets/logo.svg';
import { useTranslation } from '../i18n';
import { useLocalePath } from '../middleware';
import { useTheme } from '../context/ThemeContext';
const Footer = () => {
const currentYear = new Date().getFullYear();
const { t, locale, setLocale } = useTranslation();
const localePath = useLocalePath();
const { theme, toggleTheme } = useTheme();
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="grid grid-cols-1 md:grid-cols-3 gap-8 text-center md:text-left">
{/* About Section */}
@@ -57,11 +60,21 @@ const Footer = () => {
</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">
&copy; {currentYear} ospab.host. {locale === 'en' ? 'All rights reserved.' : 'Все права защищены.'}
</p>
<div className="flex items-center gap-4">
{/* Theme Switcher */}
<button
onClick={toggleTheme}
className="p-2 rounded-full text-gray-400 hover:text-white hover:bg-gray-700 dark:hover:bg-gray-800 transition-colors"
aria-label={theme === 'light' ? 'Включить тёмную тему' : 'Включить светлую тему'}
>
{theme === 'light' ? <FaMoon className="text-xl" /> : <FaSun className="text-xl" />}
</button>
{/* Language Switcher */}
<div className="flex items-center gap-2">
<button
@@ -69,7 +82,7 @@ const Footer = () => {
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'
: 'text-gray-400 hover:text-white hover:bg-gray-700 dark:hover:bg-gray-800'
}`}
>
RU
@@ -79,7 +92,7 @@ const Footer = () => {
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'
: 'text-gray-400 hover:text-white hover:bg-gray-700 dark:hover:bg-gray-800'
}`}
>
EN
@@ -87,6 +100,7 @@ const Footer = () => {
</div>
</div>
</div>
</div>
</footer>
);
};

View File

@@ -18,24 +18,24 @@ const Header = () => {
};
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="flex justify-between items-center">
<div className="flex items-center gap-1">
<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" />
<span className="font-mono text-xl lg:text-2xl text-gray-800 font-bold">ospab.host</span>
<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 dark:text-white font-bold">ospab.host</span>
</Link>
</div>
{/* Desktop Menu */}
<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('/blog')} className="text-gray-600 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('/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 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 dark:text-gray-300 hover:text-ospab-primary dark:hover:text-ospab-primary transition-colors">{t('nav.about')}</Link>
{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 />
<button
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
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"
@@ -60,7 +60,7 @@ const Header = () => {
{/* Mobile Menu Button */}
<button
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-expanded={isMobileMenuOpen}
>
@@ -76,24 +76,24 @@ const Header = () => {
{/* Mobile Menu */}
{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
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)}
>
{t('nav.tariffs')}
</Link>
<Link
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)}
>
{t('nav.blog')}
</Link>
<Link
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)}
>
{t('nav.about')}

View 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;

View File

@@ -10,7 +10,7 @@ interface AuthContextType {
isInitialized: boolean;
userData: UserData | null;
setUserData: (data: UserData | null) => void;
login: (token: string) => void;
login: (token: string) => Promise<void>;
logout: () => void;
refreshUser: () => Promise<void>;
}
@@ -21,7 +21,7 @@ const AuthContext = createContext<AuthContextType>({
isInitialized: false,
userData: null,
setUserData: () => {},
login: () => {},
login: async () => {},
logout: () => {},
refreshUser: async () => {},
});
@@ -55,11 +55,18 @@ export const AuthProvider = ({ children }: AuthProviderProps) => {
tickets: fetchedUser.tickets ?? [],
});
setIsLoggedIn(true);
} catch (error) {
console.warn('[Auth] bootstrap failed, clearing token', error);
} catch (error: any) {
console.warn('[Auth] bootstrap failed', error);
// Очищаем токен только при ошибках авторизации (401), но не при сетевых ошибках
if (error?.response?.status === 401) {
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 {
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);
// После установки токена немедленно валидируем пользователя
bootstrapSession();
await bootstrapSession();
};
const logout = () => {

View File

@@ -1,7 +1,30 @@
@tailwind base;
@tailwind components;
@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;
}
/* ===== МОБИЛЬНАЯ АДАПТАЦИЯ ===== */
/* Улучшенный перенос слов для мобильных устройств */

View File

@@ -2,9 +2,12 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
import { ThemeProvider } from './context/ThemeContext'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<ThemeProvider>
<App />
</ThemeProvider>
</StrictMode>,
)

View File

@@ -1,10 +1,10 @@
// frontend/src/pages/dashboard/mainpage.tsx
import { useState, useEffect, useContext } from 'react';
import { Routes, Route, Link, useNavigate, useLocation } from 'react-router-dom';
import { isAxiosError } from 'axios';
import apiClient from '../../utils/apiClient';
import { Routes, Route, Link, useLocation } from 'react-router-dom';
import AuthContext from '../../context/authcontext';
import { useTranslation } from '../../i18n';
import { useTheme } from '../../context/ThemeContext';
import { FaSun, FaMoon } from 'react-icons/fa';
// Импортируем компоненты для вкладок
import Summary from './summary';
@@ -27,10 +27,10 @@ import NewTicketPage from './tickets/new';
const Dashboard = () => {
const [loading, setLoading] = useState<boolean>(true);
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState<boolean>(false);
const navigate = useNavigate();
const location = useLocation();
const { userData, setUserData, logout, refreshUser, isInitialized } = useContext(AuthContext);
const { userData, refreshUser, isInitialized } = useContext(AuthContext);
const { locale, setLocale } = useTranslation();
const { theme, toggleTheme } = useTheme();
const isEn = locale === 'en';
const [activeTab, setActiveTab] = useState('summary');
@@ -43,40 +43,16 @@ const Dashboard = () => {
}, [location.pathname]);
useEffect(() => {
const fetchData = async () => {
try {
const token = localStorage.getItem('access_token');
if (!token) {
console.log('Токен не найден, перенаправляем на логин');
logout();
navigate('/login');
return;
}
await refreshUser();
} catch (err) {
console.error('Ошибка загрузки данных:', err);
if (isAxiosError(err) && err.response?.status === 401) {
logout();
navigate('/login');
}
} finally {
// Просто снимаем флаг загрузки, так как AuthContext уже загрузил данные пользователя
if (isInitialized) {
setLoading(false);
}
};
fetchData();
}, [logout, navigate]);
}, [isInitialized]);
// Функция для обновления userData из API
const updateUserData = async () => {
try {
const token = localStorage.getItem('access_token');
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 ?? [],
});
await refreshUser();
} catch (err) {
console.error('Ошибка обновления userData:', err);
}
@@ -126,13 +102,13 @@ const Dashboard = () => {
];
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 */}
<button
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 ? (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
) : (
@@ -144,27 +120,27 @@ const Dashboard = () => {
{/* Sidebar - теперь адаптивный */}
<div className={`
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
${isMobileMenuOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}
`}>
<div className="p-6 border-b border-gray-200">
<h2 className="text-xl font-bold text-gray-800 break-words">
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-xl font-bold text-gray-800 dark:text-white break-words">
{isEn ? 'Hello' : 'Привет'}, {userData?.user?.username || (isEn ? 'Guest' : 'Гость')}!
</h2>
<div className="flex gap-2 mt-2">
{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' : 'Оператор'}
</span>
)}
{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' : 'Супер Админ'}
</span>
)}
</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>
</div>
</div>
@@ -176,7 +152,7 @@ const Dashboard = () => {
to={tab.to}
onClick={() => setIsMobileMenuOpen(false)}
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}
@@ -184,8 +160,8 @@ const Dashboard = () => {
))}
</div>
{isOperator && (
<div className="mt-8 pt-6 border-t border-gray-200">
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3 px-4">
<div className="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700">
<p className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3 px-4">
{isEn ? 'Admin Panel' : 'Админ панель'}
</p>
<div className="space-y-1">
@@ -195,7 +171,7 @@ const Dashboard = () => {
to={tab.to}
onClick={() => setIsMobileMenuOpen(false)}
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}
@@ -205,8 +181,8 @@ const Dashboard = () => {
</div>
)}
{isAdmin && (
<div className="mt-8 pt-6 border-t border-gray-200">
<p className="text-xs font-semibold text-red-500 uppercase tracking-wider mb-3 px-4">
<div className="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700">
<p className="text-xs font-semibold text-red-500 dark:text-red-400 uppercase tracking-wider mb-3 px-4">
{isEn ? 'Super Admin' : 'Супер Админ'}
</p>
<div className="space-y-1">
@@ -216,7 +192,7 @@ const Dashboard = () => {
to={tab.to}
onClick={() => setIsMobileMenuOpen(false)}
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}
@@ -227,13 +203,13 @@ const Dashboard = () => {
)}
</nav>
{/* 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
onClick={() => setLocale('ru')}
className={`px-3 py-1 rounded-full text-sm font-medium transition-colors ${
locale === 'ru'
? '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
@@ -243,15 +219,25 @@ const Dashboard = () => {
className={`px-3 py-1 rounded-full text-sm font-medium transition-colors ${
locale === 'en'
? '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
</button>
</div>
<div className="p-6 pt-2 border-t border-gray-200 text-xs text-gray-500 text-center">
<p>&copy; 2025 ospab.host</p>
<p className="mt-1">{isEn ? 'Version' : 'Версия'} 1.0.0</p>
{/* Theme Switcher */}
<div className="px-4 pb-2 flex justify-center">
<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>&copy; 2026 ospab.host</p>
<p className="mt-1">{isEn ? 'Version' : 'Версия'} 2.0.0</p>
</div>
</div>

View File

@@ -8,24 +8,24 @@ const HomePage = () => {
const localePath = useLocalePath();
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 */}
<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 */}
<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>
{/* 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 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 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-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 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 dark:bg-sky-800/20 rounded-full blur-[80px]" />
<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')}
</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')}
</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 }}>
@@ -37,7 +37,7 @@ const HomePage = () => {
</Link>
<Link
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')}
</Link>
@@ -46,40 +46,40 @@ const HomePage = () => {
</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">
<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="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="p-4 bg-blue-100 rounded-2xl">
<FaDatabase className="text-4xl text-blue-600" />
<div className="p-4 bg-blue-100 dark:bg-blue-900/50 rounded-2xl">
<FaDatabase className="text-4xl text-blue-600 dark:text-blue-400" />
</div>
</div>
<h3 className="text-2xl font-bold text-center text-gray-900">{t('home.features.s3Compatible.title')}</h3>
<p className="mt-2 text-center text-gray-600">
<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 dark:text-gray-300">
{t('home.features.s3Compatible.description')}
</p>
</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="p-4 bg-blue-100 rounded-2xl">
<FaCloud className="text-4xl text-blue-600" />
<div className="p-4 bg-blue-100 dark:bg-blue-900/50 rounded-2xl">
<FaCloud className="text-4xl text-blue-600 dark:text-blue-400" />
</div>
</div>
<h3 className="text-2xl font-bold text-center text-gray-900">{t('home.features.speed.title')}</h3>
<p className="mt-2 text-center text-gray-600">
<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 dark:text-gray-300">
{t('home.features.speed.description')}
</p>
</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="p-4 bg-blue-100 rounded-2xl">
<FaShieldAlt className="text-4xl text-blue-600" />
<div className="p-4 bg-blue-100 dark:bg-blue-900/50 rounded-2xl">
<FaShieldAlt className="text-4xl text-blue-600 dark:text-blue-400" />
</div>
</div>
<h3 className="text-2xl font-bold text-center text-gray-900">{t('home.features.reliability.title')}</h3>
<p className="mt-2 text-center text-gray-600">
<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 dark:text-gray-300">
{t('home.features.reliability.description')}
</p>
</div>

View File

@@ -27,8 +27,10 @@ const LoginPage = () => {
// Redirect if logged in
useEffect(() => {
const handleOAuthLogin = async () => {
if (isLoggedIn) {
navigate(localePath('/dashboard'), { replace: true });
return;
}
// Handle OAuth token from URL & QR param
@@ -38,7 +40,7 @@ const LoginPage = () => {
const qrParam = params.get('qr');
if (token) {
login(token);
await login(token);
navigate(localePath('/dashboard'), { replace: true });
}
@@ -52,6 +54,9 @@ const LoginPage = () => {
setLoginMethod('qr');
// allow QR component to generate immediately
}
};
handleOAuthLogin();
}, [isLoggedIn, navigate, location, login, localePath, locale]);
const handleLogin = async (e: React.FormEvent) => {
@@ -72,7 +77,7 @@ const LoginPage = () => {
password: password,
turnstileToken: turnstileToken,
});
login(response.data.token);
await login(response.data.token);
// Return to original page if redirected
type LocationState = { from?: { pathname?: string } };
const state = location.state as LocationState | null;
@@ -102,19 +107,19 @@ const LoginPage = () => {
};
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
<div className="bg-white 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>
<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 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 dark:text-white mb-6">{t('auth.login.title')}</h1>
{/* 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
type="button"
onClick={() => setLoginMethod('password')}
className={`flex-1 py-2 px-4 rounded-full text-sm font-medium transition-colors ${
loginMethod === 'password'
? 'bg-white text-gray-900 shadow'
: 'text-gray-600 hover:text-gray-900'
? 'bg-white dark:bg-gray-600 text-gray-900 dark:text-white shadow'
: 'text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white'
}`}
>
{t('auth.login.password')}
@@ -124,8 +129,8 @@ const LoginPage = () => {
onClick={() => setLoginMethod('qr')}
className={`flex-1 py-2 px-4 rounded-full text-sm font-medium transition-colors ${
loginMethod === 'qr'
? 'bg-white text-gray-900 shadow'
: 'text-gray-600 hover:text-gray-900'
? 'bg-white dark:bg-gray-600 text-gray-900 dark:text-white shadow'
: 'text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white'
}`}
>
{locale === 'en' ? 'QR Code' : 'QR-код'}
@@ -140,7 +145,7 @@ const LoginPage = () => {
value={email}
onChange={(e) => setEmail(e.target.value)}
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
disabled={isLoading}
/>
@@ -149,7 +154,7 @@ const LoginPage = () => {
value={password}
onChange={(e) => setPassword(e.target.value)}
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
disabled={isLoading}
/>
@@ -181,8 +186,8 @@ const LoginPage = () => {
</button>
</form>
{error && (
<div className="mt-4 p-3 bg-red-50 border border-red-200 rounded-lg">
<p className="text-sm text-red-600">{error}</p>
<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 dark:text-red-400">{error}</p>
</div>
)}
</>
@@ -194,14 +199,14 @@ const LoginPage = () => {
<div className="mt-6">
<div className="relative">
<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 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 className="mt-6 grid grid-cols-4 gap-3">
<div className="mt-6 grid grid-cols-3 gap-3">
<button
type="button"
onClick={() => handleOAuthLogin('google')}
@@ -228,15 +233,6 @@ const LoginPage = () => {
>
<img src="/yandex.png" alt="" className="h-6 w-6" />
</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>

View File

@@ -99,12 +99,13 @@ const RegisterPage = () => {
// Handle OAuth token from URL
useEffect(() => {
const handleOAuthLogin = async () => {
const params = new URLSearchParams(location.search);
const token = params.get('token');
const authError = params.get('error');
if (token) {
login(token);
await login(token);
navigate(localePath('/dashboard'), { replace: true });
}
@@ -113,6 +114,9 @@ const RegisterPage = () => {
? 'Social login error. Please try again.'
: 'Ошибка авторизации через социальную сеть. Попробуйте снова.');
}
};
handleOAuthLogin();
}, [location, login, navigate, localePath, locale]);
const handleRegister = async (e: React.FormEvent) => {
@@ -288,7 +292,7 @@ const RegisterPage = () => {
</div>
</div>
<div className="mt-6 grid grid-cols-4 gap-3">
<div className="mt-6 grid grid-cols-3 gap-3">
<button
type="button"
onClick={() => handleOAuthLogin('google')}
@@ -315,15 +319,6 @@ const RegisterPage = () => {
>
<img src="/yandex.png" alt="" className="h-6 w-6" />
</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>

View File

@@ -4,6 +4,7 @@ export default {
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
darkMode: 'class',
theme: {
extend: {
colors: {