feat: Implement dark mode support across the application
- Added ThemeContext to manage theme state and toggle functionality. - Updated components to support dark mode styles, including header, dashboard, and home page. - Enhanced CSS for smooth transitions between light and dark themes. - Modified authentication context to handle async login operations. - Improved user experience by preserving theme preference in local storage. - Refactored login and register pages to handle OAuth tokens and errors more gracefully.
This commit is contained in:
98
.github/copilot-instructions.md
vendored
98
.github/copilot-instructions.md
vendored
@@ -1,72 +1,38 @@
|
||||
# Copilot Instructions for Ospabhost 8.1
|
||||
# Copilot Instructions for Ospabhost
|
||||
|
||||
## Architecture Overview
|
||||
- **Monorepo**: Backend (Express + TypeScript + Prisma) and Frontend (React + Vite + TypeScript).
|
||||
- **Backend Entry**: `ospabhost/backend/src/index.ts` - Express server with CORS, rate limiting, WebSocket, and module routes (`/api/*`).
|
||||
- **Modules**: Organized in `ospabhost/backend/src/modules/*` (auth, storage, ticket, blog, etc.), each exporting routes and services.
|
||||
- **Database**: MySQL via Prisma (`ospabhost/backend/prisma/schema.prisma`), models like User, StorageBucket, Ticket with string statuses (`pending`, `approved`, `active`).
|
||||
- **Frontend Dashboard**: `ospabhost/frontend/src/pages/dashboard/mainpage.tsx` - dynamic sidebar tabs based on user roles (`operator`, `isAdmin`).
|
||||
|
||||
## Архитектура и основные компоненты
|
||||
- **Монорепозиторий**: две части — `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`.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,33 +60,44 @@ 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">
|
||||
© {currentYear} ospab.host. {locale === 'en' ? 'All rights reserved.' : 'Все права защищены.'}
|
||||
</p>
|
||||
|
||||
{/* Language Switcher */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Theme Switcher */}
|
||||
<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'
|
||||
}`}
|
||||
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' ? 'Включить тёмную тему' : 'Включить светлую тему'}
|
||||
>
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
EN
|
||||
{theme === 'light' ? <FaMoon className="text-xl" /> : <FaSun className="text-xl" />}
|
||||
</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>
|
||||
|
||||
@@ -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')}
|
||||
|
||||
106
ospabhost/frontend/src/context/ThemeContext.tsx
Normal file
106
ospabhost/frontend/src/context/ThemeContext.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import { createContext, useState, useEffect, useContext } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
type Theme = 'light' | 'dark';
|
||||
|
||||
interface ThemeContextType {
|
||||
theme: Theme;
|
||||
toggleTheme: () => void;
|
||||
setTheme: (theme: Theme) => void;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType>({
|
||||
theme: 'light',
|
||||
toggleTheme: () => {},
|
||||
setTheme: () => {},
|
||||
});
|
||||
|
||||
interface ThemeProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const ThemeProvider = ({ children }: ThemeProviderProps) => {
|
||||
const [theme, setThemeState] = useState<Theme>('light');
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
|
||||
// Инициализация темы при загрузке
|
||||
useEffect(() => {
|
||||
// Проверяем сохраненную тему
|
||||
const savedTheme = localStorage.getItem('theme') as Theme | null;
|
||||
|
||||
if (savedTheme) {
|
||||
// Если есть сохраненная тема, используем её
|
||||
setThemeState(savedTheme);
|
||||
applyTheme(savedTheme);
|
||||
} else {
|
||||
// Иначе проверяем системные настройки браузера
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
const systemTheme = prefersDark ? 'dark' : 'light';
|
||||
setThemeState(systemTheme);
|
||||
applyTheme(systemTheme);
|
||||
localStorage.setItem('theme', systemTheme);
|
||||
}
|
||||
|
||||
setIsInitialized(true);
|
||||
}, []);
|
||||
|
||||
// Слушаем изменения системной темы
|
||||
useEffect(() => {
|
||||
if (!isInitialized) return;
|
||||
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
|
||||
const handleChange = (e: MediaQueryListEvent) => {
|
||||
// Обновляем только если нет сохраненной темы или пользователь не менял тему вручную
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
if (!savedTheme) {
|
||||
const newTheme = e.matches ? 'dark' : 'light';
|
||||
setThemeState(newTheme);
|
||||
applyTheme(newTheme);
|
||||
}
|
||||
};
|
||||
|
||||
mediaQuery.addEventListener('change', handleChange);
|
||||
return () => mediaQuery.removeEventListener('change', handleChange);
|
||||
}, [isInitialized]);
|
||||
|
||||
const applyTheme = (newTheme: Theme) => {
|
||||
const root = document.documentElement;
|
||||
const body = document.body;
|
||||
|
||||
if (newTheme === 'dark') {
|
||||
root.classList.add('dark');
|
||||
body.classList.add('dark');
|
||||
} else {
|
||||
root.classList.remove('dark');
|
||||
body.classList.remove('dark');
|
||||
}
|
||||
};
|
||||
|
||||
const setTheme = (newTheme: Theme) => {
|
||||
setThemeState(newTheme);
|
||||
localStorage.setItem('theme', newTheme);
|
||||
applyTheme(newTheme);
|
||||
};
|
||||
|
||||
const toggleTheme = () => {
|
||||
const newTheme = theme === 'light' ? 'dark' : 'light';
|
||||
setTheme(newTheme);
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, toggleTheme, setTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useTheme = () => {
|
||||
const context = useContext(ThemeContext);
|
||||
if (!context) {
|
||||
throw new Error('useTheme must be used within ThemeProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export default ThemeContext;
|
||||
@@ -10,7 +10,7 @@ interface AuthContextType {
|
||||
isInitialized: boolean;
|
||||
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);
|
||||
localStorage.removeItem('access_token');
|
||||
setIsLoggedIn(false);
|
||||
setUserData(null);
|
||||
} 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 = () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
/* ===== МОБИЛЬНАЯ АДАПТАЦИЯ ===== */
|
||||
|
||||
/* Улучшенный перенос слов для мобильных устройств */
|
||||
|
||||
@@ -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>
|
||||
<App />
|
||||
<ThemeProvider>
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
@@ -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 {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
}, [logout, navigate]);
|
||||
// Просто снимаем флаг загрузки, так как AuthContext уже загрузил данные пользователя
|
||||
if (isInitialized) {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [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>© 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>© 2026 ospab.host</p>
|
||||
<p className="mt-1">{isEn ? 'Version' : 'Версия'} 2.0.0</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -27,31 +27,36 @@ const LoginPage = () => {
|
||||
|
||||
// Redirect if logged in
|
||||
useEffect(() => {
|
||||
if (isLoggedIn) {
|
||||
navigate(localePath('/dashboard'), { replace: true });
|
||||
}
|
||||
const handleOAuthLogin = async () => {
|
||||
if (isLoggedIn) {
|
||||
navigate(localePath('/dashboard'), { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle OAuth token from URL & QR param
|
||||
const params = new URLSearchParams(location.search);
|
||||
const token = params.get('token');
|
||||
const authError = params.get('error');
|
||||
const qrParam = params.get('qr');
|
||||
// Handle OAuth token from URL & QR param
|
||||
const params = new URLSearchParams(location.search);
|
||||
const token = params.get('token');
|
||||
const authError = params.get('error');
|
||||
const qrParam = params.get('qr');
|
||||
|
||||
if (token) {
|
||||
login(token);
|
||||
navigate(localePath('/dashboard'), { replace: true });
|
||||
}
|
||||
if (token) {
|
||||
await login(token);
|
||||
navigate(localePath('/dashboard'), { replace: true });
|
||||
}
|
||||
|
||||
if (authError) {
|
||||
setError(locale === 'en'
|
||||
? 'Social login error. Please try again.'
|
||||
: 'Ошибка авторизации через социальную сеть. Попробуйте снова.');
|
||||
}
|
||||
if (authError) {
|
||||
setError(locale === 'en'
|
||||
? 'Social login error. Please try again.'
|
||||
: 'Ошибка авторизации через социальную сеть. Попробуйте снова.');
|
||||
}
|
||||
|
||||
if (qrParam === '1' || qrParam === 'true') {
|
||||
setLoginMethod('qr');
|
||||
// allow QR component to generate immediately
|
||||
}
|
||||
if (qrParam === '1' || qrParam === 'true') {
|
||||
setLoginMethod('qr');
|
||||
// allow QR component to generate immediately
|
||||
}
|
||||
};
|
||||
|
||||
handleOAuthLogin();
|
||||
}, [isLoggedIn, navigate, location, login, localePath, locale]);
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
@@ -67,12 +72,12 @@ const LoginPage = () => {
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await axios.post(`${API_URL}/api/auth/login`, {
|
||||
const response = await axios.post(`${API_URL}/api/auth/login`, {
|
||||
email: email,
|
||||
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>
|
||||
|
||||
|
||||
@@ -99,20 +99,24 @@ const RegisterPage = () => {
|
||||
|
||||
// Handle OAuth token from URL
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const token = params.get('token');
|
||||
const authError = params.get('error');
|
||||
const handleOAuthLogin = async () => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const token = params.get('token');
|
||||
const authError = params.get('error');
|
||||
|
||||
if (token) {
|
||||
login(token);
|
||||
navigate(localePath('/dashboard'), { replace: true });
|
||||
}
|
||||
if (token) {
|
||||
await login(token);
|
||||
navigate(localePath('/dashboard'), { replace: true });
|
||||
}
|
||||
|
||||
if (authError) {
|
||||
setError(locale === 'en'
|
||||
? 'Social login error. Please try again.'
|
||||
: 'Ошибка авторизации через социальную сеть. Попробуйте снова.');
|
||||
}
|
||||
if (authError) {
|
||||
setError(locale === 'en'
|
||||
? '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>
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ export default {
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
|
||||
Reference in New Issue
Block a user