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`).
## Архитектура и основные компоненты ## 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 или паттернов. Для уточнения разделов — дайте обратную связь!_

View File

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

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; export default router;

View File

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

View File

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

View File

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

View File

@@ -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">
&copy; {currentYear} ospab.host. {locale === 'en' ? 'All rights reserved.' : 'Все права защищены.'} &copy; {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>

View File

@@ -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')}

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; 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 = () => {

View File

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

View File

@@ -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>,
) )

View File

@@ -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>&copy; 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>&copy; 2026 ospab.host</p>
<p className="mt-1">{isEn ? 'Version' : 'Версия'} 2.0.0</p>
</div> </div>
</div> </div>

View File

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

View File

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

View File

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

View File

@@ -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: {