Compare commits

...

9 Commits

Author SHA1 Message Date
738113a927 Merge branch 'master' of ssh://git.smartsoltech.kr:2222/trevor/links into feature/premium-system
Some checks failed
continuous-integration/drone/pr Build is failing
2025-11-11 08:27:44 +09:00
271f303757 localization, dark theme 2025-11-11 08:26:25 +09:00
68bbef35ee localization && navbar fix 2025-11-09 22:15:48 +09:00
d638b062a9 Merge pull request 'feat: улучшена навигационная панель с полной интеграцией темы и локализации' (#1) from feature/premium-system into master
Some checks failed
continuous-integration/drone/push Build is failing
Reviewed-on: #1
2025-11-09 06:47:17 +00:00
a963281be0 feat: улучшена навигационная панель с полной интеграцией темы и локализации
Some checks failed
continuous-integration/drone/pr Build is failing
- Обновлен LayoutWrapper с улучшенным UI навигации
- Добавлен dropdown меню пользователя с аватаром
- Интегрированы ThemeToggle и LanguageSelector в навигацию
- Переключатели темы и языка теперь всегда видны
- Добавлены флаги стран в селектор языков
- Создана страница редактирования профиля /profile
- Улучшены стили для темной темы в navbar
- Добавлены CSS стили для навигации и профиля
2025-11-09 15:45:01 +09:00
2ef7b4fa95 themes.css fix
Some checks failed
continuous-integration/drone/push Build is failing
2025-11-09 15:30:37 +09:00
79f74b83a8 localization
Some checks failed
continuous-integration/drone/push Build is failing
2025-11-09 15:25:03 +09:00
341911a8d3 Полный UI для экспорта/импорта данных профиля
Some checks failed
continuous-integration/drone/push Build is failing
 Новые возможности:
- Добавлена вкладка 'Данные' в панель настроек
- Интерактивный модал экспорта с деревом выбора элементов
- Модал импорта с превью архива и селективным восстановлением
- Автоматическая обработка ZIP архивов и медиафайлов

🎯 Функционал экспорта:
- Древовидный выбор: профиль, группы, конкретные ссылки
- Чекбоксы для типов данных: стили, медиа
- Прогресс-индикаторы и автозагрузка файлов
- Подсчет выбранных элементов в реальном времени

📥 Функционал импорта:
- Drag&Drop загрузка ZIP архивов
- Детальное превью содержимого файла
- Селективный выбор данных для восстановления
- Защита от перезаписи с опциональным режимом

🔗 Интеграция:
- Полная интеграция с существующими API endpoints
- Автообновление данных после импорта
- Обработка ошибок и пользовательские уведомления
- Responsive дизайн для всех устройств
2025-11-09 14:45:09 +09:00
d78c296e5a Добавлен полнофункциональный экспорт/импорт профилей
- Кнопки 'убрать фон' для всех элементов: профиль, группы, ссылки
- Кнопка 'сбросить настройки интерфейса' с подтверждением
- Django app export_import с полным API для бэкапа и восстановления
- Экспорт: создание ZIP архивов с данными профиля и медиафайлами
- Импорт: селективная загрузка групп, ссылок, стилей, медиа
- Обработка мультипарт форм, Django транзакции, управление ошибками
- Полное тестирование: экспорт → импорт данных между пользователями
- API эндпоинты: /api/export/, /api/import/, превью архивов
- Готовая система для производственного развертывания
2025-11-09 14:28:45 +09:00
48 changed files with 7244 additions and 445 deletions

71
.env.local Normal file
View File

@@ -0,0 +1,71 @@
# Django настройки для локальной разработки
DJANGO_SECRET_KEY=lskjflSDJHFdSFYU7TYOREIFLUDJKFBNKLJSDHFP9Q234856QT80OUAEIYDWSF9PQ28345701784QRTEOYAGWDFLSBAPWO9I485Y
DJANGO_DEBUG=True
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,web
DJANGO_CSRF_TRUSTED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000,http://localhost:8000,http://127.0.0.1:8000
# CORS настройки для локальной разработки
CORS_ALLOWED_ORIGINS=http://127.0.0.1:3000,http://localhost:3000
CORS_ALLOW_ALL_ORIGINS=True
CORS_ALLOW_CREDENTIALS=True
CORS_ALLOW_HEADERS=accept,accept-encoding,authorization,content-type,dnt,origin,user-agent,x-csrftoken,x-requested-with
# Локализация
DJANGO_LANGUAGE_CODE=ru-ru
DJANGO_TIME_ZONE=UTC
DJANGO_USE_I18N=True
DJANGO_USE_TZ=True
# Статические файлы
DJANGO_STATIC_URL=/static/
DJANGO_MEDIA_URL=/storage/
# API настройки
DJANGO_APPEND_SLASH=False
# JWT настройки
JWT_ACCESS_TOKEN_LIFETIME_MINUTES=60
JWT_REFRESH_TOKEN_LIFETIME_DAYS=1
# База данных PostgreSQL
DATABASE_ENGINE=django.db.backends.postgresql
DATABASE_NAME=links_db
DATABASE_USER=links_user
DATABASE_PASSWORD=links_OASDUIFH90324*ftye(guBJ;O234789SDgfu{
DATABASE_HOST=db
DATABASE_PORT=5432
# PostgreSQL настройки для контейнера
POSTGRES_DB=links_db
POSTGRES_USER=links_user
POSTGRES_PASSWORD=links_OASDUIFH90324*ftye(guBJ;O234789SDgfu{
# Frontend настройки (для локальной разработки)
NEXT_PUBLIC_API_URL=http://localhost:8000
# URL настройки для Django backend (локальные)
DJANGO_BACKEND_URL=http://localhost:8000
DJANGO_BACKEND_PROTOCOL=http
DJANGO_BACKEND_DOMAIN=localhost:8000
DJANGO_MEDIA_BASE_URL=http://localhost:8000
# Безопасность (отключено для локальной разработки)
DJANGO_SECURE_SSL_REDIRECT=False
DJANGO_SECURE_HSTS_SECONDS=0
DJANGO_SECURE_HSTS_INCLUDE_SUBDOMAINS=False
DJANGO_SECURE_HSTS_PRELOAD=False
DJANGO_SECURE_CONTENT_TYPE_NOSNIFF=True
DJANGO_SECURE_BROWSER_XSS_FILTER=True
DJANGO_X_FRAME_OPTIONS=SAMEORIGIN
# Email настройки (консоль для локальной разработки)
DJANGO_EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend
DJANGO_EMAIL_HOST=
DJANGO_EMAIL_PORT=587
DJANGO_EMAIL_HOST_USER=
DJANGO_EMAIL_HOST_PASSWORD=
DJANGO_EMAIL_USE_TLS=False
DJANGO_EMAIL_USE_SSL=False
DJANGO_EMAIL_TIMEOUT=30
DJANGO_DEFAULT_FROM_EMAIL=
DJANGO_SERVER_EMAIL=

319
PREMIUM_FEATURES.md Normal file
View File

@@ -0,0 +1,319 @@
# 🎯 Премиум функционал CatLink
## 📈 Анализ пользователей и тарификация
### Текущая аудитория
- **Free пользователи**: Базовый функционал
- **Premium пользователи**: Расширенные возможности
- **Business пользователи**: Корпоративные функции
### Тарифные планы
```
🆓 FREE (Бесплатный)
- 1 список ссылок
- До 10 групп
- До 50 ссылок
- Базовая кастомизация
- Показы страниц (общая статистика)
⭐ PREMIUM ($5/месяц)
- До 5 списков ссылок
- Неограниченные группы и ссылки
- Продвинутая кастомизация
- Детальная аналитика по каждой ссылке
- Управление доступами
- Календарное планирование публикаций
- A/B тестирование ссылок
- Интеграции с соц.сетями
💼 BUSINESS ($15/месяц)
- Неограниченные списки
- Командная работа (до 5 пользователей)
- Брендирование (белые метки)
- API доступ
- Продвинутая аналитика и экспорт данных
- Собственный домен
- Приоритетная поддержка
- Webhook интеграции
```
---
## 🚀 Ключевые премиум функции
### 1. 📚 Множественные списки ссылок
#### Концепция
Пользователи могут создавать несколько независимых наборов ссылок для разных целей:
- Личный профиль
- Рабочий профиль
- Проекты
- Мероприятия
- Временные кампании
#### Функционал
- **Создание списков**: Неограниченное количество для Premium/Business
- **Уникальные URL**: `/username/список-название` или `/username/work`
- **Индивидуальная кастомизация**: Каждый список имеет свои настройки дизайна
- **Управление доступом**: Публичные/приватные/по паролю/по времени
- **Переключение**: Быстрое переключение между списками в дашборде
- **Шаблоны**: Создание списков на основе шаблонов
- **Импорт/экспорт**: Отдельно для каждого списка
#### Пример использования
```
trevor.catlink.com/ - основной профиль
trevor.catlink.com/business - рабочий профиль
trevor.catlink.com/crypto - криптопроекты
trevor.catlink.com/event2024 - временное событие
```
### 2. 📊 Система аналитики
#### Отслеживание показов
- **Page Views**: Количество просмотров каждого списка
- **Unique Visitors**: Уникальные посетители
- **Геолокация**: Страны и города посетителей
- **Устройства**: Десктоп, мобильные, планшеты
- **Источники**: Прямые переходы, соц.сети, поиск
- **Время на странице**: Среднее время просмотра
#### Детальная аналитика ссылок
- **Клики по ссылкам**: Количество переходов на каждую ссылку
- **CTR (Click Through Rate)**: Процент кликов от показов
- **Тепловая карта**: Самые популярные ссылки
- **Временная статистика**: По часам, дням, неделям, месяцам
- **Источники трафика**: Откуда пришли пользователи
- **Конверсионная воронка**: Путь пользователя по ссылкам
#### Экспорт и отчеты
- **CSV/Excel экспорт**: Для дальнейшего анализа
- **Еженедельные дайджесты**: Автоматические email отчеты
- **Сравнительная аналитика**: Сравнение периодов
- **Цели и события**: Отслеживание конверсий
### 3. 🎨 Продвинутая кастомизация
#### Темы и шаблоны
- **Premium темы**: Эксклюзивные дизайны
- **Анимации**: Плавные переходы и hover эффекты
- **Кастомные шрифты**: Интеграция Google Fonts
- **CSS редактор**: Полная кастомизация стилей
- **Фоновые видео**: Вместо статичных изображений
- **Градиенты**: Сложные цветовые переходы
#### Брендирование
- **Собственный домен**: `links.your-company.com`
- **Фавикон**: Собственная иконка
- **Белые метки**: Скрытие брендинга CatLink
- **Кастомный footer**: Собственные копирайты и ссылки
### 4. ⏰ Календарное планирование
#### Расписание публикаций
- **Автопубликация**: Ссылки появляются в нужное время
- **Временные ссылки**: Автоматическое скрытие по истечении срока
- **Событийные списки**: Специальные страницы для мероприятий
- **Сезонные кампании**: Автоматическое переключение контента
### 5. 🧪 A/B тестирование
#### Тестирование ссылок
- **Варианты ссылок**: Разные тексты, иконки, позиции
- **Сплит трафик**: Равномерное распределение посетителей
- **Статистика результатов**: Какой вариант работает лучше
- **Автоматическая оптимизация**: Выбор лучшего варианта
### 6. 🔗 Интеграции и API
#### Социальные сети
- **Instagram Stories**: Автоматическая синхронизация ссылок
- **TikTok Bio**: Динамическое обновление профиля
- **YouTube**: Интеграция с описаниями видео
- **Twitter**: Автообновление био
#### API и Webhook
- **REST API**: Программное управление ссылками
- **Webhook уведомления**: События кликов и просмотров
- **Zapier интеграция**: Автоматизация с другими сервисами
- **WordPress плагин**: Интеграция с сайтами
### 7. 👥 Командная работа (Business)
#### Мульти-пользовательский доступ
- **Роли пользователей**: Admin, Editor, Viewer
- **Совместное редактирование**: Несколько человек работают одновременно
- **История изменений**: Кто и когда вносил правки
- **Approval workflow**: Модерация изменений
### 8. 🛡️ Продвинутое управление доступом
#### Типы доступа
- **Публичный**: Доступен всем
- **Приватный**: Только по прямой ссылке
- **По паролю**: Защищен паролем
- **По времени**: Доступен только в определенные часы/дни
- **Геоблокировка**: Ограничение по странам
- **Лимит просмотров**: Автоскрытие после N просмотров
### 9. 🎯 Smart Links и редиректы
#### Умные ссылки
- **Geo-таргетинг**: Разные ссылки для разных стран
- **Device-таргетинг**: iOS -> App Store, Android -> Google Play
- **Время-таргетинг**: Разные ссылки в разное время
- **Короткие URL**: Собственный сервис сокращения ссылок
- **QR коды**: Автоматическая генерация для каждой ссылки
### 10. 💸 Монетизация и коммерция
#### Встроенная коммерция
- **Donate кнопки**: Интеграция с PayPal, Stripe
- **Affiliate ссылки**: Отслеживание партнерских программ
- **Продажа товаров**: Прямые ссылки на товары с preview
- **Подписки**: Ссылки на Patreon, OnlyFans и т.д.
---
## 🔧 Техническая архитектура
### Backend расширения
#### Новые модели Django
```python
# Множественные списки
class LinkCollection(models.Model):
user = models.ForeignKey(User)
name = models.CharField(max_length=100)
slug = models.SlugField(unique=True)
is_public = models.BooleanField(default=True)
access_type = models.CharField(choices=ACCESS_TYPES)
password = models.CharField(blank=True)
expires_at = models.DateTimeField(null=True)
# Аналитика
class PageView(models.Model):
collection = models.ForeignKey(LinkCollection)
ip_address = models.GenericIPAddressField()
user_agent = models.TextField()
country = models.CharField(max_length=2)
timestamp = models.DateTimeField(auto_now_add=True)
class LinkClick(models.Model):
link = models.ForeignKey(Link)
page_view = models.ForeignKey(PageView)
timestamp = models.DateTimeField(auto_now_add=True)
# Подписки
class Subscription(models.Model):
user = models.OneToOneField(User)
plan = models.CharField(choices=PLAN_CHOICES)
status = models.CharField(choices=STATUS_CHOICES)
expires_at = models.DateTimeField()
```
#### API эндпоинты
```python
# Аналитика
/api/analytics/collections/{id}/views/
/api/analytics/collections/{id}/clicks/
/api/analytics/export/
# Множественные списки
/api/collections/
/api/collections/{id}/
/api/collections/{id}/links/
# Подписки
/api/subscription/
/api/subscription/upgrade/
/api/subscription/billing/
```
### Frontend компоненты
#### Новые React компоненты
- `CollectionManager` - управление списками
- `AnalyticsDashboard` - дашборд аналитики
- `PremiumUpgrade` - модал апгрейда
- `LinkScheduler` - планировщик публикаций
- `ABTestManager` - управление A/B тестами
### Система платежей
#### Интеграции
- **Stripe**: Основной платежный процессор
- **PayPal**: Альтернативный способ оплаты
- **Банковские карты**: Прямое принятие платежей
- **Crypto**: Bitcoin, Ethereum (опционально)
---
## 📋 План разработки
### Этап 1 (2 недели): Базовая премиум инфраструктура
- [ ] Система подписок и планов
- [ ] Базовые ограничения для free пользователей
- [ ] Премиум upgrade flow
- [ ] Интеграция со Stripe
### Этап 2 (3 недели): Множественные списки
- [ ] Backend модели и API
- [ ] Frontend управление списками
- [ ] Роутинг для разных списков
- [ ] Импорт/экспорт списков
### Этап 3 (2 недели): Базовая аналитика
- [ ] Трекинг показов страниц
- [ ] Трекинг кликов по ссылкам
- [ ] Базовый дашборд аналитики
- [ ] Экспорт данных
### Этап 4 (3 недели): Продвинутая аналитика
- [ ] Геолокация и устройства
- [ ] Временная аналитика
- [ ] Тепловые карты
- [ ] Автоматические отчеты
### Этап 5 (2 недели): Управление доступом
- [ ] Приватные списки
- [ ] Парольная защита
- [ ] Временные ограничения
- [ ] Геоблокировка
### Этап 6 (2 недели): A/B тестирование
- [ ] Создание вариантов ссылок
- [ ] Сплит трафика
- [ ] Статистика результатов
- [ ] Автооптимизация
---
## 💰 Монетизация
### Прогнозируемые метрики
- **Conversion Free -> Premium**: 5-10%
- **Churn Rate**: < 5% в месяц
- **ARPU (Average Revenue Per User)**: $5-15
- **Customer Lifetime Value**: $50-150
### Маркетинговая стратегия
- **Freemium модель**: Привлечение через бесплатный план
- **Feature gating**: Ключевые функции только в Premium
- **Social proof**: Отзывы и кейсы успешных пользователей
- **Партнерские программы**: Реферальная система
---
## 🎯 Уникальные конкурентные преимущества
1. **Множественные списки**: Уникальная возможность разделения контекстов
2. **Детальная аналитика**: Глубокий анализ поведения пользователей
3. **Smart Links**: Умная маршрутизация в зависимости от контекста
4. **A/B тестирование**: Оптимизация конверсий
5. **Календарное планирование**: Автоматизация публикаций
6. **API-first подход**: Интеграция с любыми системами
7. **Локализация**: Поддержка 5 языков из коробки
8. **Открытый код**: Возможность самостоятельного развертывания
Этот премиум функционал превратит CatLink из простого агрегатора ссылок в мощную платформу для управления цифровым присутствием и аналитики.

972
PREMIUM_IMPLEMENTATION.md Normal file
View File

@@ -0,0 +1,972 @@
# 🚀 Техническое руководство: Премиум инфраструктура
## 📋 Этап 1: Базовая премиум система
### 🏗️ Backend: Django модели
#### 1. Модель подписок
```python
# backend/subscriptions/__init__.py
# backend/subscriptions/models.py
from django.db import models
from django.contrib.auth.models import User
from django.utils import timezone
class SubscriptionPlan(models.Model):
"""Планы подписок"""
PLAN_CHOICES = [
('free', 'Free'),
('premium', 'Premium'),
('business', 'Business'),
]
name = models.CharField(max_length=50, choices=PLAN_CHOICES, unique=True)
display_name = models.CharField(max_length=100)
price_monthly = models.DecimalField(max_digits=10, decimal_places=2)
price_yearly = models.DecimalField(max_digits=10, decimal_places=2)
description = models.TextField()
features = models.JSONField(default=dict) # Список возможностей
max_collections = models.IntegerField(default=1)
max_groups = models.IntegerField(default=10)
max_links = models.IntegerField(default=50)
analytics_enabled = models.BooleanField(default=False)
custom_domain_enabled = models.BooleanField(default=False)
api_access_enabled = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return self.display_name
class UserSubscription(models.Model):
"""Подписка пользователя"""
STATUS_CHOICES = [
('active', 'Active'),
('cancelled', 'Cancelled'),
('expired', 'Expired'),
('trial', 'Trial'),
]
user = models.OneToOneField(User, on_delete=models.CASCADE)
plan = models.ForeignKey(SubscriptionPlan, on_delete=models.CASCADE)
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='active')
starts_at = models.DateTimeField(default=timezone.now)
expires_at = models.DateTimeField()
stripe_subscription_id = models.CharField(max_length=255, blank=True)
stripe_customer_id = models.CharField(max_length=255, blank=True)
auto_renew = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'user_subscriptions'
def is_active(self):
return self.status == 'active' and self.expires_at > timezone.now()
def is_premium(self):
return self.plan.name in ['premium', 'business'] and self.is_active()
def days_remaining(self):
if self.expires_at > timezone.now():
return (self.expires_at - timezone.now()).days
return 0
def __str__(self):
return f"{self.user.username} - {self.plan.display_name}"
class PaymentHistory(models.Model):
"""История платежей"""
subscription = models.ForeignKey(UserSubscription, on_delete=models.CASCADE)
amount = models.DecimalField(max_digits=10, decimal_places=2)
currency = models.CharField(max_length=3, default='USD')
stripe_payment_id = models.CharField(max_length=255)
status = models.CharField(max_length=50)
payment_date = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'payment_history'
```
#### 2. Модель множественных списков
```python
# backend/collections/models.py
from django.db import models
from django.contrib.auth.models import User
from django.utils.text import slugify
class LinkCollection(models.Model):
"""Коллекции ссылок для премиум пользователей"""
ACCESS_CHOICES = [
('public', 'Public'),
('private', 'Private'),
('password', 'Password Protected'),
('scheduled', 'Scheduled'),
]
user = models.ForeignKey(User, on_delete=models.CASCADE)
name = models.CharField(max_length=100)
slug = models.SlugField(unique=True, blank=True)
description = models.TextField(blank=True)
is_default = models.BooleanField(default=False)
access_type = models.CharField(max_length=20, choices=ACCESS_CHOICES, default='public')
password = models.CharField(max_length=255, blank=True)
published_at = models.DateTimeField(null=True, blank=True)
expires_at = models.DateTimeField(null=True, blank=True)
view_count = models.IntegerField(default=0)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
# Настройки дизайна для каждой коллекции
theme_color = models.CharField(max_length=7, default='#ffffff')
background_image = models.ImageField(upload_to='collections/backgrounds/', blank=True)
custom_css = models.TextField(blank=True)
class Meta:
db_table = 'link_collections'
unique_together = ['user', 'slug']
def save(self, *args, **kwargs):
if not self.slug:
base_slug = slugify(self.name)
slug = base_slug
counter = 1
while LinkCollection.objects.filter(user=self.user, slug=slug).exists():
slug = f"{base_slug}-{counter}"
counter += 1
self.slug = slug
super().save(*args, **kwargs)
def get_absolute_url(self):
if self.is_default:
return f"/{self.user.username}/"
return f"/{self.user.username}/{self.slug}/"
def __str__(self):
return f"{self.user.username}/{self.slug}"
```
#### 3. Обновление модели групп и ссылок
```python
# backend/links/models.py - добавить поле collection
class LinkGroup(models.Model):
# ... существующие поля ...
collection = models.ForeignKey(
'collections.LinkCollection',
on_delete=models.CASCADE,
related_name='groups',
null=True, # Для обратной совместимости
blank=True
)
class Link(models.Model):
# ... существующие поля ...
collection = models.ForeignKey(
'collections.LinkCollection',
on_delete=models.CASCADE,
related_name='links',
null=True, # Для обратной совместимости
blank=True
)
```
### 🔧 Система ограничений
#### 1. Декораторы для проверки лимитов
```python
# backend/subscriptions/decorators.py
from functools import wraps
from django.http import JsonResponse
from django.contrib.auth.decorators import login_required
from .models import UserSubscription
def premium_required(feature_name=None):
"""Декоратор для проверки премиум подписки"""
def decorator(view_func):
@wraps(view_func)
@login_required
def wrapped_view(request, *args, **kwargs):
try:
subscription = UserSubscription.objects.get(user=request.user)
if not subscription.is_premium():
return JsonResponse({
'error': 'Premium subscription required',
'feature': feature_name,
'upgrade_url': '/upgrade/'
}, status=403)
except UserSubscription.DoesNotExist:
return JsonResponse({
'error': 'No subscription found',
'upgrade_url': '/upgrade/'
}, status=403)
return view_func(request, *args, **kwargs)
return wrapped_view
return decorator
def check_limits(limit_type):
"""Проверка лимитов по плану"""
def decorator(view_func):
@wraps(view_func)
@login_required
def wrapped_view(request, *args, **kwargs):
try:
subscription = UserSubscription.objects.get(user=request.user)
plan = subscription.plan
if limit_type == 'collections':
current_count = request.user.linkcollection_set.count()
if current_count >= plan.max_collections:
return JsonResponse({
'error': f'Collection limit reached ({plan.max_collections})',
'upgrade_url': '/upgrade/'
}, status=403)
elif limit_type == 'groups':
current_count = request.user.linkgroup_set.count()
if current_count >= plan.max_groups:
return JsonResponse({
'error': f'Group limit reached ({plan.max_groups})',
'upgrade_url': '/upgrade/'
}, status=403)
elif limit_type == 'links':
current_count = request.user.link_set.count()
if current_count >= plan.max_links:
return JsonResponse({
'error': f'Link limit reached ({plan.max_links})',
'upgrade_url': '/upgrade/'
}, status=403)
except UserSubscription.DoesNotExist:
# Free план по умолчанию
pass
return view_func(request, *args, **kwargs)
return wrapped_view
return decorator
```
#### 2. Сервисный слой для проверки лимитов
```python
# backend/subscriptions/services.py
from .models import UserSubscription, SubscriptionPlan
from collections.models import LinkCollection
class SubscriptionService:
@staticmethod
def get_user_plan(user):
"""Получить план пользователя"""
try:
subscription = UserSubscription.objects.get(user=user)
if subscription.is_active():
return subscription.plan
except UserSubscription.DoesNotExist:
pass
# Free план по умолчанию
return SubscriptionPlan.objects.get(name='free')
@staticmethod
def can_create_collection(user):
"""Может ли пользователь создать новую коллекцию"""
plan = SubscriptionService.get_user_plan(user)
current_count = LinkCollection.objects.filter(user=user).count()
return current_count < plan.max_collections
@staticmethod
def can_create_group(user):
"""Может ли пользователь создать новую группу"""
plan = SubscriptionService.get_user_plan(user)
current_count = user.linkgroup_set.count()
return current_count < plan.max_groups
@staticmethod
def can_create_link(user):
"""Может ли пользователь создать новую ссылку"""
plan = SubscriptionService.get_user_plan(user)
current_count = user.link_set.count()
return current_count < plan.max_links
@staticmethod
def get_usage_stats(user):
"""Статистика использования"""
plan = SubscriptionService.get_user_plan(user)
return {
'plan': plan.name,
'collections': {
'current': LinkCollection.objects.filter(user=user).count(),
'limit': plan.max_collections,
'unlimited': plan.max_collections == -1
},
'groups': {
'current': user.linkgroup_set.count(),
'limit': plan.max_groups,
'unlimited': plan.max_groups == -1
},
'links': {
'current': user.link_set.count(),
'limit': plan.max_links,
'unlimited': plan.max_links == -1
},
'features': {
'analytics': plan.analytics_enabled,
'custom_domain': plan.custom_domain_enabled,
'api_access': plan.api_access_enabled,
}
}
```
### 🌐 API эндпоинты
#### 1. Subscription API
```python
# backend/subscriptions/views.py
from rest_framework import viewsets, status
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated
from .models import UserSubscription, SubscriptionPlan
from .serializers import SubscriptionSerializer, PlanSerializer
from .services import SubscriptionService
class SubscriptionViewSet(viewsets.ReadOnlyModelViewSet):
permission_classes = [IsAuthenticated]
@action(detail=False, methods=['get'])
def current(self, request):
"""Текущая подписка пользователя"""
try:
subscription = UserSubscription.objects.get(user=request.user)
serializer = SubscriptionSerializer(subscription)
return Response(serializer.data)
except UserSubscription.DoesNotExist:
# Free план
free_plan = SubscriptionPlan.objects.get(name='free')
return Response({
'plan': PlanSerializer(free_plan).data,
'status': 'free',
'expires_at': None,
'is_active': True
})
@action(detail=False, methods=['get'])
def usage(self, request):
"""Статистика использования"""
stats = SubscriptionService.get_usage_stats(request.user)
return Response(stats)
@action(detail=False, methods=['get'])
def plans(self, request):
"""Доступные планы"""
plans = SubscriptionPlan.objects.all()
serializer = PlanSerializer(plans, many=True)
return Response(serializer.data)
class CollectionViewSet(viewsets.ModelViewSet):
permission_classes = [IsAuthenticated]
def get_queryset(self):
return LinkCollection.objects.filter(user=self.request.user)
def perform_create(self, serializer):
if not SubscriptionService.can_create_collection(self.request.user):
plan = SubscriptionService.get_user_plan(self.request.user)
raise ValidationError(
f"Collection limit reached ({plan.max_collections}). "
f"Upgrade to Premium for unlimited collections."
)
serializer.save(user=self.request.user)
```
#### 2. URL маршруты
```python
# backend/subscriptions/urls.py
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import SubscriptionViewSet
router = DefaultRouter()
router.register(r'subscriptions', SubscriptionViewSet, basename='subscription')
urlpatterns = [
path('api/', include(router.urls)),
]
# backend/collections/urls.py
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import CollectionViewSet
router = DefaultRouter()
router.register(r'collections', CollectionViewSet, basename='collection')
urlpatterns = [
path('api/', include(router.urls)),
]
```
### 💳 Интеграция со Stripe
#### 1. Настройки Stripe
```python
# backend/settings.py
import stripe
STRIPE_PUBLISHABLE_KEY = os.environ.get('STRIPE_PUBLISHABLE_KEY')
STRIPE_SECRET_KEY = os.environ.get('STRIPE_SECRET_KEY')
STRIPE_WEBHOOK_SECRET = os.environ.get('STRIPE_WEBHOOK_SECRET')
stripe.api_key = STRIPE_SECRET_KEY
```
#### 2. Stripe сервисы
```python
# backend/subscriptions/stripe_services.py
import stripe
from django.conf import settings
from .models import UserSubscription, SubscriptionPlan
class StripeService:
@staticmethod
def create_customer(user):
"""Создать клиента в Stripe"""
customer = stripe.Customer.create(
email=user.email,
name=user.get_full_name() or user.username,
metadata={'user_id': user.id}
)
return customer.id
@staticmethod
def create_subscription(user, plan_name, payment_method_id):
"""Создать подписку"""
plan = SubscriptionPlan.objects.get(name=plan_name)
# Получить или создать клиента
try:
user_subscription = UserSubscription.objects.get(user=user)
customer_id = user_subscription.stripe_customer_id
if not customer_id:
customer_id = StripeService.create_customer(user)
user_subscription.stripe_customer_id = customer_id
user_subscription.save()
except UserSubscription.DoesNotExist:
customer_id = StripeService.create_customer(user)
# Прикрепить способ оплаты
stripe.PaymentMethod.attach(
payment_method_id,
customer=customer_id,
)
# Создать подписку в Stripe
subscription = stripe.Subscription.create(
customer=customer_id,
items=[{
'price': plan.stripe_price_id,
}],
default_payment_method=payment_method_id,
metadata={
'user_id': user.id,
'plan_name': plan_name
}
)
return subscription
@staticmethod
def cancel_subscription(stripe_subscription_id):
"""Отменить подписку"""
return stripe.Subscription.cancel(stripe_subscription_id)
```
#### 3. Webhook обработка
```python
# backend/subscriptions/webhooks.py
import json
import stripe
from django.http import HttpResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from django.conf import settings
from .models import UserSubscription, PaymentHistory
@csrf_exempt
@require_POST
def stripe_webhook(request):
payload = request.body
sig_header = request.META.get('HTTP_STRIPE_SIGNATURE')
try:
event = stripe.Webhook.construct_event(
payload, sig_header, settings.STRIPE_WEBHOOK_SECRET
)
except ValueError:
return HttpResponse(status=400)
except stripe.error.SignatureVerificationError:
return HttpResponse(status=400)
# Обработка событий
if event['type'] == 'invoice.payment_succeeded':
handle_payment_succeeded(event['data']['object'])
elif event['type'] == 'customer.subscription.deleted':
handle_subscription_cancelled(event['data']['object'])
elif event['type'] == 'customer.subscription.updated':
handle_subscription_updated(event['data']['object'])
return HttpResponse(status=200)
def handle_payment_succeeded(invoice):
"""Обработка успешного платежа"""
subscription_id = invoice['subscription']
customer_id = invoice['customer']
amount = invoice['amount_paid'] / 100 # Stripe в центах
try:
user_subscription = UserSubscription.objects.get(
stripe_subscription_id=subscription_id
)
# Обновить срок подписки
user_subscription.status = 'active'
user_subscription.save()
# Записать платеж в историю
PaymentHistory.objects.create(
subscription=user_subscription,
amount=amount,
stripe_payment_id=invoice['payment_intent'],
status='succeeded'
)
except UserSubscription.DoesNotExist:
pass # Логирование ошибки
```
### 📱 Frontend компоненты
#### 1. Компонент управления подпиской
```typescript
// frontend/src/app/components/SubscriptionManager.tsx
import React, { useState, useEffect } from 'react';
import { useLocale } from '../contexts/LocaleContext';
interface SubscriptionStats {
plan: string;
collections: { current: number; limit: number; unlimited: boolean };
groups: { current: number; limit: number; unlimited: boolean };
links: { current: number; limit: number; unlimited: boolean };
features: {
analytics: boolean;
custom_domain: boolean;
api_access: boolean;
};
}
const SubscriptionManager: React.FC = () => {
const { t } = useLocale();
const [stats, setStats] = useState<SubscriptionStats | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadSubscriptionStats();
}, []);
const loadSubscriptionStats = async () => {
try {
const token = localStorage.getItem('token');
const response = await fetch('/api/subscriptions/usage/', {
headers: { 'Authorization': `Bearer ${token}` }
});
const data = await response.json();
setStats(data);
} catch (error) {
console.error('Error loading subscription stats:', error);
} finally {
setLoading(false);
}
};
const getProgressColor = (current: number, limit: number, unlimited: boolean) => {
if (unlimited) return 'success';
const percentage = (current / limit) * 100;
if (percentage >= 90) return 'danger';
if (percentage >= 75) return 'warning';
return 'primary';
};
if (loading || !stats) {
return <div className="spinner-border" />;
}
return (
<div className="card">
<div className="card-header d-flex justify-content-between align-items-center">
<h5 className="mb-0">{t('subscription.currentPlan')}</h5>
<span className={`badge bg-${stats.plan === 'free' ? 'secondary' : 'primary'}`}>
{stats.plan.toUpperCase()}
</span>
</div>
<div className="card-body">
{/* Usage Stats */}
<div className="row">
<div className="col-md-4">
<div className="mb-3">
<label className="form-label">{t('subscription.collections')}</label>
<div className="progress">
<div
className={`progress-bar bg-${getProgressColor(
stats.collections.current,
stats.collections.limit,
stats.collections.unlimited
)}`}
style={{
width: stats.collections.unlimited
? '100%'
: `${(stats.collections.current / stats.collections.limit) * 100}%`
}}
/>
</div>
<small className="text-muted">
{stats.collections.current} / {stats.collections.unlimited ? '∞' : stats.collections.limit}
</small>
</div>
</div>
<div className="col-md-4">
<div className="mb-3">
<label className="form-label">{t('subscription.groups')}</label>
<div className="progress">
<div
className={`progress-bar bg-${getProgressColor(
stats.groups.current,
stats.groups.limit,
stats.groups.unlimited
)}`}
style={{
width: stats.groups.unlimited
? '100%'
: `${(stats.groups.current / stats.groups.limit) * 100}%`
}}
/>
</div>
<small className="text-muted">
{stats.groups.current} / {stats.groups.unlimited ? '∞' : stats.groups.limit}
</small>
</div>
</div>
<div className="col-md-4">
<div className="mb-3">
<label className="form-label">{t('subscription.links')}</label>
<div className="progress">
<div
className={`progress-bar bg-${getProgressColor(
stats.links.current,
stats.links.limit,
stats.links.unlimited
)}`}
style={{
width: stats.links.unlimited
? '100%'
: `${(stats.links.current / stats.links.limit) * 100}%`
}}
/>
</div>
<small className="text-muted">
{stats.links.current} / {stats.links.unlimited ? '∞' : stats.links.limit}
</small>
</div>
</div>
</div>
{/* Features */}
<div className="row mt-3">
<div className="col-12">
<h6>{t('subscription.features')}</h6>
<div className="d-flex gap-3">
<span className={`badge ${stats.features.analytics ? 'bg-success' : 'bg-secondary'}`}>
<i className={`fas fa-${stats.features.analytics ? 'check' : 'times'} me-1`} />
{t('subscription.analytics')}
</span>
<span className={`badge ${stats.features.custom_domain ? 'bg-success' : 'bg-secondary'}`}>
<i className={`fas fa-${stats.features.custom_domain ? 'check' : 'times'} me-1`} />
{t('subscription.customDomain')}
</span>
<span className={`badge ${stats.features.api_access ? 'bg-success' : 'bg-secondary'}`}>
<i className={`fas fa-${stats.features.api_access ? 'check' : 'times'} me-1`} />
{t('subscription.apiAccess')}
</span>
</div>
</div>
</div>
{/* Upgrade Button */}
{stats.plan === 'free' && (
<div className="mt-4 text-center">
<button className="btn btn-primary btn-lg">
<i className="fas fa-rocket me-2" />
{t('subscription.upgradeToPremium')}
</button>
</div>
)}
</div>
</div>
);
};
export default SubscriptionManager;
```
#### 2. Компонент апгрейда
```typescript
// frontend/src/app/components/UpgradeModal.tsx
import React, { useState } from 'react';
import { loadStripe } from '@stripe/stripe-js';
import { Elements, CardElement, useStripe, useElements } from '@stripe/react-stripe-js';
const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!);
interface Plan {
name: string;
display_name: string;
price_monthly: number;
price_yearly: number;
features: string[];
}
interface UpgradeModalProps {
isOpen: boolean;
onClose: () => void;
}
const CheckoutForm: React.FC<{ plan: Plan; onSuccess: () => void }> = ({ plan, onSuccess }) => {
const stripe = useStripe();
const elements = useElements();
const [processing, setProcessing] = useState(false);
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
if (!stripe || !elements) return;
setProcessing(true);
const cardElement = elements.getElement(CardElement);
if (!cardElement) return;
const { error, paymentMethod } = await stripe.createPaymentMethod({
type: 'card',
card: cardElement,
});
if (error) {
console.error(error);
setProcessing(false);
return;
}
// Создать подписку через API
try {
const token = localStorage.getItem('token');
const response = await fetch('/api/subscriptions/create/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
plan_name: plan.name,
payment_method_id: paymentMethod.id
})
});
if (response.ok) {
onSuccess();
}
} catch (error) {
console.error('Subscription creation failed:', error);
} finally {
setProcessing(false);
}
};
return (
<form onSubmit={handleSubmit}>
<div className="mb-3">
<CardElement
options={{
style: {
base: {
fontSize: '16px',
color: '#424770',
'::placeholder': {
color: '#aab7c4',
},
},
},
}}
/>
</div>
<button
type="submit"
className="btn btn-primary w-100"
disabled={!stripe || processing}
>
{processing ? 'Обработка...' : `Подписаться на ${plan.display_name}`}
</button>
</form>
);
};
export const UpgradeModal: React.FC<UpgradeModalProps> = ({ isOpen, onClose }) => {
const [selectedPlan, setSelectedPlan] = useState<Plan | null>(null);
const plans: Plan[] = [
{
name: 'premium',
display_name: 'Premium',
price_monthly: 5,
price_yearly: 50,
features: ['Unlimited collections', 'Advanced analytics', 'Custom themes']
},
{
name: 'business',
display_name: 'Business',
price_monthly: 15,
price_yearly: 150,
features: ['Everything in Premium', 'Team collaboration', 'Custom domain', 'API access']
}
];
if (!isOpen) return null;
return (
<div className="modal fade show d-block" style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}>
<div className="modal-dialog modal-lg">
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title">Upgrade Your Plan</h5>
<button type="button" className="btn-close" onClick={onClose} />
</div>
<div className="modal-body">
{!selectedPlan ? (
<div className="row">
{plans.map((plan) => (
<div key={plan.name} className="col-md-6 mb-3">
<div className="card h-100">
<div className="card-body text-center">
<h4>{plan.display_name}</h4>
<div className="mb-3">
<span className="h3">${plan.price_monthly}</span>
<span className="text-muted">/month</span>
</div>
<ul className="list-unstyled">
{plan.features.map((feature, index) => (
<li key={index} className="mb-2">
<i className="fas fa-check text-success me-2" />
{feature}
</li>
))}
</ul>
<button
className="btn btn-primary w-100"
onClick={() => setSelectedPlan(plan)}
>
Choose {plan.display_name}
</button>
</div>
</div>
</div>
))}
</div>
) : (
<Elements stripe={stripePromise}>
<CheckoutForm
plan={selectedPlan}
onSuccess={() => {
onClose();
window.location.reload(); // Обновить страницу после успешной подписки
}}
/>
</Elements>
)}
</div>
</div>
</div>
</div>
);
};
export default UpgradeModal;
```
### 🗃️ Миграции базы данных
```sql
-- Создание планов подписок
INSERT INTO subscription_plans (name, display_name, price_monthly, price_yearly, description, max_collections, max_groups, max_links, analytics_enabled, custom_domain_enabled, api_access_enabled) VALUES
('free', 'Free', 0, 0, 'Basic features for personal use', 1, 10, 50, false, false, false),
('premium', 'Premium', 5, 50, 'Advanced features for creators', 5, -1, -1, true, false, false),
('business', 'Business', 15, 150, 'Professional features for teams', -1, -1, -1, true, true, true);
-- Создание дефолтных подписок для существующих пользователей
INSERT INTO user_subscriptions (user_id, plan_id, status, starts_at, expires_at)
SELECT
u.id,
(SELECT id FROM subscription_plans WHERE name = 'free'),
'active',
NOW(),
'2099-12-31 23:59:59'
FROM auth_user u
LEFT JOIN user_subscriptions us ON u.id = us.user_id
WHERE us.id IS NULL;
-- Создание дефолтных коллекций для существующих пользователей
INSERT INTO link_collections (user_id, name, slug, is_default, access_type, created_at, updated_at)
SELECT
u.id,
'Main Collection',
'main',
true,
'public',
NOW(),
NOW()
FROM auth_user u
LEFT JOIN link_collections lc ON u.id = lc.user_id
WHERE lc.id IS NULL;
-- Привязка существующих групп и ссылок к дефолтным коллекциям
UPDATE link_groups lg
SET collection_id = (
SELECT lc.id
FROM link_collections lc
WHERE lc.user_id = lg.user_id AND lc.is_default = true
)
WHERE lg.collection_id IS NULL;
UPDATE links l
SET collection_id = (
SELECT lc.id
FROM link_collections lc
WHERE lc.user_id = l.user_id AND lc.is_default = true
)
WHERE l.collection_id IS NULL;
```
Этот план обеспечивает полную основу для премиум функционала с проверкой лимитов, интеграцией Stripe и современным React интерфейсом.

View File

@@ -60,6 +60,7 @@ INSTALLED_APPS = [
'links',
'customization',
'api',
'export_import',
'rest_framework',
'rest_framework_simplejwt',
'django_extensions',

View File

@@ -9,6 +9,7 @@ urlpatterns = [
path('admin/', admin.site.urls),
path('api/', include('api.urls')), # API endpoints
path('api/users/', include('users.urls')), # User management API
path('api/', include('export_import.urls')), # Export/Import API
path('users/', include('users.urls')), # User management app
path('links/', include('links.urls')), # Link management app
path('api-auth/', include('rest_framework.urls', namespace='rest_framework')),

View File

View File

@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

View File

@@ -0,0 +1,7 @@
from django.apps import AppConfig
class ExportImportConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'export_import'
verbose_name = 'Export/Import'

View File

@@ -0,0 +1,63 @@
# Generated by Django 5.2.8 on 2025-11-09 05:08
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='ExportTask',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('status', models.CharField(choices=[('pending', 'Ожидает выполнения'), ('processing', 'В процессе'), ('completed', 'Завершен'), ('failed', 'Ошибка')], default='pending', max_length=20, verbose_name='Статус')),
('include_groups', models.BooleanField(default=True, verbose_name='Включить группы')),
('include_links', models.BooleanField(default=True, verbose_name='Включить ссылки')),
('include_styles', models.BooleanField(default=True, verbose_name='Включить стили')),
('include_media', models.BooleanField(default=True, verbose_name='Включить медиафайлы')),
('export_file', models.FileField(blank=True, null=True, upload_to='exports/', verbose_name='Файл экспорта')),
('error_message', models.TextField(blank=True, verbose_name='Сообщение об ошибке')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создано')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Обновлено')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='Пользователь')),
],
options={
'verbose_name': 'Задача экспорта',
'verbose_name_plural': 'Задачи экспорта',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='ImportTask',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('status', models.CharField(choices=[('pending', 'Ожидает выполнения'), ('processing', 'В процессе'), ('completed', 'Завершен'), ('failed', 'Ошибка')], default='pending', max_length=20, verbose_name='Статус')),
('import_file', models.FileField(upload_to='imports/', verbose_name='Файл для импорта')),
('import_groups', models.BooleanField(default=True, verbose_name='Импортировать группы')),
('import_links', models.BooleanField(default=True, verbose_name='Импортировать ссылки')),
('import_styles', models.BooleanField(default=True, verbose_name='Импортировать стили')),
('import_media', models.BooleanField(default=True, verbose_name='Импортировать медиафайлы')),
('overwrite_existing', models.BooleanField(default=False, verbose_name='Перезаписать существующие')),
('imported_groups_count', models.PositiveIntegerField(default=0, verbose_name='Импортировано групп')),
('imported_links_count', models.PositiveIntegerField(default=0, verbose_name='Импортировано ссылок')),
('imported_media_count', models.PositiveIntegerField(default=0, verbose_name='Импортировано медиафайлов')),
('error_message', models.TextField(blank=True, verbose_name='Сообщение об ошибке')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создано')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Обновлено')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='Пользователь')),
],
options={
'verbose_name': 'Задача импорта',
'verbose_name_plural': 'Задачи импорта',
'ordering': ['-created_at'],
},
),
]

View File

@@ -0,0 +1,92 @@
from django.db import models
from django.contrib.auth import get_user_model
from django.utils import timezone
User = get_user_model()
class ExportTask(models.Model):
"""Модель для отслеживания задач экспорта профиля"""
STATUS_CHOICES = [
('pending', 'Ожидает выполнения'),
('processing', 'В процессе'),
('completed', 'Завершен'),
('failed', 'Ошибка'),
]
user = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name='Пользователь')
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending', verbose_name='Статус')
# Опции экспорта
include_groups = models.BooleanField(default=True, verbose_name='Включить группы')
include_links = models.BooleanField(default=True, verbose_name='Включить ссылки')
include_styles = models.BooleanField(default=True, verbose_name='Включить стили')
include_media = models.BooleanField(default=True, verbose_name='Включить медиафайлы')
# Файл с результатом
export_file = models.FileField(
upload_to='exports/',
null=True,
blank=True,
verbose_name='Файл экспорта'
)
# Логирование
error_message = models.TextField(blank=True, verbose_name='Сообщение об ошибке')
created_at = models.DateTimeField(auto_now_add=True, verbose_name='Создано')
updated_at = models.DateTimeField(auto_now=True, verbose_name='Обновлено')
class Meta:
verbose_name = 'Задача экспорта'
verbose_name_plural = 'Задачи экспорта'
ordering = ['-created_at']
def __str__(self):
return f'Экспорт {self.user.username} - {self.get_status_display()}'
class ImportTask(models.Model):
"""Модель для отслеживания задач импорта профиля"""
STATUS_CHOICES = [
('pending', 'Ожидает выполнения'),
('processing', 'В процессе'),
('completed', 'Завершен'),
('failed', 'Ошибка'),
]
user = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name='Пользователь')
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending', verbose_name='Статус')
# Файл для импорта
import_file = models.FileField(upload_to='imports/', verbose_name='Файл для импорта')
# Опции импорта
import_groups = models.BooleanField(default=True, verbose_name='Импортировать группы')
import_links = models.BooleanField(default=True, verbose_name='Импортировать ссылки')
import_styles = models.BooleanField(default=True, verbose_name='Импортировать стили')
import_media = models.BooleanField(default=True, verbose_name='Импортировать медиафайлы')
# Стратегия конфликтов
overwrite_existing = models.BooleanField(default=False, verbose_name='Перезаписать существующие')
# Результаты импорта
imported_groups_count = models.PositiveIntegerField(default=0, verbose_name='Импортировано групп')
imported_links_count = models.PositiveIntegerField(default=0, verbose_name='Импортировано ссылок')
imported_media_count = models.PositiveIntegerField(default=0, verbose_name='Импортировано медиафайлов')
# Логирование
error_message = models.TextField(blank=True, verbose_name='Сообщение об ошибке')
created_at = models.DateTimeField(auto_now_add=True, verbose_name='Создано')
updated_at = models.DateTimeField(auto_now=True, verbose_name='Обновлено')
class Meta:
verbose_name = 'Задача импорта'
verbose_name_plural = 'Задачи импорта'
ordering = ['-created_at']
def __str__(self):
return f'Импорт {self.user.username} - {self.get_status_display()}'

View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

View File

@@ -0,0 +1,16 @@
from django.urls import path
from . import views
urlpatterns = [
# Экспорт профиля
path('export/', views.create_export, name='create_export'),
path('export/<int:task_id>/', views.export_status, name='export_status'),
path('export/<int:task_id>/download/', views.download_export, name='download_export'),
path('export/list/', views.export_list, name='export_list'),
# Импорт профиля
path('import/', views.create_import, name='create_import'),
path('import/<int:task_id>/', views.import_status, name='import_status'),
path('import/list/', views.import_list, name='import_list'),
path('import/preview/', views.preview_import, name='preview_import'),
]

View File

@@ -0,0 +1,732 @@
import json
import zipfile
import tempfile
import os
from pathlib import Path
from django.http import HttpResponse, Http404
from django.conf import settings
from django.shortcuts import get_object_or_404
from django.core.files.storage import default_storage
from django.core.files.base import ContentFile
from django.db import transaction
from rest_framework import status
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from django.utils import timezone
import shutil
from .models import ExportTask, ImportTask
from users.models import User
from links.models import LinkGroup, Link
from customization.models import DesignSettings
@api_view(['POST'])
@permission_classes([IsAuthenticated])
def create_export(request):
"""Создание задачи экспорта профиля"""
# Получаем параметры экспорта
include_groups = request.data.get('include_groups', True)
include_links = request.data.get('include_links', True)
include_styles = request.data.get('include_styles', True)
include_media = request.data.get('include_media', True)
# Создаем задачу экспорта
export_task = ExportTask.objects.create(
user=request.user,
include_groups=include_groups,
include_links=include_links,
include_styles=include_styles,
include_media=include_media,
)
try:
# Обновляем статус
export_task.status = 'processing'
export_task.save()
# Создаем архив с данными профиля
export_file_path = _create_profile_archive(export_task)
# Сохраняем путь к файлу в задаче
with open(export_file_path, 'rb') as f:
export_task.export_file.save(
f'profile_export_{export_task.user.username}_{timezone.now().strftime("%Y%m%d_%H%M%S")}.zip',
ContentFile(f.read()),
save=True
)
# Удаляем временный файл
os.remove(export_file_path)
export_task.status = 'completed'
export_task.save()
return Response({
'task_id': export_task.id,
'status': export_task.status,
'download_url': f'/api/export/{export_task.id}/download/',
'message': 'Экспорт профиля завершен успешно'
})
except Exception as e:
export_task.status = 'failed'
export_task.error_message = str(e)
export_task.save()
return Response({
'error': 'Ошибка при создании экспорта',
'details': str(e)
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
@api_view(['GET'])
@permission_classes([IsAuthenticated])
def download_export(request, task_id):
"""Скачивание файла экспорта"""
export_task = get_object_or_404(ExportTask, id=task_id, user=request.user)
if export_task.status != 'completed' or not export_task.export_file:
return Response({
'error': 'Файл экспорта недоступен'
}, status=status.HTTP_404_NOT_FOUND)
try:
response = HttpResponse(
export_task.export_file.read(),
content_type='application/zip'
)
response['Content-Disposition'] = f'attachment; filename="profile_export_{request.user.username}.zip"'
return response
except FileNotFoundError:
raise Http404("Файл экспорта не найден")
@api_view(['GET'])
@permission_classes([IsAuthenticated])
def export_status(request, task_id):
"""Получение статуса задачи экспорта"""
export_task = get_object_or_404(ExportTask, id=task_id, user=request.user)
return Response({
'task_id': export_task.id,
'status': export_task.status,
'created_at': export_task.created_at,
'updated_at': export_task.updated_at,
'error_message': export_task.error_message,
'download_url': f'/api/export/{export_task.id}/download/' if export_task.status == 'completed' else None
})
def _create_profile_archive(export_task):
"""Создание архива с данными профиля"""
user = export_task.user
# Создаем временную директорию
with tempfile.TemporaryDirectory() as temp_dir:
profile_dir = Path(temp_dir) / 'profile_export'
profile_dir.mkdir()
# Создаем структуру данных для экспорта
export_data = {
'export_info': {
'username': user.username,
'export_date': timezone.now().isoformat(),
'export_options': {
'include_groups': export_task.include_groups,
'include_links': export_task.include_links,
'include_styles': export_task.include_styles,
'include_media': export_task.include_media,
}
},
'user_data': {
'username': user.username,
'email': user.email,
'first_name': user.first_name,
'last_name': user.last_name,
'bio': getattr(user, 'bio', ''),
'avatar': user.avatar.url if user.avatar else None,
'cover': user.cover.url if user.cover else None,
},
'groups': [],
'links': [],
'design_settings': {},
}
# Экспорт групп
if export_task.include_groups:
for group in LinkGroup.objects.filter(owner=user):
group_data = {
'id': group.id,
'title': group.title,
'description': group.description,
'image': group.image.url if group.image else None,
'is_active': group.is_active,
'is_public': group.is_public,
'is_featured': group.is_featured,
'created_at': group.created_at.isoformat(),
'order': group.order,
}
export_data['groups'].append(group_data)
# Экспорт ссылок
if export_task.include_links:
for link in Link.objects.filter(group__owner=user):
link_data = {
'id': link.id,
'group_id': link.group.id,
'title': link.title,
'url': link.url,
'description': link.description,
'image': link.image.url if link.image else None,
'is_active': link.is_active,
'is_public': link.is_public,
'is_featured': link.is_featured,
'created_at': link.created_at.isoformat(),
'order': link.order,
}
export_data['links'].append(link_data)
# Экспорт настроек дизайна
if export_task.include_styles:
try:
design_settings = DesignSettings.objects.get(user=user)
export_data['design_settings'] = {
'background_image': design_settings.background_image.url if design_settings.background_image else None,
'theme_color': design_settings.theme_color,
'dashboard_layout': design_settings.dashboard_layout,
'groups_default_expanded': design_settings.groups_default_expanded,
'show_group_icons': design_settings.show_group_icons,
'show_link_icons': design_settings.show_link_icons,
'dashboard_background_color': design_settings.dashboard_background_color,
'font_family': design_settings.font_family,
'custom_css': design_settings.custom_css,
'header_text_color': design_settings.header_text_color,
'group_text_color': design_settings.group_text_color,
'link_text_color': design_settings.link_text_color,
'cover_overlay_enabled': design_settings.cover_overlay_enabled,
'cover_overlay_color': design_settings.cover_overlay_color,
'cover_overlay_opacity': design_settings.cover_overlay_opacity,
'group_overlay_enabled': design_settings.group_overlay_enabled,
'group_overlay_color': design_settings.group_overlay_color,
'group_overlay_opacity': design_settings.group_overlay_opacity,
'show_groups_title': design_settings.show_groups_title,
'group_description_text_color': design_settings.group_description_text_color,
'body_font_family': design_settings.body_font_family,
'heading_font_family': design_settings.heading_font_family,
'template_id': design_settings.template_id,
'link_overlay_enabled': design_settings.link_overlay_enabled,
'link_overlay_color': design_settings.link_overlay_color,
'link_overlay_opacity': design_settings.link_overlay_opacity,
}
except DesignSettings.DoesNotExist:
export_data['design_settings'] = {}
# Сохраняем данные в JSON
json_file = profile_dir / 'profile_data.json'
with open(json_file, 'w', encoding='utf-8') as f:
json.dump(export_data, f, indent=2, ensure_ascii=False)
# Копируем медиафайлы
if export_task.include_media:
media_dir = profile_dir / 'media'
media_dir.mkdir()
# Копируем файлы пользователя
_copy_user_media_files(user, media_dir, export_data)
# Создаем ZIP архив
archive_path = temp_dir + '.zip'
with zipfile.ZipFile(archive_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
for root, dirs, files in os.walk(profile_dir):
for file in files:
file_path = Path(root) / file
arc_name = file_path.relative_to(profile_dir)
zipf.write(file_path, arc_name)
return archive_path
def _copy_user_media_files(user, media_dir, export_data):
"""Копирование медиафайлов пользователя"""
media_root = Path(settings.MEDIA_ROOT)
# Функция для копирования файла
def copy_file_if_exists(url, subdir):
if url and url.startswith('/storage/'):
file_path = media_root / url[9:] # убираем /storage/
if file_path.exists():
target_dir = media_dir / subdir
target_dir.mkdir(exist_ok=True, parents=True)
shutil.copy2(file_path, target_dir / file_path.name)
# Аватар и обложка пользователя
copy_file_if_exists(export_data['user_data']['avatar'], 'avatars')
copy_file_if_exists(export_data['user_data']['cover'], 'avatars')
# Фоновые изображения в настройках дизайна
if export_data['design_settings'].get('background_image'):
copy_file_if_exists(export_data['design_settings']['background_image'], 'customization')
# Изображения групп
for group in export_data['groups']:
copy_file_if_exists(group.get('image'), 'link_groups')
# Изображения ссылок
for link in export_data['links']:
copy_file_if_exists(link.get('image'), 'links')
@api_view(['GET'])
def export_list(request):
"""Получение списка задач экспорта пользователя"""
# Для тестирования - простой ответ
if not request.user.is_authenticated:
return Response({
'message': 'Export API доступен',
'authenticated': False
})
export_tasks = ExportTask.objects.filter(user=request.user)
tasks_data = []
for task in export_tasks:
tasks_data.append({
'id': task.id,
'status': task.status,
'created_at': task.created_at,
'updated_at': task.updated_at,
'include_groups': task.include_groups,
'include_links': task.include_links,
'include_styles': task.include_styles,
'include_media': task.include_media,
'download_url': f'/api/export/{task.id}/download/' if task.status == 'completed' else None,
'error_message': task.error_message,
})
return Response({
'tasks': tasks_data,
'count': len(tasks_data)
})
@api_view(['POST'])
@permission_classes([IsAuthenticated])
def create_import(request):
"""Создание задачи импорта профиля"""
# Проверяем наличие файла
if 'import_file' not in request.FILES:
return Response({
'error': 'Файл для импорта не предоставлен'
}, status=status.HTTP_400_BAD_REQUEST)
import_file = request.FILES['import_file']
# Проверяем тип файла
if not import_file.name.endswith('.zip'):
return Response({
'error': 'Поддерживаются только ZIP архивы'
}, status=status.HTTP_400_BAD_REQUEST)
# Получаем параметры импорта из POST данных (для multipart/form-data)
def get_bool_param(name, default=True):
value = request.data.get(name, request.POST.get(name, str(default)))
return str(value).lower() in ('true', '1', 'yes', 'on')
import_groups = get_bool_param('import_groups', True)
import_links = get_bool_param('import_links', True)
import_styles = get_bool_param('import_styles', True)
import_media = get_bool_param('import_media', True)
overwrite_existing = get_bool_param('overwrite_existing', False)
# Создаем задачу импорта
try:
import_task = ImportTask.objects.create(
user=request.user,
import_file=import_file,
import_groups=import_groups,
import_links=import_links,
import_styles=import_styles,
import_media=import_media,
overwrite_existing=overwrite_existing,
)
# Обновляем статус
import_task.status = 'processing'
import_task.save()
# Выполняем импорт
_process_import(import_task)
import_task.status = 'completed'
import_task.save()
return Response({
'task_id': import_task.id,
'status': import_task.status,
'imported_groups_count': import_task.imported_groups_count,
'imported_links_count': import_task.imported_links_count,
'imported_media_count': import_task.imported_media_count,
'message': 'Импорт профиля завершен успешно'
})
except Exception as e:
# Если задача была создана, обновляем её статус
if 'import_task' in locals():
import_task.status = 'failed'
import_task.error_message = str(e)
import_task.save()
return Response({
'error': 'Ошибка при импорте',
'details': str(e)
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
@api_view(['GET'])
@permission_classes([IsAuthenticated])
def import_status(request, task_id):
"""Получение статуса задачи импорта"""
import_task = get_object_or_404(ImportTask, id=task_id, user=request.user)
return Response({
'task_id': import_task.id,
'status': import_task.status,
'created_at': import_task.created_at,
'updated_at': import_task.updated_at,
'import_groups': import_task.import_groups,
'import_links': import_task.import_links,
'import_styles': import_task.import_styles,
'import_media': import_task.import_media,
'overwrite_existing': import_task.overwrite_existing,
'imported_groups_count': import_task.imported_groups_count,
'imported_links_count': import_task.imported_links_count,
'imported_media_count': import_task.imported_media_count,
'error_message': import_task.error_message,
})
@api_view(['GET'])
@permission_classes([IsAuthenticated])
def import_list(request):
"""Получение списка задач импорта пользователя"""
import_tasks = ImportTask.objects.filter(user=request.user)
tasks_data = []
for task in import_tasks:
tasks_data.append({
'id': task.id,
'status': task.status,
'created_at': task.created_at,
'updated_at': task.updated_at,
'import_groups': task.import_groups,
'import_links': task.import_links,
'import_styles': task.import_styles,
'import_media': task.import_media,
'overwrite_existing': task.overwrite_existing,
'imported_groups_count': task.imported_groups_count,
'imported_links_count': task.imported_links_count,
'imported_media_count': task.imported_media_count,
'error_message': task.error_message,
})
return Response({
'tasks': tasks_data,
'count': len(tasks_data)
})
@api_view(['POST'])
@permission_classes([IsAuthenticated])
def preview_import(request):
"""Предварительный просмотр содержимого архива импорта"""
if 'import_file' not in request.FILES:
return Response({
'error': 'Файл для импорта не предоставлен'
}, status=status.HTTP_400_BAD_REQUEST)
import_file = request.FILES['import_file']
if not import_file.name.endswith('.zip'):
return Response({
'error': 'Поддерживаются только ZIP архивы'
}, status=status.HTTP_400_BAD_REQUEST)
try:
with tempfile.TemporaryDirectory() as temp_dir:
archive_path = Path(temp_dir) / 'preview.zip'
# Сохраняем файл
with open(archive_path, 'wb') as f:
for chunk in import_file.chunks():
f.write(chunk)
# Извлекаем архив
extract_dir = Path(temp_dir) / 'extracted'
with zipfile.ZipFile(archive_path, 'r') as zipf:
zipf.extractall(extract_dir)
# Читаем данные профиля
profile_data_path = extract_dir / 'profile_data.json'
if not profile_data_path.exists():
return Response({
'error': 'Файл profile_data.json не найден в архиве'
}, status=status.HTTP_400_BAD_REQUEST)
with open(profile_data_path, 'r', encoding='utf-8') as f:
profile_data = json.load(f)
# Формируем превью
preview = {
'export_info': profile_data.get('export_info', {}),
'user_data': profile_data.get('user_data', {}),
'groups_count': len(profile_data.get('groups', [])),
'links_count': len(profile_data.get('links', [])),
'has_design_settings': bool(profile_data.get('design_settings')),
'media_files': _count_media_files(extract_dir),
'groups_preview': profile_data.get('groups', [])[:5], # Первые 5 групп для превью
'links_preview': profile_data.get('links', [])[:10], # Первые 10 ссылок для превью
}
return Response(preview)
except Exception as e:
return Response({
'error': 'Ошибка при обработке архива',
'details': str(e)
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
def _count_media_files(extract_dir):
"""Подсчет медиафайлов в архиве"""
media_dir = extract_dir / 'media'
if not media_dir.exists():
return {}
counts = {}
for category in ['avatars', 'customization', 'link_groups', 'links']:
category_dir = media_dir / category
if category_dir.exists():
counts[category] = len([f for f in category_dir.iterdir() if f.is_file()])
else:
counts[category] = 0
return counts
def _process_import(import_task):
"""Обработка импорта профиля"""
user = import_task.user
# Создаем временную директорию для извлечения архива
with tempfile.TemporaryDirectory() as temp_dir:
archive_path = Path(temp_dir) / 'import.zip'
# Сохраняем файл во временную директорию
with open(archive_path, 'wb') as f:
for chunk in import_task.import_file.chunks():
f.write(chunk)
# Извлекаем архив
extract_dir = Path(temp_dir) / 'extracted'
with zipfile.ZipFile(archive_path, 'r') as zipf:
zipf.extractall(extract_dir)
# Читаем данные профиля
profile_data_path = extract_dir / 'profile_data.json'
if not profile_data_path.exists():
raise Exception('Файл profile_data.json не найден в архиве')
with open(profile_data_path, 'r', encoding='utf-8') as f:
profile_data = json.load(f)
# Импортируем данные в транзакции
with transaction.atomic():
_import_profile_data(import_task, profile_data, extract_dir)
def _import_profile_data(import_task, profile_data, extract_dir):
"""Импорт данных профиля"""
user = import_task.user
# Импорт групп
if import_task.import_groups and 'groups' in profile_data:
groups_count = _import_groups(user, profile_data['groups'], import_task.overwrite_existing)
import_task.imported_groups_count = groups_count
# Импорт ссылок
if import_task.import_links and 'links' in profile_data:
links_count = _import_links(user, profile_data['links'], profile_data.get('groups', []), import_task.overwrite_existing)
import_task.imported_links_count = links_count
# Импорт настроек дизайна
if import_task.import_styles and 'design_settings' in profile_data:
_import_design_settings(user, profile_data['design_settings'], import_task.overwrite_existing)
# Импорт медиафайлов
if import_task.import_media:
media_count = _import_media_files(user, extract_dir, import_task.overwrite_existing)
import_task.imported_media_count = media_count
import_task.save()
def _import_groups(user, groups_data, overwrite_existing):
"""Импорт групп ссылок"""
imported_count = 0
for group_data in groups_data:
# Проверяем существование группы по названию
existing_group = LinkGroup.objects.filter(
owner=user,
title=group_data['title']
).first()
if existing_group and not overwrite_existing:
continue # Пропускаем если группа существует и перезапись отключена
# Создаем или обновляем группу
group_defaults = {
'description': group_data.get('description', ''),
'is_active': group_data.get('is_active', True),
'is_public': group_data.get('is_public', False),
'is_featured': group_data.get('is_featured', False),
'order': group_data.get('order', 0),
}
group, created = LinkGroup.objects.update_or_create(
owner=user,
title=group_data['title'],
defaults=group_defaults
)
imported_count += 1
return imported_count
def _import_links(user, links_data, groups_data, overwrite_existing):
"""Импорт ссылок"""
imported_count = 0
# Создаем словарь соответствия старых ID групп к новым объектам
group_mapping = {}
for group_data in groups_data:
group = LinkGroup.objects.filter(
owner=user,
title=group_data['title']
).first()
if group:
group_mapping[group_data['id']] = group
for link_data in links_data:
# Находим группу для ссылки
old_group_id = link_data.get('group_id')
if old_group_id not in group_mapping:
continue # Пропускаем если группа не найдена
target_group = group_mapping[old_group_id]
# Проверяем существование ссылки по URL и группе
existing_link = Link.objects.filter(
group=target_group,
url=link_data['url']
).first()
if existing_link and not overwrite_existing:
continue # Пропускаем если ссылка существует и перезапись отключена
# Создаем или обновляем ссылку
link_defaults = {
'title': link_data.get('title', ''),
'description': link_data.get('description', ''),
'is_active': link_data.get('is_active', True),
'is_public': link_data.get('is_public', False),
'is_featured': link_data.get('is_featured', False),
'order': link_data.get('order', 0),
}
link, created = Link.objects.update_or_create(
group=target_group,
url=link_data['url'],
defaults=link_defaults
)
imported_count += 1
return imported_count
def _import_design_settings(user, design_data, overwrite_existing):
"""Импорт настроек дизайна"""
if not design_data:
return
# Получаем или создаем настройки дизайна
design_settings, created = DesignSettings.objects.get_or_create(
user=user,
defaults={}
)
if not created and not overwrite_existing:
return # Пропускаем если настройки существуют и перезапись отключена
# Обновляем настройки
for field, value in design_data.items():
if field != 'background_image' and hasattr(design_settings, field):
setattr(design_settings, field, value)
design_settings.save()
def _import_media_files(user, extract_dir, overwrite_existing):
"""Импорт медиафайлов"""
imported_count = 0
media_dir = extract_dir / 'media'
if not media_dir.exists():
return imported_count
# Создаем соответствующие директории в медиа
user_media_root = Path(settings.MEDIA_ROOT)
# Импорт файлов по категориям
for category in ['avatars', 'customization', 'link_groups', 'links']:
category_dir = media_dir / category
if not category_dir.exists():
continue
target_dir = user_media_root / category
target_dir.mkdir(exist_ok=True, parents=True)
# Копируем файлы
for file_path in category_dir.iterdir():
if file_path.is_file():
target_file = target_dir / file_path.name
if target_file.exists() and not overwrite_existing:
continue
shutil.copy2(file_path, target_file)
imported_count += 1
return imported_count

View File

@@ -8,7 +8,7 @@ services:
- media_volume:/app/storage
- static_volume:/app/staticfiles
env_file:
- .env
- .env.local
ports:
- "8000:8000"
depends_on:
@@ -22,7 +22,7 @@ services:
volumes:
- postgres_data:/var/lib/postgresql/data/
env_file:
- .env
- .env.local
environment:
- POSTGRES_INITDB_ARGS=--auth-host=scram-sha-256
restart: unless-stopped
@@ -34,9 +34,9 @@ services:
ports:
- "3000:3000"
environment:
- NEXT_PUBLIC_API_URL=https://links.shareon.kr
- NEXT_PUBLIC_API_URL=http://localhost:8000
env_file:
- .env
- .env.local
restart: unless-stopped
depends_on:
- web

View File

@@ -3,7 +3,6 @@ node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
package-lock.json
yarn.lock
# Production build
@@ -81,7 +80,6 @@ __tests__/
# TypeScript
*.tsbuildinfo
tsconfig.json
# Storybook
.storybook/

View File

@@ -1,12 +1,24 @@
FROM node:20-alpine
# Этап 1: Установка зависимостей
FROM node:20-alpine as deps
WORKDIR /app
# Копирование package.json и package-lock.json
COPY package*.json ./
# Установка зависимостей
RUN npm install
# Установка зависимостей с очисткой кеша
RUN npm ci --omit=dev && npm cache clean --force
# Этап 2: Сборка приложения
FROM node:20-alpine as builder
WORKDIR /app
# Копирование package.json и package-lock.json
COPY package*.json ./
# Установка всех зависимостей (включая dev)
RUN npm ci
# Копирование исходного кода
COPY . .
@@ -14,6 +26,17 @@ COPY . .
# Сборка приложения
RUN npm run build
# Этап 3: Финальный образ
FROM node:20-alpine as runner
WORKDIR /app
# Копирование зависимостей продакшена
COPY --from=deps /app/node_modules ./node_modules
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public
COPY --from=builder /app/package*.json ./
EXPOSE 3000
CMD ["npm", "start"]

View File

@@ -6,6 +6,8 @@ import Link from 'next/link'
import Image from 'next/image'
import { ProfileCard } from '../../components/ProfileCard'
import { CustomizationPanel } from '../../components/CustomizationPanel'
import { Navbar } from '../../components/Navbar'
import { useLocale } from '../../contexts/LocaleContext'
interface UserProfile {
id: number
@@ -72,6 +74,7 @@ interface DesignSettings {
}
export default function DashboardClient() {
const { t } = useLocale()
const router = useRouter()
const [user, setUser] = useState<UserProfile | null>(null)
const [groups, setGroups] = useState<Group[]>([])
@@ -174,7 +177,7 @@ export default function DashboardClient() {
try {
await navigator.clipboard.writeText(shareUrl)
alert('Ссылка скопирована в буфер обмена!')
alert(t('common.success') + ': ' + t('dashboard.shareUrl.copied'))
} catch (err) {
// Fallback для старых браузеров
const textArea = document.createElement('textarea')
@@ -183,7 +186,7 @@ export default function DashboardClient() {
textArea.select()
document.execCommand('copy')
document.body.removeChild(textArea)
alert('Ссылка скопирована в буфер обмена!')
alert(t('common.success') + ': ' + t('dashboard.shareUrl.copied'))
}
}
@@ -221,9 +224,9 @@ export default function DashboardClient() {
fetch('/api/customization/settings/', { headers: { Authorization: `Bearer ${token}` } }),
])
.then(async ([uRes, gRes, lRes, dRes]) => {
if (!uRes.ok) throw new Error('Не удалось получить профиль')
if (!gRes.ok) throw new Error('Не удалось загрузить группы')
if (!lRes.ok) throw new Error('Не удалось загрузить ссылки')
if (!uRes.ok) throw new Error(t('common.error') + ': ' + t('auth.networkError'))
if (!gRes.ok) throw new Error(t('common.error') + ': ' + t('dashboard.groups'))
if (!lRes.ok) throw new Error(t('common.error') + ': ' + t('dashboard.links'))
const userData = await uRes.json()
const groupsData = await gRes.json()
@@ -318,7 +321,7 @@ export default function DashboardClient() {
await reloadData()
}
async function handleDeleteGroup(grp: Group) {
if (!confirm(`Удалить группу "${grp.name}"?`)) return
if (!confirm(t('common.confirm') + ` ${t('group.delete')} "${grp.name}"?`)) return
const token = localStorage.getItem('token')!
await fetch(`${API}/api/groups/${grp.id}/`, {
method: 'DELETE',
@@ -372,7 +375,7 @@ export default function DashboardClient() {
await reloadData()
}
async function handleDeleteLink(link: LinkItem) {
if (!confirm(`Удалить ссылку "${link.title}"?`)) return
if (!confirm(t('common.confirm') + ` ${t('link.delete')} "${link.title}"?`)) return
const token = localStorage.getItem('token')!
await fetch(`${API}/api/links/${link.id}/`, {
method: 'DELETE',
@@ -419,11 +422,11 @@ export default function DashboardClient() {
setShowProfileModal(false)
} else {
const error = await res.json()
alert('Ошибка: ' + JSON.stringify(error))
alert(t('dashboard.error') + JSON.stringify(error))
}
}
if (loading) return <div className="flex items-center justify-center h-screen">Загрузка...</div>
if (loading) return <div className="flex items-center justify-center h-screen">{t('common.loading')}</div>
if (error) return <div className="p-6 text-red-600 max-w-xl mx-auto">{error}</div>
// Функция расчета оптимального размера изображения для группы
@@ -475,9 +478,9 @@ export default function DashboardClient() {
const renderListLayout = () => (
<div className="card">
<div className="card-header d-flex justify-content-between align-items-center">
<h5 className="mb-0">Группы ссылок</h5>
<h5 className="mb-0">{t('dashboard.groups')}</h5>
<button className="btn btn-sm btn-success" onClick={openAddGroup}>
<i className="bi bi-plus-lg"></i> Добавить группу
<i className="bi bi-plus-lg"></i> {t('dashboard.addGroup')}
</button>
</div>
<div className="list-group list-group-flush">
@@ -528,9 +531,9 @@ export default function DashboardClient() {
const renderGridLayout = () => (
<div>
<div className="d-flex justify-content-between align-items-center mb-3">
<h5 className="mb-0">Группы ссылок</h5>
<h5 className="mb-0">{t('dashboard.groups')}</h5>
<button className="btn btn-sm btn-success" onClick={openAddGroup}>
<i className="bi bi-plus-lg"></i> Добавить группу
<i className="bi bi-plus-lg"></i> {t('dashboard.addGroup')}
</button>
</div>
<div className="row g-3">
@@ -599,7 +602,7 @@ export default function DashboardClient() {
const renderCompactLayout = () => (
<div className="compact-layout">
<div className="d-flex justify-content-between align-items-center mb-2">
<h6 className="mb-0">Группы ссылок</h6>
<h6 className="mb-0">{t('dashboard.groups')}</h6>
<button className="btn btn-sm btn-success" onClick={openAddGroup}>
<i className="bi bi-plus-lg"></i>
</button>
@@ -647,9 +650,9 @@ export default function DashboardClient() {
const renderCardsLayout = () => (
<div>
<div className="d-flex justify-content-between align-items-center mb-4">
<h5 className="mb-0">Группы ссылок</h5>
<h5 className="mb-0">{t('dashboard.linkGroups')}</h5>
<button className="btn btn-success" onClick={openAddGroup}>
<i className="bi bi-plus-lg"></i> Добавить группу
<i className="bi bi-plus-lg"></i> {t('dashboard.addGroup')}
</button>
</div>
<div className="row g-4">
@@ -673,7 +676,7 @@ export default function DashboardClient() {
<h5 className="mb-1" style={{ color: designSettings.group_text_color || designSettings.theme_color }}>
{group.name}
</h5>
<small className="text-muted">{group.links.length} ссылок</small>
<small className="text-muted">{t('dashboard.linksCount', { count: group.links.length })}</small>
</div>
</div>
</div>
@@ -738,7 +741,7 @@ export default function DashboardClient() {
<div className="col-md-3">
<div className="card">
<div className="card-header">
<h6 className="mb-0">Группы</h6>
<h6 className="mb-0">{t('dashboard.groups')}</h6>
<button className="btn btn-sm btn-success mt-2 w-100" onClick={openAddGroup}>
<i className="bi bi-plus-lg"></i> Добавить
</button>
@@ -830,9 +833,9 @@ export default function DashboardClient() {
const renderMasonryLayout = () => (
<div>
<div className="d-flex justify-content-between align-items-center mb-3">
<h5 className="mb-0">Группы ссылок</h5>
<h5 className="mb-0">{t('dashboard.groups')}</h5>
<button className="btn btn-success" onClick={openAddGroup}>
<i className="bi bi-plus-lg"></i> Добавить группу
<i className="bi bi-plus-lg"></i> {t('dashboard.addGroup')}
</button>
</div>
<div className="row g-3">
@@ -904,9 +907,9 @@ export default function DashboardClient() {
const renderTimelineLayout = () => (
<div>
<div className="d-flex justify-content-between align-items-center mb-4">
<h5 className="mb-0">Группы ссылок</h5>
<h5 className="mb-0">{t('dashboard.groups')}</h5>
<button className="btn btn-success" onClick={openAddGroup}>
<i className="bi bi-plus-lg"></i> Добавить группу
<i className="bi bi-plus-lg"></i> {t('dashboard.addGroup')}
</button>
</div>
<div className="timeline">
@@ -929,7 +932,7 @@ export default function DashboardClient() {
<h6 className="mb-0" style={{ color: designSettings.group_text_color || designSettings.theme_color }}>
{group.name}
</h6>
<small className="text-muted">{group.links.length} ссылок</small>
<small className="text-muted">{t('dashboard.linksCount', { count: group.links.length })}</small>
</div>
</div>
</div>
@@ -976,9 +979,9 @@ export default function DashboardClient() {
const renderMagazineLayout = () => (
<div>
<div className="d-flex justify-content-between align-items-center mb-4">
<h5 className="mb-0">Группы ссылок</h5>
<h5 className="mb-0">{t('dashboard.groups')}</h5>
<button className="btn btn-success" onClick={openAddGroup}>
<i className="bi bi-plus-lg"></i> Добавить группу
<i className="bi bi-plus-lg"></i> {t('dashboard.addGroup')}
</button>
</div>
<div className="magazine-layout">
@@ -1006,7 +1009,7 @@ export default function DashboardClient() {
{group.name}
</h5>
<p className="card-text text-muted">
{group.links.length} ссылок в этой группе
{t('dashboard.linksInGroup', { count: group.links.length })}
</p>
<div className="links-preview">
{group.links.slice(0, 3).map(link => (
@@ -1017,7 +1020,7 @@ export default function DashboardClient() {
</div>
))}
{group.links.length > 3 && (
<small className="text-muted">и еще {group.links.length - 3}...</small>
<small className="text-muted">{t('dashboard.andMore', { count: group.links.length - 3 })}</small>
)}
</div>
<div className="mt-3">
@@ -1087,6 +1090,8 @@ export default function DashboardClient() {
style={containerStyle}
suppressHydrationWarning={true}
>
<Navbar />
{user && (
<ProfileCard
avatar={user.avatar_url || user.avatar}
@@ -1102,20 +1107,20 @@ export default function DashboardClient() {
<div className="container my-4">
<div className="d-flex justify-content-between align-items-center mb-3">
<h2>Ваши ссылки</h2>
<h2>{t('dashboard.title')}</h2>
<div>
<span className="me-2">Panel state: {showCustomizationPanel ? 'Open' : 'Closed'}</span>
<span className="me-2">Panel state: {showCustomizationPanel ? t('dashboard.panelOpen') : t('dashboard.panelClosed')}</span>
<button
className="btn btn-outline-info me-2"
onClick={openEditProfile}
>
<i className="bi bi-person-gear"></i> Профиль
<i className="bi bi-person-gear"></i> {t('profile.edit')}
</button>
<button
className="btn btn-outline-success me-2"
onClick={() => setShowShareModal(true)}
>
<i className="bi bi-share"></i> Поделиться
<i className="bi bi-share"></i> {t('dashboard.share')}
</button>
<button
className="btn btn-outline-primary"
@@ -1126,7 +1131,7 @@ export default function DashboardClient() {
console.log('After setting showCustomizationPanel to true')
}}
>
<i className="bi bi-gear"></i> Настройки
<i className="bi bi-gear"></i> {t('dashboard.settings')}
</button>
</div>
</div>
@@ -1141,18 +1146,18 @@ export default function DashboardClient() {
<div className="modal-dialog">
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title">{groupModalMode === 'add' ? 'Добавить группу' : 'Редактировать группу'}</h5>
<h5 className="modal-title">{groupModalMode === 'add' ? t('group.create') : t('group.edit')}</h5>
<button
type="button"
className="btn-close"
onClick={() => setShowGroupModal(false)}
aria-label="Закрыть"
title="Закрыть модальное окно"
aria-label={t('common.close')}
title={t('common.close')}
/>
</div>
<div className="modal-body">
<div className="mb-3">
<label className="form-label">Название</label>
<label className="form-label">{t('group.name')}</label>
<input
type="text"
className="form-control"
@@ -1161,17 +1166,17 @@ export default function DashboardClient() {
/>
</div>
<div className="mb-3">
<label className="form-label">Описание (опционально)</label>
<label className="form-label">{t('group.description')} ({t('common.optional')})</label>
<textarea
className="form-control"
rows={3}
value={groupForm.description}
onChange={e => setGroupForm(f => ({ ...f, description: e.target.value }))}
placeholder="Краткое описание группы ссылок"
placeholder={t('group.descriptionPlaceholder')}
/>
</div>
<div className="mb-3">
<label className="form-label">Цвет заголовка</label>
<label className="form-label">{t('group.color')}</label>
<input
type="color"
className="form-control form-control-color"
@@ -1191,7 +1196,7 @@ export default function DashboardClient() {
onChange={(e) => setGroupForm(prev => ({ ...prev, is_public: e.target.checked }))}
/>
<label className="form-check-label" htmlFor="groupPublic">
Публичная
{t('group.public')}
</label>
</div>
</div>
@@ -1205,7 +1210,7 @@ export default function DashboardClient() {
onChange={(e) => setGroupForm(prev => ({ ...prev, is_favorite: e.target.checked }))}
/>
<label className="form-check-label" htmlFor="groupFavorite">
Избранная
{t('group.favorite')}
</label>
</div>
</div>
@@ -1219,23 +1224,113 @@ export default function DashboardClient() {
onChange={(e) => setGroupForm(prev => ({ ...prev, is_expanded: e.target.checked }))}
/>
<label className="form-check-label" htmlFor="groupExpanded">
Развернутая
{t('group.expanded')}
</label>
</div>
</div>
</div>
<div className="mb-3">
<label className="form-label">Иконка группы (опционально)</label>
<label className="form-label">{t('group.icon')} ({t('common.optional')})</label>
{editingGroup?.icon_url && (
<div className="mb-2">
<label className="form-label small">{t('group.currentIcon')}:</label>
<div className="d-flex align-items-center gap-2">
<img
src={editingGroup.icon_url}
alt={t('group.currentIcon')}
className="img-thumbnail"
style={{ width: '32px', height: '32px', objectFit: 'cover' }}
/>
<button
type="button"
className="btn btn-outline-danger btn-sm"
onClick={() => {
if (confirm(t('group.confirmRemoveIcon'))) {
// Удаляем иконку через API
fetch(`/api/groups/${editingGroup.id}/`, {
method: 'PATCH',
headers: {
'Authorization': `Bearer ${localStorage.getItem('access_token')}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
icon_url: ''
})
}).then(response => {
if (response.ok) {
// Обновляем локальный объект группы
setGroups(groups.map(g =>
g.id === editingGroup.id
? { ...g, icon_url: '' }
: g
))
setEditingGroup({ ...editingGroup, icon_url: '' })
}
})
}
}}
title={t('group.removeIcon')}
>
<i className="bi bi-trash"></i> {t('group.removeIcon')}
</button>
</div>
</div>
)}
<input
type="file"
className="form-control"
accept="image/*"
onChange={e => setGroupForm(f => ({ ...f, iconFile: e.target.files?.[0] || null }))}
/>
<div className="form-text">Рекомендуемый размер: 32x32 пикселя</div>
<div className="form-text">{t('group.iconSizeRecommendation')}</div>
</div>
<div className="mb-3">
<label className="form-label">Фоновое изображение (опционально)</label>
<label className="form-label">{t('group.background')} ({t('common.optional')})</label>
{editingGroup?.background_image_url && (
<div className="mb-2">
<label className="form-label small">{t('group.currentBackground')}:</label>
<div className="d-flex align-items-center gap-2">
<img
src={editingGroup.background_image_url}
alt={t('group.currentBackground')}
className="img-thumbnail"
style={{ maxWidth: '150px', maxHeight: '80px', objectFit: 'cover' }}
/>
<button
type="button"
className="btn btn-outline-danger btn-sm"
onClick={() => {
if (confirm(t('group.confirmRemoveBackground'))) {
// Удаляем фон через API
fetch(`/api/groups/${editingGroup.id}/`, {
method: 'PATCH',
headers: {
'Authorization': `Bearer ${localStorage.getItem('access_token')}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
background_image_url: ''
})
}).then(response => {
if (response.ok) {
// Обновляем локальный объект группы
setGroups(groups.map(g =>
g.id === editingGroup.id
? { ...g, background_image_url: '' }
: g
))
setEditingGroup({ ...editingGroup, background_image_url: '' })
}
})
}
}}
title={t('group.removeBackground')}
>
<i className="bi bi-trash"></i> {t('group.removeBackground')}
</button>
</div>
</div>
)}
<input
type="file"
className="form-control"
@@ -1244,7 +1339,7 @@ export default function DashboardClient() {
/>
<div className="alert alert-info mt-2">
<i className="bi bi-info-circle me-2"></i>
<strong>Рекомендуемый размер изображения:</strong>
<strong>{t('group.imageSizeRecommendation')}</strong>
<br />
{(() => {
const linksCount = editingGroup ? editingGroup.links.length : 3 // по умолчанию для новых групп
@@ -1260,16 +1355,16 @@ export default function DashboardClient() {
</small>
<br />
<small className="text-muted">
💡 <strong>Совет:</strong> Для групп с рамкой используйте изображения с отступами по краям (10-20px)
💡 <strong>{t('group.tip')}</strong> {t('group.borderTip')}
</small>
</div>
<div className="form-text">Изображение будет использовано как фон для содержимого группы</div>
<div className="form-text">{t('group.backgroundDescription')}</div>
</div>
</div>
<div className="modal-footer">
<button className="btn btn-secondary" onClick={() => setShowGroupModal(false)}>Отмена</button>
<button className="btn btn-secondary" onClick={() => setShowGroupModal(false)}>{t('common.cancel')}</button>
<button className="btn btn-primary" onClick={handleGroupSubmit}>
Сохранить
{t('common.save')}
</button>
</div>
</div>
@@ -1281,61 +1376,109 @@ export default function DashboardClient() {
<div className="modal-dialog">
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title">{linkModalMode === 'add' ? 'Добавить ссылку' : 'Редактировать ссылку'}</h5>
<h5 className="modal-title">{linkModalMode === 'add' ? t('link.create') : t('link.edit')}</h5>
<button
type="button"
className="btn-close"
onClick={() => setShowLinkModal(false)}
aria-label="Закрыть"
title="Закрыть модальное окно"
aria-label={t('common.close')}
title={t('common.close')}
/>
</div>
<div className="modal-body">
<div className="mb-3">
<label className="form-label">Заголовок</label>
<label className="form-label">{t('link.title')}</label>
<input
type="text"
className="form-control"
value={linkForm.title}
onChange={e => setLinkForm(f => ({ ...f, title: e.target.value }))}
placeholder="Название ссылки"
placeholder={t('link.titlePlaceholder')}
/>
</div>
<div className="mb-3">
<label className="form-label">URL</label>
<label className="form-label">{t('link.url')}</label>
<input
type="url"
className="form-control"
value={linkForm.url}
onChange={e => setLinkForm(f => ({ ...f, url: e.target.value }))}
placeholder="https://example.com"
placeholder={t('link.urlPlaceholder')}
/>
</div>
<div className="mb-3">
<label className="form-label">Описание (опционально)</label>
<label className="form-label">{t('link.description')} ({t('common.optional')})</label>
<textarea
className="form-control"
rows={2}
value={linkForm.description}
onChange={e => setLinkForm(f => ({ ...f, description: e.target.value }))}
placeholder="Краткое описание ссылки"
placeholder={t('link.descriptionPlaceholder')}
/>
</div>
<div className="mb-3">
<label className="form-label">Иконка (опционально)</label>
<label className="form-label">{t('link.icon')} ({t('common.optional')})</label>
{editingLink?.icon_url && (
<div className="mb-2">
<label className="form-label small">{t('link.currentIcon')}:</label>
<div className="d-flex align-items-center gap-2">
<img
src={editingLink.icon_url}
alt={t('link.currentIcon')}
className="img-thumbnail"
style={{ width: '32px', height: '32px', objectFit: 'cover' }}
/>
<button
type="button"
className="btn btn-outline-danger btn-sm"
onClick={() => {
if (confirm(t('link.confirmRemoveIcon'))) {
// Удаляем иконку через API
fetch(`/api/links/${editingLink.id}/`, {
method: 'PATCH',
headers: {
'Authorization': `Bearer ${localStorage.getItem('access_token')}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
icon_url: ''
})
}).then(response => {
if (response.ok) {
// Обновляем локальные данные
setGroups(groups.map(g => ({
...g,
links: g.links.map(l =>
l.id === editingLink.id
? { ...l, icon_url: '' }
: l
)
})))
setEditingLink({ ...editingLink, icon_url: '' })
}
})
}
}}
title={t('link.removeIcon')}
>
<i className="bi bi-trash"></i> {t('link.removeIcon')}
</button>
</div>
</div>
)}
<input
type="file"
className="form-control"
accept="image/*"
onChange={e => setLinkForm(f => ({ ...f, iconFile: e.target.files?.[0] || null }))}
/>
<div className="form-text">Рекомендуемый размер: 24x24 пикселя</div>
<div className="form-text">{t('link.iconSizeRecommendation')}</div>
</div>
</div>
<div className="modal-footer">
<button className="btn btn-secondary" onClick={() => setShowLinkModal(false)}>Отмена</button>
<button className="btn btn-secondary" onClick={() => setShowLinkModal(false)}>{t('common.cancel')}</button>
<button className="btn btn-primary" onClick={handleLinkSubmit}>
Сохранить
{t('common.save')}
</button>
</div>
</div>
@@ -1347,25 +1490,25 @@ export default function DashboardClient() {
<div className="modal-dialog">
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title">Поделиться страницей</h5>
<h5 className="modal-title">{t('share.title')}</h5>
<button
type="button"
className="btn-close"
onClick={() => setShowShareModal(false)}
aria-label="Закрыть"
title="Закрыть модальное окно"
aria-label={t('common.close')}
title={t('common.closeModal')}
/>
</div>
<div className="modal-body">
<p>Ваша публичная страница со ссылками доступна по адресу:</p>
<p>{t('share.description')}</p>
<div className="input-group mb-3">
<input
type="text"
className="form-control"
value={shareUrl || 'Загрузка...'}
value={shareUrl || t('share.loading')}
readOnly
aria-label="URL публичной страницы"
title="URL публичной страницы"
aria-label={t('share.urlAriaLabel')}
title={t('share.urlTitle')}
/>
<button
className="btn btn-outline-primary"
@@ -1373,17 +1516,16 @@ export default function DashboardClient() {
onClick={copyShareUrl}
disabled={!shareUrl}
>
<i className="bi bi-clipboard"></i> Копировать
<i className="bi bi-clipboard"></i> {t('share.copy')}
</button>
</div>
<p className="text-muted small">
На этой странице будут видны все ваши группы и ссылки.
Она обновляется автоматически при изменении данных.
{t('share.note')}
</p>
</div>
<div className="modal-footer">
<button className="btn btn-secondary" onClick={() => setShowShareModal(false)}>
Закрыть
{t('common.close')}
</button>
{shareUrl && (
<a
@@ -1392,7 +1534,7 @@ export default function DashboardClient() {
rel="noopener noreferrer"
className="btn btn-primary"
>
<i className="bi bi-box-arrow-up-right"></i> Открыть страницу
<i className="bi bi-box-arrow-up-right"></i> {t('share.openPage')}
</a>
)}
</div>
@@ -1405,20 +1547,20 @@ export default function DashboardClient() {
<div className="modal-dialog modal-lg">
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title">Редактировать профиль</h5>
<h5 className="modal-title">{t('profile.edit')}</h5>
<button
type="button"
className="btn-close"
onClick={() => setShowProfileModal(false)}
aria-label="Закрыть"
title="Закрыть модальное окно"
aria-label={t('common.close')}
title={t('common.closeModal')}
/>
</div>
<div className="modal-body">
<div className="row">
<div className="col-md-6">
<div className="mb-3">
<label className="form-label">Email</label>
<label className="form-label">{t('profile.email')}</label>
<input
type="email"
className="form-control"
@@ -1427,7 +1569,7 @@ export default function DashboardClient() {
/>
</div>
<div className="mb-3">
<label className="form-label">Имя</label>
<label className="form-label">{t('profile.firstName')}</label>
<input
type="text"
className="form-control"
@@ -1436,7 +1578,7 @@ export default function DashboardClient() {
/>
</div>
<div className="mb-3">
<label className="form-label">Фамилия</label>
<label className="form-label">{t('profile.lastName')}</label>
<input
type="text"
className="form-control"
@@ -1445,7 +1587,7 @@ export default function DashboardClient() {
/>
</div>
<div className="mb-3">
<label className="form-label">Полное имя</label>
<label className="form-label">{t('profile.fullName')}</label>
<input
type="text"
className="form-control"
@@ -1456,7 +1598,7 @@ export default function DashboardClient() {
</div>
<div className="col-md-6">
<div className="mb-3">
<label className="form-label">Биография</label>
<label className="form-label">{t('profile.bio')}</label>
<textarea
className="form-control"
rows={4}
@@ -1465,7 +1607,7 @@ export default function DashboardClient() {
/>
</div>
<div className="mb-3">
<label className="form-label">Аватар</label>
<label className="form-label">{t('profile.avatar')}</label>
<input
type="file"
className="form-control"
@@ -1474,12 +1616,12 @@ export default function DashboardClient() {
/>
{user?.avatar && (
<div className="mt-2">
<img src={user.avatar_url || user.avatar} alt="Текущий аватар" className="img-thumbnail w-25" />
<img src={user.avatar_url || user.avatar} alt={t('profile.currentAvatar')} className="img-thumbnail w-25" />
</div>
)}
</div>
<div className="mb-3">
<label className="form-label">Обложка</label>
<label className="form-label">{t('profile.cover')}</label>
<input
type="file"
className="form-control"
@@ -1496,7 +1638,7 @@ export default function DashboardClient() {
className="btn btn-secondary"
onClick={() => setShowProfileModal(false)}
>
Отмена
{t('common.cancel')}
</button>
<button
type="button"
@@ -1524,6 +1666,12 @@ export default function DashboardClient() {
setDesignSettings(newSettings)
setShowCustomizationPanel(false)
}}
user={user}
groups={groups}
onDataUpdate={() => {
// Перезагрузить данные после импорта
reloadData()
}}
/>
)}
</div>

View File

@@ -1,20 +1,25 @@
'use client'
import dynamic from 'next/dynamic'
import { useLocale } from '../../contexts/LocaleContext'
// Динамический импорт клиентского компонента без SSR
const DashboardClient = dynamic(() => import('./DashboardClient'), {
ssr: false,
loading: () => (
<div className="d-flex justify-content-center align-items-center min-vh-100">
<div className="text-center">
<div className="spinner-border text-primary" role="status">
<span className="visually-hidden">Загрузка...</span>
loading: () => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const { t } = useLocale()
return (
<div className="d-flex justify-content-center align-items-center min-vh-100">
<div className="text-center">
<div className="spinner-border text-primary" role="status">
<span className="visually-hidden">{t('common.loading')}</span>
</div>
<p className="mt-3">{t('dashboard.title')} {t('common.loading')}...</p>
</div>
<p className="mt-3">Загрузка дашборда...</p>
</div>
</div>
)
)
}
})
export default function DashboardPage() {

View File

@@ -5,6 +5,7 @@ import { useState, useEffect } from 'react'
import { useForm } from 'react-hook-form'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { useLocale } from '../../contexts/LocaleContext'
type FormData = { username: string; password: string }
@@ -12,6 +13,7 @@ export default function LoginPage() {
const router = useRouter()
const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm<FormData>()
const [apiError, setApiError] = useState<string | null>(null)
const { t } = useLocale()
useEffect(() => {
if (typeof window !== 'undefined' && localStorage.getItem('token')) {
@@ -32,14 +34,14 @@ export default function LoginPage() {
)
if (!res.ok) {
const json = await res.json()
setApiError(json.detail || 'Ошибка входа')
setApiError(json.detail || t('auth.loginError'))
return
}
const { access } = await res.json()
localStorage.setItem('token', access)
router.push('/dashboard')
} catch {
setApiError('Сетевая ошибка')
setApiError(t('auth.networkError'))
}
}
@@ -58,8 +60,8 @@ export default function LoginPage() {
height="80"
className="mb-3"
/>
<h2 className="fw-bold text-primary">Добро пожаловать!</h2>
<p className="text-muted">Войдите в свой аккаунт CatLink</p>
<h2 className="fw-bold text-primary">{t('auth.welcome')}</h2>
<p className="text-muted">{t('auth.welcomeSubtitle')}</p>
</div>
{apiError && (
@@ -70,13 +72,13 @@ export default function LoginPage() {
<form onSubmit={handleSubmit(onSubmit)}>
<div className="mb-3">
<label htmlFor="username" className="form-label">Имя пользователя</label>
<label htmlFor="username" className="form-label">{t('auth.usernameLabel')}</label>
<input
id="username"
type="text"
placeholder="Введите имя пользователя"
placeholder={t('auth.usernamePlaceholder')}
className={`form-control form-control-lg ${errors.username ? 'is-invalid' : ''}`}
{...register('username', { required: 'Введите имя пользователя' })}
{...register('username', { required: t('auth.usernameRequired') })}
/>
{errors.username && (
<div className="invalid-feedback">{errors.username.message}</div>
@@ -84,13 +86,13 @@ export default function LoginPage() {
</div>
<div className="mb-4">
<label htmlFor="password" className="form-label">Пароль</label>
<label htmlFor="password" className="form-label">{t('auth.passwordLabel')}</label>
<input
id="password"
type="password"
placeholder="Введите пароль"
placeholder={t('auth.passwordPlaceholder')}
className={`form-control form-control-lg ${errors.password ? 'is-invalid' : ''}`}
{...register('password', { required: 'Введите пароль' })}
{...register('password', { required: t('auth.passwordRequired') })}
/>
{errors.password && (
<div className="invalid-feedback">{errors.password.message}</div>
@@ -105,19 +107,19 @@ export default function LoginPage() {
{isSubmitting ? (
<>
<span className="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
Входим...
{t('auth.loggingIn')}
</>
) : (
'Войти'
t('auth.loginButton')
)}
</button>
</form>
<div className="text-center">
<p className="text-muted mb-0">
Нет аккаунта?{' '}
{t('auth.noAccount')}{' '}
<Link href="/auth/register" className="text-primary text-decoration-none fw-bold">
Зарегистрироваться
{t('common.register')}
</Link>
</p>
</div>

View File

@@ -4,6 +4,7 @@ import React, { useState, useEffect } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import Image from 'next/image'
import { useLocale } from '../../contexts/LocaleContext'
export default function RegisterPage() {
const [formData, setFormData] = useState({
@@ -17,6 +18,7 @@ export default function RegisterPage() {
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const router = useRouter()
const { t } = useLocale()
useEffect(() => {
// Автозаполнение email из главной страницы
@@ -40,7 +42,7 @@ export default function RegisterPage() {
setError(null)
if (formData.password !== formData.password2) {
setError('Пароли не совпадают')
setError(t('auth.passwordMismatch'))
setLoading(false)
return
}
@@ -59,10 +61,10 @@ export default function RegisterPage() {
router.push('/auth/login?message=registration_success')
} else {
const errorData = await response.json()
setError(errorData.message || 'Ошибка регистрации')
setError(errorData.message || t('auth.registrationError'))
}
} catch (err) {
setError('Ошибка соединения с сервером')
setError(t('auth.connectionError'))
} finally {
setLoading(false)
}
@@ -84,8 +86,8 @@ export default function RegisterPage() {
height={64}
className="mb-3"
/>
<h2 className="fw-bold text-primary">Создать аккаунт</h2>
<p className="text-muted">Присоединяйтесь к CatLink сегодня</p>
<h2 className="fw-bold text-primary">{t('auth.createAccount')}</h2>
<p className="text-muted">{t('auth.createAccountSubtitle')}</p>
</div>
{/* Форма регистрации */}
@@ -99,7 +101,7 @@ export default function RegisterPage() {
<div className="row">
<div className="col-sm-6 mb-3">
<label htmlFor="first_name" className="form-label">
Имя
{t('auth.firstNameLabel')}
</label>
<input
type="text"
@@ -113,7 +115,7 @@ export default function RegisterPage() {
</div>
<div className="col-sm-6 mb-3">
<label htmlFor="last_name" className="form-label">
Фамилия
{t('auth.lastNameLabel')}
</label>
<input
type="text"
@@ -129,7 +131,7 @@ export default function RegisterPage() {
<div className="mb-3">
<label htmlFor="username" className="form-label">
Имя пользователя
{t('auth.usernameLabel')}
</label>
<input
type="text"
@@ -138,14 +140,14 @@ export default function RegisterPage() {
name="username"
value={formData.username}
onChange={handleChange}
placeholder="Только латинские буквы, цифры и _"
placeholder={t('auth.usernameHelp')}
required
/>
</div>
<div className="mb-3">
<label htmlFor="email" className="form-label">
Email
{t('auth.emailLabel')}
</label>
<input
type="email"
@@ -160,7 +162,7 @@ export default function RegisterPage() {
<div className="mb-3">
<label htmlFor="password" className="form-label">
Пароль
{t('auth.passwordLabel')}
</label>
<input
type="password"
@@ -176,7 +178,7 @@ export default function RegisterPage() {
<div className="mb-4">
<label htmlFor="password2" className="form-label">
Подтвердите пароль
{t('auth.passwordConfirmLabel')}
</label>
<input
type="password"
@@ -198,17 +200,17 @@ export default function RegisterPage() {
{loading ? (
<>
<span className="spinner-border spinner-border-sm me-2"></span>
Создание аккаунта...
{t('auth.registering')}
</>
) : (
'Создать аккаунт'
t('auth.registerButton')
)}
</button>
<div className="text-center">
<span className="text-muted">Уже есть аккаунт? </span>
<span className="text-muted">{t('auth.haveAccount')} </span>
<Link href="/auth/login" className="text-decoration-none">
Войти
{t('common.login')}
</Link>
</div>
</form>
@@ -218,13 +220,13 @@ export default function RegisterPage() {
{/* Дополнительная информация */}
<div className="text-center mt-4">
<p className="text-muted small">
Создавая аккаунт, вы соглашаетесь с{' '}
{t('auth.termsAgreement')}{' '}
<Link href="/terms" className="text-decoration-none">
Условиями использования
{t('auth.termsLink')}
</Link>{' '}
и{' '}
{t('auth.and')}{' '}
<Link href="/privacy" className="text-decoration-none">
Политикой конфиденциальности
{t('auth.privacyLink')}
</Link>
</p>
</div>

View File

@@ -2,7 +2,10 @@
import React, { useState, useEffect } from 'react'
import { TemplatesSelector } from './TemplatesSelector'
import { ExportDataModal } from './ExportDataModal'
import { ImportDataModal } from './ImportDataModal'
import { designTemplates, DesignTemplate } from '../constants/designTemplates'
import { useLocale } from '../contexts/LocaleContext'
interface DesignSettings {
id?: number
@@ -37,13 +40,45 @@ interface DesignSettings {
link_overlay_opacity?: number
}
interface UserProfile {
id: number
username: string
email: string
full_name: string
bio?: string
avatar_url?: string
}
interface LinkItem {
id: number
title: string
url: string
icon_url?: string
group: number
}
interface Group {
id: number
name: string
description?: string
icon_url?: string
background_image_url?: string
is_public?: boolean
is_favorite?: boolean
links: LinkItem[]
}
interface CustomizationPanelProps {
isOpen: boolean
onClose: () => void
onSettingsUpdate: (settings: DesignSettings) => void
user?: UserProfile | null
groups?: Group[]
onDataUpdate?: () => void
}
export function CustomizationPanel({ isOpen, onClose, onSettingsUpdate }: CustomizationPanelProps) {
export function CustomizationPanel({ isOpen, onClose, onSettingsUpdate, user, groups = [], onDataUpdate }: CustomizationPanelProps) {
const { t } = useLocale()
const [settings, setSettings] = useState<DesignSettings>({
theme_color: '#ffffff',
dashboard_layout: 'list',
@@ -58,8 +93,12 @@ export function CustomizationPanel({ isOpen, onClose, onSettingsUpdate }: Custom
header_text_color: '#000000'
})
const [loading, setLoading] = useState(false)
const [activeTab, setActiveTab] = useState<'layout' | 'colors' | 'groups' | 'templates' | 'advanced'>('templates')
const [activeTab, setActiveTab] = useState<'layout' | 'colors' | 'groups' | 'templates' | 'advanced' | 'data'>('templates')
const [backgroundImageFile, setBackgroundImageFile] = useState<File | null>(null)
// Состояния для модалов экспорта/импорта
const [showExportModal, setShowExportModal] = useState(false)
const [showImportModal, setShowImportModal] = useState(false)
useEffect(() => {
if (isOpen) {
@@ -241,7 +280,7 @@ export function CustomizationPanel({ isOpen, onClose, onSettingsUpdate }: Custom
<div className="modal-header">
<h5 className="modal-title">
<i className="bi bi-palette me-2"></i>
Настройки дашборда
{t('customization.title')}
</h5>
<button
type="button"
@@ -259,7 +298,7 @@ export function CustomizationPanel({ isOpen, onClose, onSettingsUpdate }: Custom
onClick={() => setActiveTab('layout')}
>
<i className="bi bi-layout-sidebar me-1"></i>
Макет
{t('customization.layout')}
</button>
</li>
<li className="nav-item">
@@ -268,7 +307,7 @@ export function CustomizationPanel({ isOpen, onClose, onSettingsUpdate }: Custom
onClick={() => setActiveTab('colors')}
>
<i className="bi bi-palette-fill me-1"></i>
Цвета
{t('customization.colors')}
</button>
</li>
<li className="nav-item">
@@ -277,7 +316,7 @@ export function CustomizationPanel({ isOpen, onClose, onSettingsUpdate }: Custom
onClick={() => setActiveTab('groups')}
>
<i className="bi bi-collection me-1"></i>
Группы
{t('customization.groups')}
</button>
</li>
<li className="nav-item">
@@ -286,7 +325,7 @@ export function CustomizationPanel({ isOpen, onClose, onSettingsUpdate }: Custom
onClick={() => setActiveTab('templates')}
>
<i className="bi bi-palette me-1"></i>
Шаблоны
{t('customization.templates')}
</button>
</li>
<li className="nav-item">
@@ -295,7 +334,16 @@ export function CustomizationPanel({ isOpen, onClose, onSettingsUpdate }: Custom
onClick={() => setActiveTab('advanced')}
>
<i className="bi bi-gear me-1"></i>
Дополнительно
{t('customization.advanced')}
</button>
</li>
<li className="nav-item">
<button
className={`nav-link ${activeTab === 'data' ? 'active' : ''}`}
onClick={() => setActiveTab('data')}
>
<i className="bi bi-database me-1"></i>
{t('customization.data')}
</button>
</li>
</ul>
@@ -310,63 +358,63 @@ export function CustomizationPanel({ isOpen, onClose, onSettingsUpdate }: Custom
<div className="col-12 mb-4">
<label className="form-label fs-5 mb-3">
<i className="bi bi-layout-text-window-reverse me-2"></i>
Стиль отображения групп и ссылок
{t('customization.layout.style')}
</label>
<div className="row g-3">
{[
{
value: 'list',
label: 'Список',
labelKey: 'customization.layout.list',
icon: 'bi-list-ul',
description: 'Классический вертикальный список'
descriptionKey: 'customization.layout.listDescription'
},
{
value: 'grid',
label: 'Сетка',
labelKey: 'customization.layout.grid',
icon: 'bi-grid-3x3',
description: 'Равномерная сетка карточек'
descriptionKey: 'customization.layout.gridDescription'
},
{
value: 'cards',
label: 'Карточки',
labelKey: 'customization.layout.cards',
icon: 'bi-card-heading',
description: 'Большие информативные карточки'
descriptionKey: 'customization.layout.cardsDescription'
},
{
value: 'compact',
label: 'Компактный',
labelKey: 'customization.layout.compact',
icon: 'bi-layout-text-sidebar',
description: 'Компактное отображение без отступов'
descriptionKey: 'customization.layout.compactDescription'
},
{
value: 'sidebar',
label: 'Боковая панель',
labelKey: 'customization.layout.sidebar',
icon: 'bi-layout-sidebar',
description: 'Навигация в боковой панели'
descriptionKey: 'customization.layout.sidebarDescription'
},
{
value: 'masonry',
label: 'Кладка',
labelKey: 'customization.layout.masonry',
icon: 'bi-bricks',
description: 'Динамическая сетка разной высоты'
descriptionKey: 'customization.layout.masonryDescription'
},
{
value: 'timeline',
label: 'Лента времени',
labelKey: 'customization.layout.timeline',
icon: 'bi-clock-history',
description: 'Хронологическое отображение'
descriptionKey: 'customization.layout.timelineDescription'
},
{
value: 'magazine',
label: 'Журнальный',
labelKey: 'customization.layout.magazine',
icon: 'bi-newspaper',
description: 'Стиль журнала с крупными изображениями'
descriptionKey: 'customization.layout.magazineDescription'
},
{
value: 'test-list',
label: 'Тестовый список',
labelKey: 'customization.layout.testList',
icon: 'bi-list-check',
description: 'Полный несворачиваемый список всех групп и ссылок'
descriptionKey: 'customization.layout.testListDescription'
}
].map((layout) => (
<div key={layout.value} className="col-md-6 col-lg-4">
@@ -377,8 +425,8 @@ export function CustomizationPanel({ isOpen, onClose, onSettingsUpdate }: Custom
>
<div className="card-body d-flex flex-column">
<i className={`${layout.icon} fs-1 mb-3 text-primary`}></i>
<h6 className="card-title mb-2">{layout.label}</h6>
<p className="card-text small text-muted flex-grow-1">{layout.description}</p>
<h6 className="card-title mb-2">{t(layout.labelKey)}</h6>
<p className="card-text small text-muted flex-grow-1">{t(layout.descriptionKey)}</p>
{settings.dashboard_layout === layout.value && (
<div className="mt-2">
<span className="badge bg-primary">
@@ -397,7 +445,7 @@ export function CustomizationPanel({ isOpen, onClose, onSettingsUpdate }: Custom
<div className="col-12 mb-3">
<div className="alert alert-info">
<i className="bi bi-info-circle me-2"></i>
<strong>Совет:</strong> Попробуйте разные макеты, чтобы найти наиболее подходящий для вашего контента.
<strong>{t('customization.layout.tip')}</strong> {t('customization.layout.tipText')}
Каждый стиль имеет свои преимущества в зависимости от количества ссылок и их типа.
</div>
</div>
@@ -410,7 +458,7 @@ export function CustomizationPanel({ isOpen, onClose, onSettingsUpdate }: Custom
<div className="tab-pane fade show active">
<div className="row">
<div className="col-md-6 mb-3">
<label className="form-label">Основной цвет темы</label>
<label className="form-label">{t('customization.colors.theme')}</label>
<div className="input-group">
<input
type="color"
@@ -427,7 +475,7 @@ export function CustomizationPanel({ isOpen, onClose, onSettingsUpdate }: Custom
</div>
</div>
<div className="col-md-6 mb-3">
<label className="form-label">Цвет фона дашборда</label>
<label className="form-label">{t('customization.colors.background')}</label>
<div className="input-group">
<input
type="color"
@@ -444,7 +492,7 @@ export function CustomizationPanel({ isOpen, onClose, onSettingsUpdate }: Custom
</div>
</div>
<div className="col-12 mb-3">
<label className="form-label">Фоновое изображение</label>
<label className="form-label">{t('customization.colors.backgroundImage')}</label>
<div className="mb-2">
<input
type="file"
@@ -458,25 +506,35 @@ export function CustomizationPanel({ isOpen, onClose, onSettingsUpdate }: Custom
}}
/>
<div className="form-text">
Выберите изображение для фона (JPG, PNG, GIF). Если не выбрано - текущее изображение останется без изменений.
{t('customization.colors.backgroundImageHelp')}
</div>
</div>
{settings.background_image_url && (
<div className="mb-2">
<label className="form-label small">Текущее изображение:</label>
<div>
<label className="form-label small">{t('customization.colors.currentImage')}</label>
<div className="d-flex align-items-center gap-2">
<img
src={settings.background_image_url}
alt="Текущий фон"
alt={t('customization.colors.currentBackgroundAlt')}
className="img-thumbnail"
style={{ maxWidth: '200px', maxHeight: '100px', objectFit: 'cover' }}
/>
<button
type="button"
className="btn btn-outline-danger btn-sm"
onClick={() => {
handleChange('background_image_url', '')
}}
title={t('customization.colors.removeBackground')}
>
<i className="bi bi-trash"></i> {t('customization.colors.removeBackground')}
</button>
</div>
</div>
)}
{backgroundImageFile && (
<div className="mb-2">
<label className="form-label small">Новое изображение (будет применено после сохранения):</label>
<label className="form-label small">{t('customization.colors.newImage')}</label>
<div className="text-success">
<i className="bi bi-file-earmark-image me-1"></i>
{backgroundImageFile.name}
@@ -485,7 +543,7 @@ export function CustomizationPanel({ isOpen, onClose, onSettingsUpdate }: Custom
)}
</div>
<div className="col-md-4 mb-3">
<label className="form-label">Цвет заголовков</label>
<label className="form-label">{t('customization.colors.header')}</label>
<div className="input-group">
<input
type="color"
@@ -502,7 +560,7 @@ export function CustomizationPanel({ isOpen, onClose, onSettingsUpdate }: Custom
</div>
</div>
<div className="col-md-4 mb-3">
<label className="form-label">Цвет названий групп</label>
<label className="form-label">{t('customization.colors.group')}</label>
<div className="input-group">
<input
type="color"
@@ -519,7 +577,7 @@ export function CustomizationPanel({ isOpen, onClose, onSettingsUpdate }: Custom
</div>
</div>
<div className="col-md-4 mb-3">
<label className="form-label">Цвет названий ссылок</label>
<label className="form-label">{t('customization.colors.link')}</label>
<div className="input-group">
<input
type="color"
@@ -544,7 +602,7 @@ export function CustomizationPanel({ isOpen, onClose, onSettingsUpdate }: Custom
<div className="tab-pane fade show active">
<div className="row">
<div className="col-12 mb-3">
<h6 className="text-muted">Настройки отображения групп</h6>
<h6 className="text-muted">{t('customization.groups.displaySettings')}</h6>
</div>
<div className="col-12 mb-3">
<div className="form-check form-switch">
@@ -596,13 +654,13 @@ export function CustomizationPanel({ isOpen, onClose, onSettingsUpdate }: Custom
onChange={(e) => handleChange('show_groups_title', e.target.checked)}
/>
<label className="form-check-label">
Показывать заголовок "Группы ссылок"
{t('customization.colors.showGroupsTitle')}
</label>
</div>
</div>
<div className="col-md-6 mb-3">
<label className="form-label">Цвет описаний групп</label>
<label className="form-label">{t('customization.colors.groupDescription')}</label>
<div className="input-group">
<input
type="color"
@@ -623,7 +681,7 @@ export function CustomizationPanel({ isOpen, onClose, onSettingsUpdate }: Custom
<div className="col-12 mb-3">
<div className="card">
<div className="card-header">
<h6 className="mb-0">Цветовое перекрытие групп</h6>
<h6 className="mb-0">{t('customization.colors.groupOverlay')}</h6>
</div>
<div className="card-body">
<div className="form-check mb-3">
@@ -650,7 +708,7 @@ export function CustomizationPanel({ isOpen, onClose, onSettingsUpdate }: Custom
className="form-control form-control-color"
value={settings.group_overlay_color || '#000000'}
onChange={(e) => handleChange('group_overlay_color', e.target.value)}
title="Выберите цвет перекрытия"
title={t('customization.colors.chooseOverlayColor')}
/>
<input
type="text"
@@ -658,7 +716,7 @@ export function CustomizationPanel({ isOpen, onClose, onSettingsUpdate }: Custom
value={settings.group_overlay_color || '#000000'}
onChange={(e) => handleChange('group_overlay_color', e.target.value)}
placeholder="#000000"
title="Hex код цвета"
title={t('customization.colors.hexColorCode')}
/>
</div>
</div>
@@ -709,7 +767,7 @@ export function CustomizationPanel({ isOpen, onClose, onSettingsUpdate }: Custom
<div className="col-12">
<div className="alert alert-info">
<i className="bi bi-info-circle me-2"></i>
<strong>Настройки отдельных групп</strong><br/>
<strong>{t('customization.advanced.individualGroupSettings')}</strong><br/>
Чтобы настроить конкретную группу (публичность, избранное, разворачивание), используйте кнопку редактирования рядом с названием группы в основном списке.
</div>
</div>
@@ -718,7 +776,7 @@ export function CustomizationPanel({ isOpen, onClose, onSettingsUpdate }: Custom
<div className="col-12 mt-4">
<div className="border rounded p-3">
<div className="d-flex align-items-center justify-content-between mb-3">
<h6 className="mb-0">Цветовое перекрытие кнопок ссылок</h6>
<h6 className="mb-0">{t('customization.colors.linkOverlay')}</h6>
<div className="form-check form-switch">
<input
className="form-check-input"
@@ -816,17 +874,17 @@ export function CustomizationPanel({ isOpen, onClose, onSettingsUpdate }: Custom
<div className="tab-pane fade show active">
<div className="row">
<div className="col-12 mb-4">
<h6 className="text-muted">Настройки шрифтов</h6>
<h6 className="text-muted">{t('customization.advanced.fontSettings')}</h6>
</div>
<div className="col-md-6 mb-3">
<label className="form-label">Основной шрифт</label>
<label className="form-label">{t('customization.advanced.mainFont')}</label>
<select
className="form-select"
value={settings.font_family}
onChange={(e) => handleChange('font_family', e.target.value)}
>
<option value="sans-serif">Системный Sans Serif</option>
<option value="serif">Системный Serif</option>
<option value="sans-serif">{t('customization.advanced.systemSansSerif')}</option>
<option value="serif">{t('customization.advanced.systemSerif')}</option>
<option value="monospace">Monospace</option>
<option value="'PT Sans', sans-serif">PT Sans</option>
<option value="'PT Serif', serif">PT Serif</option>
@@ -842,13 +900,13 @@ export function CustomizationPanel({ isOpen, onClose, onSettingsUpdate }: Custom
</select>
</div>
<div className="col-md-6 mb-3">
<label className="form-label">Шрифт заголовков</label>
<label className="form-label">{t('customization.advanced.headingFont')}</label>
<select
className="form-select"
value={settings.heading_font_family || settings.font_family}
onChange={(e) => handleChange('heading_font_family', e.target.value)}
>
<option value="">Как основной</option>
<option value="">{t('customization.advanced.sameAsMain')}</option>
<option value="'PT Sans', sans-serif">PT Sans</option>
<option value="'PT Serif', serif">PT Serif</option>
<option value="'Roboto', sans-serif">Roboto</option>
@@ -871,13 +929,13 @@ export function CustomizationPanel({ isOpen, onClose, onSettingsUpdate }: Custom
</select>
</div>
<div className="col-md-6 mb-3">
<label className="form-label">Шрифт основного текста</label>
<label className="form-label">{t('customization.advanced.bodyFont')}</label>
<select
className="form-select"
value={settings.body_font_family || settings.font_family}
onChange={(e) => handleChange('body_font_family', e.target.value)}
>
<option value="">Как основной</option>
<option value="">{t('customization.advanced.sameAsMain')}</option>
<option value="'PT Sans', sans-serif">PT Sans</option>
<option value="'PT Serif', serif">PT Serif</option>
<option value="'Roboto', sans-serif">Roboto</option>
@@ -905,11 +963,11 @@ export function CustomizationPanel({ isOpen, onClose, onSettingsUpdate }: Custom
<div className="col-12 mb-4">
<hr />
<h6 className="text-muted">Дополнительные настройки</h6>
<h6 className="text-muted">{t('customization.advanced.additionalSettings')}</h6>
</div>
<div className="col-12 mb-3">
<label className="form-label">Дополнительный CSS</label>
<label className="form-label">{t('customization.advanced.customCSS')}</label>
<textarea
className="form-control font-monospace"
rows={6}
@@ -1008,6 +1066,108 @@ export function CustomizationPanel({ isOpen, onClose, onSettingsUpdate }: Custom
</div>
</div>
)}
{/* Вкладка: Данные */}
{activeTab === 'data' && (
<div className="tab-pane fade show active">
<div className="row">
<div className="col-12 mb-4">
<h6 className="text-muted">
<i className="bi bi-database me-2"></i>
Экспорт и импорт данных профиля
</h6>
<p className="text-muted small">
Создавайте резервные копии данных профиля или восстанавливайте их из архива
</p>
</div>
{/* Экспорт данных */}
<div className="col-12 mb-4">
<div className="card">
<div className="card-header">
<h6 className="card-title mb-0">
<i className="bi bi-upload me-2"></i>
Экспорт данных
</h6>
</div>
<div className="card-body">
<p className="text-muted small mb-3">
Создать архив с данными профиля для резервного копирования или переноса
</p>
<button
type="button"
className="btn btn-outline-primary"
onClick={() => setShowExportModal(true)}
>
<i className="bi bi-download me-2"></i>
Создать экспорт
</button>
</div>
</div>
</div>
{/* Импорт данных */}
<div className="col-12 mb-4">
<div className="card">
<div className="card-header">
<h6 className="card-title mb-0">
<i className="bi bi-upload me-2"></i>
Импорт данных
</h6>
</div>
<div className="card-body">
<p className="text-muted small mb-3">
Загрузить и восстановить данные из архива экспорта
</p>
<div className="mb-3">
<label className="form-label">Выберите файл архива (.zip)</label>
<input
type="file"
className="form-control"
accept=".zip"
onChange={(e) => {
// TODO: Обработать загрузку файла и показать превью
const file = e.target.files?.[0]
if (file) {
console.log('Файл выбран:', file.name)
}
}}
/>
</div>
<button
type="button"
className="btn btn-outline-success"
onClick={() => setShowImportModal(true)}
>
<i className="bi bi-upload me-2"></i>
Открыть мастер импорта
</button>
</div>
</div>
</div>
{/* История операций */}
<div className="col-12">
<div className="card">
<div className="card-header">
<h6 className="card-title mb-0">
<i className="bi bi-clock-history me-2"></i>
История операций
</h6>
</div>
<div className="card-body">
<p className="text-muted">
Здесь будет отображаться история экспортов и импортов
</p>
</div>
</div>
</div>
</div>
</div>
)}
</div>
</div>
@@ -1019,6 +1179,49 @@ export function CustomizationPanel({ isOpen, onClose, onSettingsUpdate }: Custom
>
Отмена
</button>
<button
type="button"
className="btn btn-outline-warning"
onClick={() => {
if (confirm(t('customization.advanced.resetConfirm'))) {
// Сброс к дефолтным настройкам
const defaultSettings = {
theme_color: '#007bff',
background_image_url: '',
dashboard_layout: 'list' as const,
groups_default_expanded: true,
show_group_icons: true,
show_link_icons: true,
dashboard_background_color: '#ffffff',
font_family: 'Inter, sans-serif',
custom_css: '',
group_text_color: '',
link_text_color: '',
header_text_color: '',
cover_overlay_enabled: false,
cover_overlay_color: '#000000',
cover_overlay_opacity: 0.3,
group_overlay_enabled: false,
group_overlay_color: '#000000',
group_overlay_opacity: 0.3,
show_groups_title: true,
group_description_text_color: '',
body_font_family: 'Inter, sans-serif',
heading_font_family: 'Inter, sans-serif',
link_overlay_enabled: false,
link_overlay_color: '#000000',
link_overlay_opacity: 0.3
}
setSettings(defaultSettings)
onSettingsUpdate(defaultSettings)
}
}}
disabled={loading}
title={t('customization.resetSettings')}
>
<i className="bi bi-arrow-counterclockwise me-2"></i>
{t('customization.resetSettings')}
</button>
<button
type="button"
className="btn btn-primary"
@@ -1028,18 +1231,36 @@ export function CustomizationPanel({ isOpen, onClose, onSettingsUpdate }: Custom
{loading ? (
<>
<span className="spinner-border spinner-border-sm me-2"></span>
Сохранение...
{t('common.saving')}
</>
) : (
<>
<i className="bi bi-check-lg me-2"></i>
Сохранить
{t('common.save')}
</>
)}
</button>
</div>
</div>
</div>
{/* Модалы экспорта и импорта */}
<ExportDataModal
isOpen={showExportModal}
onClose={() => setShowExportModal(false)}
user={user || null}
groups={groups}
/>
<ImportDataModal
isOpen={showImportModal}
onClose={() => setShowImportModal(false)}
onImportComplete={() => {
if (onDataUpdate) {
onDataUpdate()
}
}}
/>
</div>
)
}

View File

@@ -0,0 +1,390 @@
'use client'
import React, { useState, useEffect } from 'react'
interface UserProfile {
id: number
username: string
email: string
full_name: string
bio?: string
avatar_url?: string
}
interface LinkItem {
id: number
title: string
url: string
icon_url?: string
group: number
}
interface Group {
id: number
name: string
description?: string
icon_url?: string
background_image_url?: string
is_public?: boolean
is_favorite?: boolean
links: LinkItem[]
}
interface ExportDataModalProps {
isOpen: boolean
onClose: () => void
user: UserProfile | null
groups: Group[]
}
interface ExportSelection {
profile: boolean
groups: { [key: number]: boolean }
links: { [key: number]: boolean }
styles: boolean
media: boolean
}
export function ExportDataModal({ isOpen, onClose, user, groups }: ExportDataModalProps) {
const [selection, setSelection] = useState<ExportSelection>({
profile: true,
groups: {},
links: {},
styles: true,
media: true
})
const [expandedGroups, setExpandedGroups] = useState<{ [key: number]: boolean }>({})
const [loading, setLoading] = useState(false)
// Инициализация выбора при открытии модала
useEffect(() => {
if (isOpen) {
const newSelection: ExportSelection = {
profile: true,
groups: {},
links: {},
styles: true,
media: true
}
// По умолчанию выбираем все группы
groups.forEach(group => {
newSelection.groups[group.id] = true
// По умолчанию выбираем все ссылки в группе
group.links.forEach(link => {
newSelection.links[link.id] = true
})
})
setSelection(newSelection)
}
}, [isOpen, groups])
const handleGroupToggle = (groupId: number) => {
const group = groups.find(g => g.id === groupId)
if (!group) return
const newGroupState = !selection.groups[groupId]
setSelection(prev => {
const newSelection = { ...prev }
newSelection.groups[groupId] = newGroupState
// Переключаем все ссылки в группе
group.links.forEach(link => {
newSelection.links[link.id] = newGroupState
})
return newSelection
})
}
const handleLinkToggle = (linkId: number) => {
setSelection(prev => ({
...prev,
links: {
...prev.links,
[linkId]: !prev.links[linkId]
}
}))
}
const toggleGroupExpansion = (groupId: number) => {
setExpandedGroups(prev => ({
...prev,
[groupId]: !prev[groupId]
}))
}
const handleExport = async () => {
setLoading(true)
try {
const selectedGroupIds = Object.keys(selection.groups)
.filter(id => selection.groups[parseInt(id)])
.map(id => parseInt(id))
const selectedLinkIds = Object.keys(selection.links)
.filter(id => selection.links[parseInt(id)])
.map(id => parseInt(id))
const exportData = {
include_profile: selection.profile,
include_groups: selectedGroupIds.length > 0,
include_links: selectedLinkIds.length > 0,
include_styles: selection.styles,
include_media: selection.media,
selected_groups: selectedGroupIds,
selected_links: selectedLinkIds
}
const token = localStorage.getItem('access_token') || localStorage.getItem('token')
const API = process.env.NEXT_PUBLIC_API_URL || 'https://links.shareon.kr'
const response = await fetch(`${API}/api/export/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(exportData)
})
if (response.ok) {
const result = await response.json()
if (result.download_url) {
// Скачиваем файл
const downloadResponse = await fetch(`${API}${result.download_url}`, {
headers: {
'Authorization': `Bearer ${token}`
}
})
if (downloadResponse.ok) {
const blob = await downloadResponse.blob()
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `profile_export_${user?.username || 'user'}_${new Date().toISOString().split('T')[0]}.zip`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
alert('Экспорт создан и загружен успешно!')
onClose()
} else {
throw new Error('Ошибка при скачивании файла')
}
} else {
throw new Error('Файл экспорта не создан')
}
} else {
const errorData = await response.json()
throw new Error(errorData.error || 'Ошибка при создании экспорта')
}
} catch (error) {
console.error('Ошибка экспорта:', error)
alert('Ошибка при создании экспорта: ' + (error instanceof Error ? error.message : 'Неизвестная ошибка'))
} finally {
setLoading(false)
}
}
const getSelectedCount = () => {
const groupsCount = Object.values(selection.groups).filter(Boolean).length
const linksCount = Object.values(selection.links).filter(Boolean).length
return { groups: groupsCount, links: linksCount }
}
if (!isOpen) return null
const { groups: selectedGroupsCount, links: selectedLinksCount } = getSelectedCount()
return (
<div className="modal fade show d-block" style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}>
<div className="modal-dialog modal-lg">
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title">
<i className="bi bi-download me-2"></i>
Экспорт данных профиля
</h5>
<button
type="button"
className="btn-close"
onClick={onClose}
disabled={loading}
></button>
</div>
<div className="modal-body">
<p className="text-muted mb-4">
Выберите данные для включения в архив экспорта
</p>
{/* Общие настройки */}
<div className="mb-4">
<h6 className="mb-3">Общие данные</h6>
<div className="form-check mb-2">
<input
className="form-check-input"
type="checkbox"
id="export-profile"
checked={selection.profile}
onChange={(e) => setSelection(prev => ({ ...prev, profile: e.target.checked }))}
/>
<label className="form-check-label" htmlFor="export-profile">
<i className="bi bi-person me-2"></i>
Данные профиля (имя, био, аватар)
</label>
</div>
<div className="form-check mb-2">
<input
className="form-check-input"
type="checkbox"
id="export-styles"
checked={selection.styles}
onChange={(e) => setSelection(prev => ({ ...prev, styles: e.target.checked }))}
/>
<label className="form-check-label" htmlFor="export-styles">
<i className="bi bi-palette me-2"></i>
Настройки дизайна и стили
</label>
</div>
<div className="form-check mb-3">
<input
className="form-check-input"
type="checkbox"
id="export-media"
checked={selection.media}
onChange={(e) => setSelection(prev => ({ ...prev, media: e.target.checked }))}
/>
<label className="form-check-label" htmlFor="export-media">
<i className="bi bi-image me-2"></i>
Медиафайлы (изображения, иконки)
</label>
</div>
</div>
{/* Выбор групп и ссылок */}
<div className="mb-4">
<h6 className="mb-3">
Группы и ссылки
<span className="badge bg-secondary ms-2">
{selectedGroupsCount} групп, {selectedLinksCount} ссылок
</span>
</h6>
<div className="border rounded p-3" style={{ maxHeight: '300px', overflowY: 'auto' }}>
{groups.map(group => (
<div key={group.id} className="mb-3">
<div className="d-flex align-items-center">
<div className="form-check me-2">
<input
className="form-check-input"
type="checkbox"
id={`group-${group.id}`}
checked={selection.groups[group.id] || false}
onChange={() => handleGroupToggle(group.id)}
/>
<label className="form-check-label fw-medium" htmlFor={`group-${group.id}`}>
{group.icon_url && (
<img
src={group.icon_url}
alt=""
className="me-2"
style={{ width: '16px', height: '16px', objectFit: 'cover' }}
/>
)}
{group.name}
<span className="text-muted ms-2">({group.links.length} ссылок)</span>
</label>
</div>
{group.links.length > 0 && (
<button
type="button"
className="btn btn-sm btn-outline-secondary ms-auto"
onClick={() => toggleGroupExpansion(group.id)}
>
<i className={`bi ${expandedGroups[group.id] ? 'bi-chevron-up' : 'bi-chevron-down'}`}></i>
</button>
)}
</div>
{/* Список ссылок в группе */}
{expandedGroups[group.id] && group.links.length > 0 && (
<div className="ms-4 mt-2">
{group.links.map(link => (
<div key={link.id} className="form-check mb-1">
<input
className="form-check-input"
type="checkbox"
id={`link-${link.id}`}
checked={selection.links[link.id] || false}
onChange={() => handleLinkToggle(link.id)}
/>
<label className="form-check-label small" htmlFor={`link-${link.id}`}>
{link.icon_url && (
<img
src={link.icon_url}
alt=""
className="me-2"
style={{ width: '14px', height: '14px', objectFit: 'cover' }}
/>
)}
{link.title}
<span className="text-muted ms-2">({link.url})</span>
</label>
</div>
))}
</div>
)}
</div>
))}
{groups.length === 0 && (
<p className="text-muted text-center mb-0">
Нет групп для экспорта
</p>
)}
</div>
</div>
</div>
<div className="modal-footer">
<button
type="button"
className="btn btn-secondary"
onClick={onClose}
disabled={loading}
>
Отмена
</button>
<button
type="button"
className="btn btn-primary"
onClick={handleExport}
disabled={loading}
>
{loading ? (
<>
<span className="spinner-border spinner-border-sm me-2"></span>
Создание экспорта...
</>
) : (
<>
<i className="bi bi-download me-2"></i>
Создать и скачать
</>
)}
</button>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,403 @@
'use client'
import React, { useState } from 'react'
interface ImportDataModalProps {
isOpen: boolean
onClose: () => void
onImportComplete?: () => void
}
interface ImportPreview {
export_info?: {
username: string
export_date: string
}
user_data?: {
username: string
email: string
full_name: string
bio?: string
}
groups_count: number
links_count: number
has_design_settings: boolean
media_files: {
avatars: number
customization: number
link_groups: number
links: number
}
groups_preview: Array<{
id: number
title: string
description?: string
}>
links_preview: Array<{
id: number
title: string
url: string
group_id: number
}>
}
interface ImportSelection {
groups: boolean
links: boolean
styles: boolean
media: boolean
overwrite_existing: boolean
}
export function ImportDataModal({ isOpen, onClose, onImportComplete }: ImportDataModalProps) {
const [selectedFile, setSelectedFile] = useState<File | null>(null)
const [preview, setPreview] = useState<ImportPreview | null>(null)
const [selection, setSelection] = useState<ImportSelection>({
groups: true,
links: true,
styles: true,
media: true,
overwrite_existing: false
})
const [loading, setLoading] = useState(false)
const [previewLoading, setPreviewLoading] = useState(false)
const handleFileSelect = async (file: File) => {
setSelectedFile(file)
setPreview(null)
if (!file.name.endsWith('.zip')) {
alert('Пожалуйста, выберите ZIP архив')
return
}
setPreviewLoading(true)
try {
const formData = new FormData()
formData.append('import_file', file)
const token = localStorage.getItem('access_token') || localStorage.getItem('token')
const API = process.env.NEXT_PUBLIC_API_URL || 'https://links.shareon.kr'
const response = await fetch(`${API}/api/import/preview/`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`
},
body: formData
})
if (response.ok) {
const previewData = await response.json()
setPreview(previewData)
} else {
const errorData = await response.json()
throw new Error(errorData.error || 'Ошибка при анализе архива')
}
} catch (error) {
console.error('Ошибка при анализе файла:', error)
alert('Ошибка при анализе архива: ' + (error instanceof Error ? error.message : 'Неизвестная ошибка'))
setSelectedFile(null)
} finally {
setPreviewLoading(false)
}
}
const handleImport = async () => {
if (!selectedFile) {
alert('Пожалуйста, выберите файл для импорта')
return
}
setLoading(true)
try {
const formData = new FormData()
formData.append('import_file', selectedFile)
formData.append('import_groups', selection.groups.toString())
formData.append('import_links', selection.links.toString())
formData.append('import_styles', selection.styles.toString())
formData.append('import_media', selection.media.toString())
formData.append('overwrite_existing', selection.overwrite_existing.toString())
const token = localStorage.getItem('access_token') || localStorage.getItem('token')
const API = process.env.NEXT_PUBLIC_API_URL || 'https://links.shareon.kr'
const response = await fetch(`${API}/api/import/`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`
},
body: formData
})
if (response.ok) {
const result = await response.json()
alert(`Импорт завершен успешно!\nИмпортировано групп: ${result.imported_groups_count}\nИмпортировано ссылок: ${result.imported_links_count}\nИмпортировано медиафайлов: ${result.imported_media_count}`)
// Очищаем состояние
setSelectedFile(null)
setPreview(null)
// Вызываем коллбэк для обновления данных
if (onImportComplete) {
onImportComplete()
}
onClose()
} else {
const errorData = await response.json()
throw new Error(errorData.error || 'Ошибка при импорте')
}
} catch (error) {
console.error('Ошибка импорта:', error)
alert('Ошибка при импорте: ' + (error instanceof Error ? error.message : 'Неизвестная ошибка'))
} finally {
setLoading(false)
}
}
const formatFileSize = (bytes: number) => {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}
if (!isOpen) return null
return (
<div className="modal fade show d-block" style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}>
<div className="modal-dialog modal-lg">
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title">
<i className="bi bi-upload me-2"></i>
Импорт данных профиля
</h5>
<button
type="button"
className="btn-close"
onClick={onClose}
disabled={loading}
></button>
</div>
<div className="modal-body">
{/* Выбор файла */}
<div className="mb-4">
<label className="form-label">Выберите архив для импорта</label>
<input
type="file"
className="form-control"
accept=".zip"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) {
handleFileSelect(file)
}
}}
disabled={previewLoading || loading}
/>
{selectedFile && (
<div className="mt-2 small text-muted">
<i className="bi bi-file-zip me-1"></i>
{selectedFile.name} ({formatFileSize(selectedFile.size)})
</div>
)}
</div>
{/* Индикатор загрузки превью */}
{previewLoading && (
<div className="text-center py-4">
<div className="spinner-border text-primary" role="status">
<span className="visually-hidden">Анализ архива...</span>
</div>
<p className="text-muted mt-2">Анализ архива...</p>
</div>
)}
{/* Превью содержимого */}
{preview && (
<div className="mb-4">
<h6 className="mb-3">Содержимое архива</h6>
<div className="card">
<div className="card-body">
<div className="row">
<div className="col-md-6">
<h6 className="card-subtitle mb-2 text-muted">Информация об экспорте</h6>
{preview.export_info && (
<ul className="list-unstyled small">
<li><strong>Источник:</strong> {preview.export_info.username}</li>
<li><strong>Дата экспорта:</strong> {new Date(preview.export_info.export_date).toLocaleString()}</li>
</ul>
)}
</div>
<div className="col-md-6">
<h6 className="card-subtitle mb-2 text-muted">Статистика данных</h6>
<ul className="list-unstyled small">
<li><i className="bi bi-collection me-1"></i> Групп: {preview.groups_count}</li>
<li><i className="bi bi-link-45deg me-1"></i> Ссылок: {preview.links_count}</li>
<li><i className="bi bi-palette me-1"></i> Настройки дизайна: {preview.has_design_settings ? 'Есть' : 'Нет'}</li>
<li>
<i className="bi bi-image me-1"></i>
Медиафайлов: {Object.values(preview.media_files).reduce((a, b) => a + b, 0)}
</li>
</ul>
</div>
</div>
{/* Превью групп */}
{preview.groups_preview.length > 0 && (
<div className="mt-3">
<h6 className="card-subtitle mb-2 text-muted">Группы (первые 5)</h6>
<div className="list-group list-group-flush small">
{preview.groups_preview.map((group, index) => (
<div key={index} className="list-group-item p-2">
<strong>{group.title}</strong>
{group.description && (
<div className="text-muted">{group.description}</div>
)}
</div>
))}
</div>
</div>
)}
{/* Превью ссылок */}
{preview.links_preview.length > 0 && (
<div className="mt-3">
<h6 className="card-subtitle mb-2 text-muted">Ссылки (первые 10)</h6>
<div className="list-group list-group-flush small">
{preview.links_preview.map((link, index) => (
<div key={index} className="list-group-item p-2">
<strong>{link.title}</strong>
<div className="text-muted">{link.url}</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
</div>
)}
{/* Настройки импорта */}
{preview && (
<div className="mb-4">
<h6 className="mb-3">Настройки импорта</h6>
<div className="form-check mb-2">
<input
className="form-check-input"
type="checkbox"
id="import-groups"
checked={selection.groups}
onChange={(e) => setSelection(prev => ({ ...prev, groups: e.target.checked }))}
/>
<label className="form-check-label" htmlFor="import-groups">
<i className="bi bi-collection me-2"></i>
Импортировать группы ({preview.groups_count})
</label>
</div>
<div className="form-check mb-2">
<input
className="form-check-input"
type="checkbox"
id="import-links"
checked={selection.links}
onChange={(e) => setSelection(prev => ({ ...prev, links: e.target.checked }))}
/>
<label className="form-check-label" htmlFor="import-links">
<i className="bi bi-link-45deg me-2"></i>
Импортировать ссылки ({preview.links_count})
</label>
</div>
<div className="form-check mb-2">
<input
className="form-check-input"
type="checkbox"
id="import-styles"
checked={selection.styles}
onChange={(e) => setSelection(prev => ({ ...prev, styles: e.target.checked }))}
disabled={!preview.has_design_settings}
/>
<label className="form-check-label" htmlFor="import-styles">
<i className="bi bi-palette me-2"></i>
Импортировать настройки дизайна
{!preview.has_design_settings && <span className="text-muted"> (недоступно)</span>}
</label>
</div>
<div className="form-check mb-3">
<input
className="form-check-input"
type="checkbox"
id="import-media"
checked={selection.media}
onChange={(e) => setSelection(prev => ({ ...prev, media: e.target.checked }))}
/>
<label className="form-check-label" htmlFor="import-media">
<i className="bi bi-image me-2"></i>
Импортировать медиафайлы ({Object.values(preview.media_files).reduce((a, b) => a + b, 0)})
</label>
</div>
<div className="form-check">
<input
className="form-check-input"
type="checkbox"
id="overwrite-existing"
checked={selection.overwrite_existing}
onChange={(e) => setSelection(prev => ({ ...prev, overwrite_existing: e.target.checked }))}
/>
<label className="form-check-label" htmlFor="overwrite-existing">
<i className="bi bi-exclamation-triangle me-2 text-warning"></i>
Перезаписать существующие данные
</label>
<div className="form-text">
Если отключено, существующие группы и ссылки с такими же названиями будут пропущены
</div>
</div>
</div>
)}
</div>
<div className="modal-footer">
<button
type="button"
className="btn btn-secondary"
onClick={onClose}
disabled={loading || previewLoading}
>
Отмена
</button>
<button
type="button"
className="btn btn-success"
onClick={handleImport}
disabled={loading || previewLoading || !preview}
>
{loading ? (
<>
<span className="spinner-border spinner-border-sm me-2"></span>
Импорт...
</>
) : (
<>
<i className="bi bi-upload me-2"></i>
Импортировать
</>
)}
</button>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,61 @@
"use client"
import React, { useState } from 'react';
import { useLocale, Locale } from '../contexts/LocaleContext';
const LanguageSelector: React.FC = () => {
const { locale, setLocale, t } = useLocale();
const [isOpen, setIsOpen] = useState(false);
const languages: Array<{ code: Locale; name: string; flag: string }> = [
{ code: 'en', name: 'English', flag: '🇺🇸' },
{ code: 'ru', name: 'Русский', flag: '🇷🇺' },
{ code: 'ko', name: '한국어', flag: '🇰🇷' },
{ code: 'zh', name: '中文', flag: '🇨🇳' },
{ code: 'ja', name: '日本語', flag: '🇯🇵' },
];
const currentLanguage = languages.find(lang => lang.code === locale);
const handleLanguageChange = (langCode: Locale) => {
console.log('Changing language from', locale, 'to', langCode);
setLocale(langCode);
setIsOpen(false);
console.log('Language change completed');
};
return (
<div className="dropdown position-relative">
<button
className="btn btn-outline-secondary btn-sm d-flex align-items-center"
type="button"
onClick={() => setIsOpen(!isOpen)}
title="Выбрать язык"
>
<span className="me-1">{currentLanguage?.flag}</span>
<span className="d-none d-lg-inline me-1">{currentLanguage?.name}</span>
<i className={`bi bi-chevron-${isOpen ? 'up' : 'down'}`} style={{ fontSize: '0.7em' }}></i>
</button>
{isOpen && (
<ul
className="dropdown-menu show position-absolute end-0"
style={{ top: '100%', zIndex: 1000 }}
>
{languages.map((language) => (
<li key={language.code}>
<button
className={`dropdown-item d-flex align-items-center ${locale === language.code ? 'active' : ''}`}
onClick={() => handleLanguageChange(language.code)}
>
<span className="me-2">{language.flag}</span>
{language.name}
</button>
</li>
))}
</ul>
)}
</div>
);
};
export default LanguageSelector;

View File

@@ -6,41 +6,58 @@ import { usePathname, useRouter } from 'next/navigation'
import Link from 'next/link'
import Image from 'next/image'
import Script from 'next/script'
import { useLocale } from '../contexts/LocaleContext'
import ThemeToggle from './ThemeToggle'
import LanguageSelector from './LanguageSelector'
import '../layout.css'
interface User {
id: number
username: string
email: string
full_name: string
avatar: string | null
}
export function LayoutWrapper({ children }: { children: ReactNode }) {
const pathname = usePathname() || ''
const isPublicUserPage = /^\/[^\/]+$/.test(pathname)
const isDashboard = pathname === '/dashboard'
const [user, setUser] = useState<User | null>(null)
const [isLoading, setIsLoading] = useState(true)
const router = useRouter()
const { t } = useLocale()
// При монтировании пробуем загрузить профиль
useEffect(() => {
const token = localStorage.getItem('token')
if (token) {
fetch('/api/auth/user', {
headers: { Authorization: `Bearer ${token}` },
})
.then(res => {
if (!res.ok) throw new Error()
return res.json()
})
.then(data => {
// fullname или username
const name = data.full_name?.trim() || data.username
setUser({ username: name, avatar: data.avatar })
})
.catch(() => {
// сбросить некорректный токен
const checkAuth = async () => {
const token = localStorage.getItem('token')
if (token) {
try {
const response = await fetch('/api/auth/user', {
headers: { Authorization: `Bearer ${token}` },
})
if (response.ok) {
const data = await response.json()
setUser({
id: data.id,
username: data.username,
email: data.email,
full_name: data.full_name || '',
avatar: data.avatar
})
} else {
localStorage.removeItem('token')
setUser(null)
}
} catch (error) {
console.error('Auth check failed:', error)
localStorage.removeItem('token')
setUser(null)
})
}
}
setIsLoading(false)
}
checkAuth()
}, [])
const handleLogout = () => {
@@ -53,102 +70,142 @@ export function LayoutWrapper({ children }: { children: ReactNode }) {
return (
<>
{/* Шапка не выводим на публичных страницах /[username] */}
{!isPublicUserPage && (
<nav className="navbar navbar-expand bg-light fixed-top shadow-sm">
<nav className="navbar navbar-expand-lg navbar-light bg-light fixed-top shadow-sm border-bottom">
<div className="container">
<Link href="/" className="navbar-brand d-flex align-items-center">
<Image
src="/assets/img/CAT.png"
alt="CatLink"
width={89}
height={89}
width={32}
height={32}
className="me-2"
/>
<span className="ms-2">CatLink</span>
<span className="fw-bold">CatLink</span>
</Link>
<button
className="navbar-toggler"
type="button"
data-bs-toggle="collapse"
data-bs-target="#navcol-1"
/>
<div className="collapse navbar-collapse" id="navcol-1">
{!user && (
<Link href="/auth/login" className="btn btn-primary ms-auto">
<i className="fa fa-user"></i>
<span className="d-none d-sm-inline"> Вход</span>
</Link>
)}
{user && (
<div className="ms-auto d-flex align-items-center gap-3">
<Image
src={
user.avatar && user.avatar.startsWith('http')
? user.avatar
: user.avatar
? `${process.env.NEXT_PUBLIC_API_URL || 'https://links.shareon.kr'}${user.avatar}`
: '/assets/img/avatar-dhg.png'
}
alt="Avatar"
width={32}
height={32}
className="rounded-circle"
/>
<span>{user.username}</span>
{!isDashboard && (
<Link href="/dashboard" className="btn btn-outline-secondary btn-sm">
Дашборд
{/* Убираем navbar-toggler и делаем всё всегда видимым */}
<div className="d-flex justify-content-between align-items-center flex-grow-1">
{/* Левое меню */}
<ul className="navbar-nav d-flex flex-row me-auto">
{user && (
<>
<li className="nav-item me-3">
<Link href="/dashboard" className="nav-link">
<i className="bi bi-speedometer2 me-1"></i>
{t('dashboard.title')}
</Link>
</li>
<li className="nav-item me-3">
<Link href="/profile" className="nav-link">
<i className="bi bi-person-gear me-1"></i>
{t('common.profile')}
</Link>
</li>
</>
)}
</ul>
{/* Правое меню */}
<div className="d-flex align-items-center gap-2">
{/* Компоненты контекстов */}
<ThemeToggle />
<LanguageSelector />
{isLoading ? (
<div className="spinner-border spinner-border-sm ms-2" role="status">
<span className="visually-hidden">{t('common.loading')}</span>
</div>
) : !user ? (
<div className="d-flex gap-2 ms-2">
<Link href="/auth/login" className="btn btn-outline-primary btn-sm">
<i className="bi bi-box-arrow-in-right me-1"></i>
<span className="d-none d-sm-inline">{t('common.login')}</span>
</Link>
)}
<button
onClick={handleLogout}
className="btn btn-outline-danger btn-sm"
>
Выход
</button>
</div>
)}
<Link href="/auth/register" className="btn btn-primary btn-sm">
<i className="bi bi-person-plus me-1"></i>
<span className="d-none d-sm-inline">{t('common.register')}</span>
</Link>
</div>
) : (
<div className="dropdown ms-2">
<button
className="btn btn-outline-secondary dropdown-toggle d-flex align-items-center"
type="button"
data-bs-toggle="dropdown"
>
{user.avatar ? (
<Image
src={
user.avatar.startsWith('http')
? user.avatar
: `http://localhost:8000${user.avatar}`
}
alt="Avatar"
width={24}
height={24}
className="rounded-circle me-2"
/>
) : (
<i className="bi bi-person-circle me-2"></i>
)}
<span className="d-none d-md-inline">
{user.full_name?.trim() || user.username}
</span>
</button>
<ul className="dropdown-menu dropdown-menu-end">
<li>
<Link href="/dashboard" className="dropdown-item">
<i className="bi bi-speedometer2 me-2"></i>
{t('dashboard.title')}
</Link>
</li>
<li>
<Link href="/profile" className="dropdown-item">
<i className="bi bi-person-gear me-2"></i>
{t('common.profile')}
</Link>
</li>
<li><hr className="dropdown-divider" /></li>
<li>
<button
onClick={handleLogout}
className="dropdown-item text-danger"
>
<i className="bi bi-box-arrow-right me-2"></i>
{t('common.logout')}
</button>
</li>
</ul>
</div>
)}
</div>
</div>
</div>
</nav>
)}
{/* отступ, чтобы контент не прятался под фиксированным хедером */}
{!isPublicUserPage && <div style={{ height: 70 }} />}
{!isPublicUserPage && <div style={{ height: '76px' }} />}
{children}
{/* Подвал не выводим на публичных страницах */}
{!isPublicUserPage && (
<footer className="bg-light footer border-top mt-5">
<div className="container py-4">
<div className="row">
<div className="col-lg-6 text-center text-lg-start mb-2 mb-lg-0">
<ul className="list-inline mb-1">
<li className="list-inline-item"><Link href="#">About</Link></li>
<li className="list-inline-item"><Link href="#">{t('footer.about')}</Link></li>
<li className="list-inline-item"></li>
<li className="list-inline-item"><Link href="#">Contact</Link></li>
<li className="list-inline-item"></li>
<li className="list-inline-item"><Link href="#">Terms of Use</Link></li>
<li className="list-inline-item"></li>
<li className="list-inline-item"><Link href="#">Privacy Policy</Link></li>
</ul>
<p className="text-muted small mb-0">© CatLink 2025</p>
</div>
<div className="col-lg-6 text-center text-lg-end">
<ul className="list-inline mb-0">
<li className="list-inline-item"><Link href="#"><i className="fa fa-facebook fa-lg fa-fw"></i></Link></li>
<li className="list-inline-item"><Link href="#"><i className="fa fa-twitter fa-lg fa-fw"></i></Link></li>
<li className="list-inline-item"><Link href="#"><i className="fa fa-instagram fa-lg fa-fw"></i></Link></li>
<li className="list-inline-item"><Link href="#">{t('footer.contact')}</Link></li>
</ul>
<p className="text-muted small mb-0">{t('footer.copyright')}</p>
</div>
</div>
</div>
</footer>
)}
{/* Bootstrap JS */}
{/* Bootstrap JS: load after React hydrates to avoid DOM mutations during hydration */}
<Script
src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
strategy="afterInteractive"

View File

@@ -0,0 +1,302 @@
// src/components/LayoutWrapper.tsx
'use client'
import React, { ReactNode, useEffect, useState } from 'react'
import { usePathname, useRouter } from 'next/navigation'
import Link from 'next/link'
import Image from 'next/image'
import Script from 'next/script'
import { useLocale } from '../contexts/LocaleContext'
import { useTheme } from '../contexts/ThemeContext'
import ThemeToggle from './ThemeToggle'
import LanguageSelector from './LanguageSelector'
import '../layout.css'
interface User {
id: number
username: string
email: string
full_name: string
avatar: string | null
}
export function LayoutWrapper({ children }: { children: ReactNode }) {
const pathname = usePathname() || ''
const isPublicUserPage = /^\/[^\/]+$/.test(pathname)
const [user, setUser] = useState<User | null>(null)
const [isLoading, setIsLoading] = useState(true)
const router = useRouter()
const { t } = useLocale()
// При монтировании пробуем загрузить профиль
useEffect(() => {
const checkAuth = async () => {
const token = localStorage.getItem('token')
if (token) {
try {
const response = await fetch('/api/auth/user', {
headers: { Authorization: `Bearer ${token}` },
})
if (response.ok) {
const data = await response.json()
setUser({
id: data.id,
username: data.username,
email: data.email,
full_name: data.full_name || '',
avatar: data.avatar
})
} else {
// Токен недействителен
localStorage.removeItem('token')
setUser(null)
}
} catch (error) {
console.error('Auth check failed:', error)
localStorage.removeItem('token')
setUser(null)
}
}
setIsLoading(false)
}
checkAuth()
}, [])
const handleLogout = () => {
if (typeof window !== 'undefined') {
localStorage.removeItem('token')
}
setUser(null)
router.push('/')
}
return (
<>
{/* Шапка отображается на всех страницах кроме публичных /[username] */}
{!isPublicUserPage && (
<nav className="navbar navbar-expand-lg navbar-light bg-light fixed-top shadow-sm border-bottom">
<div className="container">
<Link href="/" className="navbar-brand d-flex align-items-center">
<Image
src="/assets/img/CAT.png"
alt="CatLink"
width={32}
height={32}
className="me-2"
/>
<span className="fw-bold">CatLink</span>
</Link>
<button
className="navbar-toggler"
type="button"
data-bs-toggle="collapse"
data-bs-target="#navcol-1"
aria-controls="navcol-1"
aria-expanded="false"
aria-label="Toggle navigation"
>
<span className="navbar-toggler-icon"></span>
</button>
<div className="collapse navbar-collapse" id="navcol-1">
{/* Левое меню */}
<ul className="navbar-nav me-auto">
{user && (
<>
<li className="nav-item">
<Link href="/dashboard" className="nav-link">
<i className="bi bi-speedometer2 me-1"></i>
Дашборд
</Link>
</li>
<li className="nav-item">
<Link href="/profile" className="nav-link">
<i className="bi bi-person-gear me-1"></i>
Профиль
</Link>
</li>
</>
)}
</ul>
{/* Правое меню - всегда отображается */}
<div className="d-flex align-items-center gap-2">
{/* Переключатели темы и языка - всегда видны */}
<ThemeToggle />
<LanguageSelector />
{/* Блок авторизации */}
{isLoading ? (
<div className="spinner-border spinner-border-sm ms-2" role="status">
<span className="visually-hidden">Загрузка...</span>
</div>
) : !user ? (
<div className="d-flex gap-2 ms-2">
<Link href="/auth/login" className="btn btn-outline-primary btn-sm">
<i className="bi bi-box-arrow-in-right me-1"></i>
<span className="d-none d-sm-inline">Вход</span>
</Link>
<Link href="/auth/register" className="btn btn-primary btn-sm">
<i className="bi bi-person-plus me-1"></i>
<span className="d-none d-sm-inline">Регистрация</span>
</Link>
</div>
) : (
<div className="dropdown ms-2">
<button
className="btn btn-outline-secondary dropdown-toggle d-flex align-items-center"
type="button"
id="userDropdown"
data-bs-toggle="dropdown"
aria-expanded="false"
>
{user.avatar ? (
<Image
src={
user.avatar.startsWith('http')
? user.avatar
: `${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'}${user.avatar}`
}
alt="Avatar"
width={24}
height={24}
className="rounded-circle me-2"
/>
) : (
<i className="bi bi-person-circle me-2"></i>
)}
<span className="d-none d-md-inline">
{user.full_name?.trim() || user.username}
</span>
</button>
<ul className="dropdown-menu dropdown-menu-end" aria-labelledby="userDropdown">
<li>
<Link href="/dashboard" className="dropdown-item">
<i className="bi bi-speedometer2 me-2"></i>
Дашборд
</Link>
</li>
<li>
<Link href="/profile" className="dropdown-item">
<i className="bi bi-person-gear me-2"></i>
Профиль
</Link>
</li>
<li><hr className="dropdown-divider" /></li>
<li>
<button
onClick={handleLogout}
className="dropdown-item text-danger"
>
<i className="bi bi-box-arrow-right me-2"></i>
Выход
</button>
</li>
</ul>
</div>
)}
</div>
</div>
</div>
</nav>
)}
{/* Отступ для фиксированного навбара */}
{!isPublicUserPage && <div style={{ height: '76px' }} />}
aria-expanded="false"
>
<Image
src={
user.avatar && user.avatar.startsWith('http')
? user.avatar
: user.avatar
? `${process.env.NEXT_PUBLIC_API_URL || 'https://links.shareon.kr'}${user.avatar}`
: '/assets/img/avatar-dhg.png'
}
alt="Avatar"
width={32}
height={32}
className="rounded-circle me-2"
/>
<span className="text-dark fw-medium d-none d-md-inline">
{user.full_name?.trim() || user.username}
</span>
</button>
<ul className="dropdown-menu dropdown-menu-end">
<li>
<Link href="/profile" className="dropdown-item">
<i className="fas fa-user me-2"></i>
{t('profile.edit')}
</Link>
</li>
<li>
<Link href="/dashboard" className="dropdown-item">
<i className="fas fa-tachometer-alt me-2"></i>
{t('dashboard.title')}
</Link>
</li>
<li><hr className="dropdown-divider" /></li>
<li>
<button
onClick={handleLogout}
className="dropdown-item text-danger"
>
<i className="fas fa-sign-out-alt me-2"></i>
{t('common.logout')}
</button>
</li>
</ul>
</div>
)}
</div>
</div>
</div>
</nav>
)}
{/* отступ, чтобы контент не прятался под фиксированным хедером */}
{!isPublicUserPage && <div className="navbar-spacing" />}
{children}
{/* Подвал не выводим на публичных страницах */}
{!isPublicUserPage && (
<footer className="bg-light footer border-top mt-5">
<div className="container py-4">
<div className="row">
<div className="col-lg-6 text-center text-lg-start mb-2 mb-lg-0">
<ul className="list-inline mb-1">
<li className="list-inline-item"><Link href="#">About</Link></li>
<li className="list-inline-item"></li>
<li className="list-inline-item"><Link href="#">Contact</Link></li>
<li className="list-inline-item"></li>
<li className="list-inline-item"><Link href="#">Terms of Use</Link></li>
<li className="list-inline-item"></li>
<li className="list-inline-item"><Link href="#">Privacy Policy</Link></li>
</ul>
<p className="text-muted small mb-0">© CatLink 2025</p>
</div>
<div className="col-lg-6 text-center text-lg-end">
<ul className="list-inline mb-0">
<li className="list-inline-item"><Link href="#"><i className="fa fa-facebook fa-lg fa-fw"></i></Link></li>
<li className="list-inline-item"><Link href="#"><i className="fa fa-twitter fa-lg fa-fw"></i></Link></li>
<li className="list-inline-item"><Link href="#"><i className="fa fa-instagram fa-lg fa-fw"></i></Link></li>
</ul>
</div>
</div>
</div>
</footer>
)}
{/* Bootstrap JS */}
{/* Bootstrap JS: load after React hydrates to avoid DOM mutations during hydration */}
<Script
src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
strategy="afterInteractive"
/>
<Script src="/assets/js/bs-init.js" strategy="afterInteractive" />
</>
)
}

View File

@@ -0,0 +1,28 @@
'use client'
import { useLocale } from '../contexts/LocaleContext'
import LanguageSelector from './LanguageSelector'
import ThemeToggle from './ThemeToggle'
interface NavbarProps {
className?: string
}
export function Navbar({ className = '' }: NavbarProps) {
const { t } = useLocale()
return (
<nav className={`navbar navbar-expand-lg navbar-light bg-light border-bottom ${className}`}>
<div className="container-fluid">
<a className="navbar-brand fw-bold" href="/">
🐱 CatLink
</a>
<div className="navbar-nav ms-auto d-flex flex-row align-items-center gap-3">
<LanguageSelector />
<ThemeToggle />
</div>
</div>
</nav>
)
}

View File

@@ -0,0 +1,21 @@
"use client"
import React from 'react';
import { ThemeProvider } from '../contexts/ThemeContext';
import { LocaleProvider } from '../contexts/LocaleContext';
interface ProvidersProps {
children: React.ReactNode;
}
const Providers: React.FC<ProvidersProps> = ({ children }) => {
return (
<LocaleProvider>
<ThemeProvider>
{children}
</ThemeProvider>
</LocaleProvider>
);
};
export default Providers;

View File

@@ -0,0 +1,133 @@
.theme-toggle {
position: relative;
width: 60px;
height: 30px;
background-color: var(--bs-light);
border: 2px solid var(--bs-border-color);
border-radius: 20px;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
}
.theme-toggle.dark {
background: linear-gradient(135deg, #7c3aed, #9333ea);
border: 2px solid #8b5cf6;
box-shadow: 0 4px 15px rgba(124, 58, 237, 0.25);
}
.theme-toggle-slider {
position: absolute;
top: 2px;
left: 2px;
width: 22px;
height: 22px;
background: linear-gradient(45deg, #ffd700, #ffed4e);
border-radius: 50%;
transition: all 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
}
.theme-toggle.dark .theme-toggle-slider {
transform: translateX(30px);
background: linear-gradient(45deg, #f8fafc, #e2e8f0);
box-shadow: 0 3px 12px rgba(124, 58, 237, 0.3);
}
.theme-toggle-icon {
font-size: 12px;
transition: all 0.4s ease;
transform-origin: center;
}
.theme-toggle .theme-toggle-icon {
animation: iconSpin 0.4s ease-in-out;
}
.theme-toggle.dark .theme-toggle-icon {
animation: iconSpin 0.4s ease-in-out;
}
@keyframes iconSpin {
0% {
transform: rotate(0deg) scale(1);
}
50% {
transform: rotate(180deg) scale(0.8);
}
100% {
transform: rotate(360deg) scale(1);
}
}
/* Дополнительные эффекты при наведении */
.theme-toggle:hover {
transform: scale(1.05);
}
.theme-toggle.dark:hover {
box-shadow: 0 6px 25px rgba(124, 58, 237, 0.35);
transform: scale(1.05);
}
.theme-toggle:active {
transform: scale(0.95);
}
/* Светлая тема - эффект солнечных лучей */
.theme-toggle:not(.dark)::before {
content: '';
position: absolute;
top: 50%;
left: 13px;
width: 2px;
height: 2px;
background: #ffd700;
border-radius: 50%;
box-shadow:
0 -8px 0 #ffd700,
6px -6px 0 #ffd700,
8px 0 0 #ffd700,
6px 6px 0 #ffd700,
0 8px 0 #ffd700,
-6px 6px 0 #ffd700,
-8px 0 0 #ffd700,
-6px -6px 0 #ffd700;
animation: sunRays 2s linear infinite;
opacity: 0.6;
}
@keyframes sunRays {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
/* Темная тема - звездочки */
.theme-toggle.dark::after {
content: '🌙';
position: absolute;
top: 50%;
right: 8px;
transform: translateY(-50%);
font-size: 10px;
animation: moonGlow 2s ease-in-out infinite alternate;
filter: drop-shadow(0 0 4px rgba(139, 92, 246, 0.6));
}
@keyframes moonGlow {
0% {
opacity: 0.6;
filter: drop-shadow(0 0 4px rgba(139, 92, 246, 0.6));
}
100% {
opacity: 1;
filter: drop-shadow(0 0 8px rgba(139, 92, 246, 0.9));
}
}

View File

@@ -0,0 +1,35 @@
"use client"
import React from 'react';
import { useTheme } from '../contexts/ThemeContext';
import { useLocale } from '../contexts/LocaleContext';
import './ThemeToggle.css';
const ThemeToggle: React.FC = () => {
const { theme, toggleTheme } = useTheme();
const { t } = useLocale();
return (
<div
className={`theme-toggle ${theme === 'dark' ? 'dark' : ''}`}
onClick={toggleTheme}
title={t('theme.toggle')}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggleTheme();
}
}}
>
<div className="theme-toggle-slider">
<span className="theme-toggle-icon">
{theme === 'dark' ? '🌙' : '☀️'}
</span>
</div>
</div>
);
};
export default ThemeToggle;

View File

@@ -1,21 +1,23 @@
import Link from 'next/link'
import { useLocale } from '../contexts/LocaleContext'
export function Footer() {
const { t } = useLocale()
return (
<footer className="bg-light footer py-5 border-top">
<div className="container">
<div className="row">
<div className="col-lg-6 text-center text-lg-start mb-3 mb-lg-0">
<ul className="list-inline mb-2">
<li className="list-inline-item"><Link href="#">About</Link></li>
<li className="list-inline-item"><Link href="#">{t('footer.about')}</Link></li>
<li className="list-inline-item"><span></span></li>
<li className="list-inline-item"><Link href="#">Contact</Link></li>
<li className="list-inline-item"><Link href="#">{t('footer.contact')}</Link></li>
<li className="list-inline-item"><span></span></li>
<li className="list-inline-item"><Link href="#">Terms of Use</Link></li>
<li className="list-inline-item"><Link href="#">{t('footer.terms')}</Link></li>
<li className="list-inline-item"><span></span></li>
<li className="list-inline-item"><Link href="#">Privacy Policy</Link></li>
<li className="list-inline-item"><Link href="#">{t('footer.privacy')}</Link></li>
</ul>
<p className="text-muted small mb-0">© CatLink 2025. Все права защищены.</p>
<p className="text-muted small mb-0">{t('footer.copyright')}</p>
</div>
<div className="col-lg-6 text-center text-lg-end">
<ul className="list-inline mb-0">

View File

@@ -0,0 +1,97 @@
'use client'
import React, { createContext, useContext, useEffect, useState } from 'react'
export type Locale = 'en' | 'ru' | 'ko' | 'zh' | 'ja'
interface LocaleContextType {
locale: Locale
setLocale: (locale: Locale) => void
t: (key: string, params?: Record<string, string | number>) => string
}
const LocaleContext = createContext<LocaleContextType | undefined>(undefined)
export const useLocale = () => {
const context = useContext(LocaleContext)
if (!context) {
throw new Error('useLocale must be used within a LocaleProvider')
}
return context
}
interface LocaleProviderProps {
children: React.ReactNode
}
// Функция для загрузки переводов
const loadTranslations = async (locale: Locale): Promise<Record<string, string>> => {
try {
const translations = await import(`../locales/${locale}.json`)
return translations.default
} catch (error) {
console.warn(`Failed to load translations for ${locale}, falling back to English`)
const fallback = await import('../locales/en.json')
return fallback.default
}
}
export const LocaleProvider: React.FC<LocaleProviderProps> = ({ children }) => {
const [locale, setLocaleState] = useState<Locale>('ru')
const [translations, setTranslations] = useState<Record<string, string>>({})
// Загружаем локаль из localStorage и браузера
useEffect(() => {
if (typeof window !== 'undefined') {
const savedLocale = localStorage.getItem('locale') as Locale
const browserLocale = navigator.language.toLowerCase()
let initialLocale: Locale = 'en'
if (savedLocale) {
initialLocale = savedLocale
} else if (browserLocale.startsWith('ru')) {
initialLocale = 'ru'
} else if (browserLocale.startsWith('ko')) {
initialLocale = 'ko'
} else if (browserLocale.startsWith('zh')) {
initialLocale = 'zh'
} else if (browserLocale.startsWith('ja')) {
initialLocale = 'ja'
}
setLocaleState(initialLocale)
}
}, [])
// Загружаем переводы при изменении локали
useEffect(() => {
loadTranslations(locale).then(setTranslations)
}, [locale])
const setLocale = (newLocale: Locale) => {
setLocaleState(newLocale)
if (typeof window !== 'undefined') {
localStorage.setItem('locale', newLocale)
}
}
// Функция перевода с поддержкой интерполяции
const t = (key: string, params?: Record<string, string | number>): string => {
let translation = translations[key] || key
if (params) {
Object.entries(params).forEach(([paramKey, paramValue]) => {
translation = translation.replace(new RegExp(`{{${paramKey}}}`, 'g'), String(paramValue))
})
}
return translation
}
return (
<LocaleContext.Provider value={{ locale, setLocale, t }}>
{children}
</LocaleContext.Provider>
)
}

View File

@@ -0,0 +1,67 @@
'use client'
import React, { createContext, useContext, useEffect, useState } from 'react'
export type Theme = 'light' | 'dark'
interface ThemeContextType {
theme: Theme
setTheme: (theme: Theme) => void
toggleTheme: () => void
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined)
export const useTheme = () => {
const context = useContext(ThemeContext)
if (!context) {
throw new Error('useTheme must be used within a ThemeProvider')
}
return context
}
interface ThemeProviderProps {
children: React.ReactNode
}
export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
const [theme, setThemeState] = useState<Theme>('light')
// Загружаем тему из localStorage при инициализации
useEffect(() => {
if (typeof window !== 'undefined') {
const savedTheme = localStorage.getItem('theme') as Theme
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
const initialTheme = savedTheme || systemTheme
setThemeState(initialTheme)
applyTheme(initialTheme)
}
}, [])
const applyTheme = (newTheme: Theme) => {
if (typeof document !== 'undefined') {
document.documentElement.classList.remove('light', 'dark')
document.documentElement.classList.add(newTheme)
document.documentElement.setAttribute('data-theme', newTheme)
}
}
const setTheme = (newTheme: Theme) => {
setThemeState(newTheme)
applyTheme(newTheme)
if (typeof window !== 'undefined') {
localStorage.setItem('theme', newTheme)
}
}
const toggleTheme = () => {
const newTheme = theme === 'light' ? 'dark' : 'light'
setTheme(newTheme)
}
return (
<ThemeContext.Provider value={{ theme, setTheme, toggleTheme }}>
{children}
</ThemeContext.Provider>
)
}

View File

@@ -0,0 +1,51 @@
/* Layout spacing */
.navbar-spacing {
height: 70px;
}
/* Navbar improvements */
.navbar-brand {
font-weight: 600;
font-size: 1.25rem;
}
.navbar .dropdown-toggle::after {
display: none;
}
.dropdown-menu {
border: none;
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
border-radius: 0.375rem;
}
.dropdown-item {
padding: 0.5rem 1rem;
font-size: 0.875rem;
}
.dropdown-item:hover {
background-color: var(--bs-primary);
color: white;
}
.dropdown-item.text-danger:hover {
background-color: var(--bs-danger);
color: white;
}
/* Theme aware navbar */
.navbar-expand-lg {
transition: background-color 0.3s ease;
}
/* Profile page styles */
.profile-avatar {
object-fit: cover;
}
.profile-cover {
max-height: 200px;
width: 100%;
object-fit: cover;
}

View File

@@ -2,8 +2,11 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import "./styles/themes.css";
import "./styles/comfort.css";
import { ReactNode } from "react";
import { LayoutWrapper } from "./components/LayoutWrapper";
import Providers from "./components/Providers";
const geistSans = Geist({
variable: "--font-geist-sans",
@@ -68,12 +71,14 @@ export default function RootLayout({
/>
<link rel="manifest" href="/manifest.json?h=457941ffad3c027c946331c09a4d7d2f" />
<meta name="theme-color" content="#ffffff" />
<meta name="theme-color" content="#1a1a23" />
</head>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<LayoutWrapper>{children}</LayoutWrapper>
<Providers>
<LayoutWrapper>{children}</LayoutWrapper>
</Providers>
</body>
</html>
);

View File

@@ -0,0 +1,344 @@
{
"common.cancel": "Cancel",
"common.save": "Save",
"common.saving": "Saving...",
"common.loading": "Loading...",
"common.error": "Error",
"common.success": "Success",
"common.close": "Close",
"common.edit": "Edit",
"common.delete": "Delete",
"common.add": "Add",
"common.create": "Create",
"common.update": "Update",
"common.search": "Search",
"common.settings": "Settings",
"common.profile": "Profile",
"common.logout": "Logout",
"common.login": "Login",
"common.register": "Register",
"common.back": "Back",
"common.next": "Next",
"common.previous": "Previous",
"common.submit": "Submit",
"common.reset": "Reset",
"common.clear": "Clear",
"common.confirm": "Confirm",
"common.yes": "Yes",
"common.no": "No",
"common.menu": "Menu",
"common.optional": "optional",
"common.closeModal": "Close modal",
"share.title": "Share Page",
"share.description": "Your public page with links is available at:",
"share.loading": "Loading...",
"share.urlAriaLabel": "Public page URL",
"share.urlTitle": "Public page URL",
"share.copy": "Copy",
"share.note": "All your groups and links will be visible on this page. It updates automatically when data changes.",
"share.openPage": "Open Page",
"home.title": "Your links. Your style. Your CatLink.",
"home.subtitle": "Create a beautiful personal page with all your important links in one place. Share professionally and stylishly!",
"home.emailPlaceholder": "Enter your email",
"home.startFree": "Start Free",
"home.haveAccount": "Already have an account?",
"home.signIn": "Sign In",
"home.features.title": "Why Choose CatLink?",
"home.features.subtitle": "Simple and powerful tool for creating your digital presence",
"home.features.links.title": "One URL — All Links",
"home.features.links.description": "Gather all important links in one place. Social media, portfolio, contacts — everything under one address.",
"home.features.customization.title": "Customization",
"home.features.customization.description": "Customize colors, fonts, layouts. Create a unique style that reflects your personality or brand.",
"home.features.analytics.title": "Analytics",
"home.features.analytics.description": "Track clicks, link popularity and visitor activity. Understand your audience better.",
"home.useCases.title": "For Everyone",
"home.useCases.bloggers": "Bloggers",
"home.useCases.bloggersDescription": "Gather all social media",
"home.useCases.business": "Business",
"home.useCases.businessDescription": "Show services and contacts",
"home.useCases.musicians": "Musicians",
"home.useCases.musiciansDescription": "Share your creativity",
"home.useCases.photographers": "Photographers",
"home.useCases.photographersDescription": "Show your portfolio",
"home.useCases.exampleTitle": "Your personal page",
"home.useCases.exampleSubtitle": "Example of your page",
"home.useCases.personalSite": "Personal website",
"home.cta.title": "Ready to Start?",
"home.cta.subtitle": "Join thousands of users who have already created their perfect link page",
"home.cta.createFree": "Create Free Account",
"home.cta.haveAccount": "I have an account",
"home.cta.features": "Free forever • No limits • Quick setup",
"auth.welcome": "Welcome!",
"auth.welcomeSubtitle": "Sign in to your CatLink account",
"auth.createAccount": "Create Account",
"auth.createAccountSubtitle": "Join CatLink today",
"auth.usernameLabel": "Username",
"auth.usernamePlaceholder": "Enter your username",
"auth.usernameRequired": "Please enter username",
"auth.usernameHelp": "Only Latin letters, numbers and _",
"auth.passwordLabel": "Password",
"auth.passwordPlaceholder": "Enter your password",
"auth.passwordRequired": "Please enter password",
"auth.passwordConfirmLabel": "Confirm Password",
"auth.passwordConfirmRequired": "Please confirm password",
"auth.passwordMismatch": "Passwords do not match",
"auth.emailLabel": "Email",
"auth.emailRequired": "Please enter email",
"auth.firstNameLabel": "First Name",
"auth.lastNameLabel": "Last Name",
"auth.loginButton": "Sign In",
"auth.registerButton": "Create Account",
"auth.loggingIn": "Signing in...",
"auth.registering": "Creating account...",
"auth.noAccount": "Don't have an account?",
"auth.haveAccount": "Already have an account?",
"auth.loginError": "Login error",
"auth.networkError": "Network error",
"auth.registrationError": "Registration error",
"auth.connectionError": "Server connection error",
"auth.termsAgreement": "By creating an account, you agree to the",
"auth.termsLink": "Terms of Service",
"auth.privacyLink": "Privacy Policy",
"auth.and": "and",
"auth.login.title": "Login",
"auth.login.email": "Email",
"auth.login.password": "Password",
"auth.login.remember": "Remember me",
"auth.login.forgot": "Forgot password?",
"auth.login.noAccount": "Don't have an account?",
"auth.login.signUp": "Sign up",
"auth.register.title": "Register",
"auth.register.username": "Username",
"auth.register.email": "Email",
"auth.register.password": "Password",
"auth.register.confirmPassword": "Confirm password",
"auth.register.firstName": "First name",
"auth.register.lastName": "Last name",
"auth.register.haveAccount": "Already have an account?",
"auth.register.signIn": "Sign in",
"dashboard.title": "Dashboard",
"dashboard.welcome": "Welcome, {{name}}!",
"dashboard.groups": "Groups",
"dashboard.links": "Links",
"dashboard.settings": "Settings",
"dashboard.share": "Share",
"dashboard.customize": "Customize",
"dashboard.panelOpen": "Open",
"dashboard.panelClosed": "Closed",
"dashboard.error": "Error: ",
"dashboard.linkGroups": "Link Groups",
"dashboard.andMore": "and {{count}} more...",
"dashboard.linksCount": "{{count}} links",
"dashboard.linksInGroup": "{{count}} links in this group",
"dashboard.addGroup": "Add Group",
"dashboard.addLink": "Add Link",
"dashboard.noGroups": "No groups yet",
"dashboard.noLinks": "No links yet",
"dashboard.createFirst": "Create your first",
"dashboard.shareUrl.copied": "Link copied to clipboard",
"group.create": "Create Group",
"group.edit": "Edit Group",
"group.delete": "delete group",
"group.name": "Group name",
"group.description": "Description",
"group.icon": "Icon",
"group.background": "Background image",
"group.color": "Header color",
"group.public": "Public",
"group.favorite": "Favorite",
"group.expanded": "Expanded by default",
"group.removeIcon": "Remove icon",
"group.removeBackground": "Remove background",
"group.descriptionPlaceholder": "Brief description of link group",
"group.currentIcon": "Current icon",
"group.confirmRemoveIcon": "Remove current group icon?",
"group.iconSizeRecommendation": "Recommended size: 32x32 pixels",
"group.currentBackground": "Current background",
"group.confirmRemoveBackground": "Remove current group background?",
"group.imageSizeRecommendation": "Recommended image size:",
"group.tip": "Tip:",
"group.borderTip": "For groups with borders, use images with edge padding (10-20px)",
"group.backgroundDescription": "Image will be used as background for group content",
"link.create": "Create Link",
"link.edit": "Edit Link",
"link.delete": "delete link",
"link.title": "Link title",
"link.url": "URL",
"link.description": "Description",
"link.icon": "Icon",
"link.removeIcon": "Remove icon",
"link.public": "Public",
"link.featured": "Featured",
"link.titlePlaceholder": "Link title",
"link.descriptionPlaceholder": "Brief description of the link",
"link.urlPlaceholder": "https://example.com",
"link.currentIcon": "Current icon",
"link.confirmRemoveIcon": "Remove current link icon?",
"link.iconSizeRecommendation": "Recommended size: 24x24 pixels",
"profile.edit": "Edit Profile",
"profile.username": "Username",
"profile.email": "Email",
"profile.firstName": "First Name",
"profile.lastName": "Last Name",
"profile.fullName": "Full Name",
"profile.bio": "Biography",
"profile.avatar": "Avatar",
"profile.cover": "Cover",
"profile.currentAvatar": "Current avatar",
"profile.removeAvatar": "Remove avatar",
"profile.removeCover": "Remove cover",
"customization.title": "Customization",
"customization.templates": "Templates",
"customization.layout": "Layout",
"customization.colors": "Colors",
"customization.groups": "Groups",
"customization.advanced": "Advanced",
"customization.data": "Data",
"customization.layout.style": "Display style for groups and links",
"customization.layout.list": "List",
"customization.layout.grid": "Grid",
"customization.layout.cards": "Cards",
"customization.layout.compact": "Compact",
"customization.layout.masonry": "Masonry",
"customization.layout.timeline": "Timeline",
"customization.layout.magazine": "Magazine",
"customization.layout.sidebar": "Sidebar",
"customization.layout.testList": "Test List",
"customization.layout.listDescription": "Classic vertical list",
"customization.layout.gridDescription": "Uniform grid of cards",
"customization.layout.cardsDescription": "Large informative cards",
"customization.layout.compactDescription": "Compact display without spacing",
"customization.layout.sidebarDescription": "Navigation in sidebar",
"customization.layout.masonryDescription": "Dynamic grid with varying heights",
"customization.layout.timelineDescription": "Chronological display",
"customization.layout.magazineDescription": "Magazine style with large images",
"customization.layout.testListDescription": "Full non-collapsible list of all groups and links",
"customization.layout.tip": "Tip:",
"customization.layout.tipText": "Try different layouts to find the most suitable for your content.",
"customization.colors.currentBackgroundAlt": "Current background",
"customization.colors.groupDescription": "Group descriptions color",
"customization.colors.showGroupsTitle": "Show \"Link Groups\" title",
"customization.colors.groupOverlay": "Group color overlay",
"customization.colors.overlayColor": "Overlay color",
"customization.colors.chooseOverlayColor": "Choose overlay color",
"customization.colors.overlayOpacity": "Overlay opacity setting",
"customization.colors.preview": "Preview",
"customization.colors.linkOverlay": "Link button color overlay",
"customization.advanced.individualGroupSettings": "Individual group settings",
"customization.advanced.systemSansSerif": "System Sans Serif",
"customization.advanced.systemSerif": "System Serif",
"customization.advanced.sameAsMain": "Same as main", "customization.colors.theme": "Theme color",
"customization.colors.background": "Background color",
"customization.colors.backgroundImage": "Background image",
"customization.colors.removeBackground": "Remove background",
"customization.colors.backgroundImageHelp": "Select background image (JPG, PNG, GIF). If not selected - current image will remain unchanged.",
"customization.colors.currentImage": "Current image:",
"customization.colors.newImage": "New image (will be applied after saving):",
"customization.colors.header": "Header text color",
"customization.colors.group": "Group text color",
"customization.colors.link": "Link text color",
"customization.groups.showIcons": "Show group icons",
"customization.groups.showLinks": "Show link icons",
"customization.groups.defaultExpanded": "Groups expanded by default",
"customization.groups.showTitle": "Show group titles",
"customization.groups.displaySettings": "Group display settings",
"customization.advanced.fonts": "Font settings",
"customization.advanced.fontSettings": "Font settings",
"customization.advanced.additionalSettings": "Additional settings",
"customization.advanced.resetConfirm": "Are you sure you want to reset all interface settings to default values? This action cannot be undone.",
"customization.advanced.mainFont": "Main font",
"customization.advanced.headingFont": "Heading font",
"customization.advanced.bodyFont": "Body font",
"customization.advanced.customCSS": "Custom CSS",
"customization.data.title": "Profile data export and import",
"customization.data.description": "Create backups of your profile data or restore them from archive",
"customization.data.export.title": "Export data",
"customization.data.export.description": "Create archive with profile data for backup or transfer",
"customization.data.export.button": "Create export",
"customization.data.import.title": "Import data",
"customization.data.import.description": "Upload and restore data from export archive",
"customization.data.import.file": "Select archive file (.zip)",
"customization.data.import.button": "Open import wizard",
"customization.data.history.title": "Operation history",
"customization.data.history.description": "Export and import history will be displayed here",
"customization.resetSettings": "Reset settings",
"customization.resetConfirm": "Are you sure you want to reset all interface settings to default values? This action cannot be undone.",
"export.title": "Export profile data",
"export.description": "Select data to include in export archive",
"export.general": "General data",
"export.profile": "Profile data (name, bio, avatar)",
"export.styles": "Design settings and styles",
"export.media": "Media files (images, icons)",
"export.groupsLinks": "Groups and links",
"export.selectedCount": "{{groups}} groups, {{links}} links",
"export.createButton": "Create and download",
"export.creating": "Creating export...",
"import.title": "Import profile data",
"import.selectFile": "Select archive for import",
"import.analyzing": "Analyzing archive...",
"import.content": "Archive content",
"import.exportInfo": "Export information",
"import.source": "Source",
"import.exportDate": "Export date",
"import.dataStats": "Data statistics",
"import.groups": "Groups",
"import.links": "Links",
"import.designSettings": "Design settings",
"import.mediaFiles": "Media files",
"import.yes": "Yes",
"import.no": "No",
"import.groupsPreview": "Groups (first 5)",
"import.linksPreview": "Links (first 10)",
"import.settings": "Import settings",
"import.importGroups": "Import groups ({{count}})",
"import.importLinks": "Import links ({{count}})",
"import.importStyles": "Import design settings",
"import.importMedia": "Import media files ({{count}})",
"import.overwriteExisting": "Overwrite existing data",
"import.overwriteHelp": "If disabled, existing groups and links with the same names will be skipped",
"import.unavailable": "(unavailable)",
"import.button": "Import",
"import.importing": "Importing...",
"theme.toggle": "Toggle theme",
"theme.light": "Light theme",
"theme.dark": "Dark theme",
"language.select": "Select language",
"language.en": "English",
"language.ru": "Русский",
"language.ko": "한국어",
"language.zh": "中文",
"language.ja": "日本語",
"footer.about": "About",
"footer.contact": "Contact",
"footer.terms": "Terms of Service",
"footer.privacy": "Privacy Policy",
"footer.copyright": "© CatLink 2025. All rights reserved."
}

View File

@@ -0,0 +1,275 @@
{
"common.cancel": "キャンセル",
"common.save": "保存",
"common.saving": "保存中...",
"common.loading": "読み込み中...",
"common.error": "エラー",
"common.success": "成功",
"common.close": "閉じる",
"common.edit": "編集",
"common.delete": "削除",
"common.add": "追加",
"common.create": "作成",
"common.update": "更新",
"common.search": "検索",
"common.settings": "設定",
"common.profile": "プロフィール",
"common.logout": "ログアウト",
"common.login": "ログイン",
"common.register": "登録",
"common.back": "戻る",
"common.next": "次へ",
"common.previous": "前へ",
"common.submit": "送信",
"common.reset": "リセット",
"common.clear": "クリア",
"common.confirm": "確認",
"common.yes": "はい",
"common.no": "いいえ",
"common.menu": "メニュー",
"home.title": "あなたのリンク。あなたのスタイル。あなたのCatLink。",
"home.subtitle": "大切なリンクをすべて一箇所に集めた美しい個人ページを作成しましょう。プロフェッショナルでスタイリッシュにシェア!",
"home.emailPlaceholder": "メールアドレスを入力",
"home.startFree": "無料で始める",
"home.haveAccount": "既にアカウントをお持ちですか?",
"home.signIn": "サインイン",
"home.features.title": "なぜCatLinkを選ぶのか",
"home.features.subtitle": "デジタルプレゼンスを作るためのシンプルで強力なツール",
"home.features.links.title": "一つのURL — すべてのリンク",
"home.features.links.description": "重要なリンクをすべて一箇所に集めます。ソーシャルメディア、ポートフォリオ、連絡先 — すべてが一つのアドレスに。",
"home.features.customization.title": "カスタマイゼーション",
"home.features.customization.description": "色、フォント、レイアウトをカスタマイズ。あなたの個性やブランドを反映するユニークなスタイルを作成しましょう。",
"home.features.analytics.title": "アナリティクス",
"home.features.analytics.description": "クリック数、リンクの人気度、訪問者の活動を追跡。あなたのオーディエンスをより良く理解しましょう。",
"home.useCases.title": "すべての人のために",
"home.useCases.bloggers": "ブロガー",
"home.useCases.bloggersDescription": "すべてのソーシャルメディアを集約",
"home.useCases.business": "ビジネス",
"home.useCases.businessDescription": "サービスと連絡先を表示",
"home.useCases.musicians": "ミュージシャン",
"home.useCases.musiciansDescription": "あなたの作品をシェア",
"home.useCases.photographers": "フォトグラファー",
"home.useCases.photographersDescription": "ポートフォリオを表示",
"home.useCases.exampleTitle": "個人ページ",
"home.useCases.exampleSubtitle": "ページの例",
"home.useCases.personalSite": "個人ウェブサイト",
"home.cta.title": "始める準備はできていますか?",
"home.cta.subtitle": "完璧なリンクページを作成した数千人のユーザーに参加しましょう",
"home.cta.createFree": "無料アカウントを作成",
"home.cta.haveAccount": "アカウントを持っています",
"home.cta.features": "永続無料 • 制限なし • 素早いセットアップ",
"auth.welcome": "ようこそ!",
"auth.welcomeSubtitle": "CatLinkアカウントにサインイン",
"auth.createAccount": "アカウント作成",
"auth.createAccountSubtitle": "今日CatLinkに参加しましょう",
"auth.usernameLabel": "ユーザー名",
"auth.usernamePlaceholder": "ユーザー名を入力",
"auth.usernameRequired": "ユーザー名を入力してください",
"auth.usernameHelp": "ラテン文字、数字、_のみ",
"auth.passwordLabel": "パスワード",
"auth.passwordPlaceholder": "パスワードを入力",
"auth.passwordRequired": "パスワードを入力してください",
"auth.passwordConfirmLabel": "パスワード確認",
"auth.passwordConfirmRequired": "パスワードを確認してください",
"auth.passwordMismatch": "パスワードが一致しません",
"auth.emailLabel": "メール",
"auth.emailRequired": "メールを入力してください",
"auth.firstNameLabel": "名前",
"auth.lastNameLabel": "苗字",
"auth.loginButton": "サインイン",
"auth.registerButton": "アカウント作成",
"auth.loggingIn": "サインイン中...",
"auth.registering": "アカウント作成中...",
"auth.noAccount": "アカウントをお持ちでない方",
"auth.haveAccount": "既にアカウントをお持ちですか?",
"auth.loginError": "ログインエラー",
"auth.networkError": "ネットワークエラー",
"auth.registrationError": "登録エラー",
"auth.connectionError": "サーバー接続エラー",
"auth.termsAgreement": "アカウントを作成することで、以下に同意したことになります",
"auth.termsLink": "利用規約",
"auth.privacyLink": "プライバシーポリシー",
"auth.and": "および",
"auth.login.title": "ログイン",
"auth.login.email": "メール",
"auth.login.password": "パスワード",
"auth.login.remember": "ログイン状態を保持",
"auth.login.forgot": "パスワードを忘れましたか?",
"auth.login.noAccount": "アカウントをお持ちでない方",
"auth.login.signUp": "新規登録",
"auth.register.title": "新規登録",
"auth.register.username": "ユーザー名",
"auth.register.email": "メール",
"auth.register.password": "パスワード",
"auth.register.confirmPassword": "パスワード確認",
"auth.register.firstName": "名",
"auth.register.lastName": "姓",
"auth.register.haveAccount": "既にアカウントをお持ちの方",
"auth.register.signIn": "ログイン",
"dashboard.title": "ダッシュボード",
"dashboard.welcome": "{{name}}さん、ようこそ!",
"dashboard.groups": "グループ",
"dashboard.links": "リンク",
"dashboard.settings": "設定",
"dashboard.customize": "シェア",
"dashboard.addGroup": "グループ追加",
"dashboard.addLink": "リンク追加",
"dashboard.noGroups": "まだグループがありません",
"dashboard.noLinks": "まだリンクがありません",
"dashboard.createFirst": "最初の作成",
"dashboard.shareUrl.copied": "リンクがクリップボードにコピーされました",
"group.create": "グループ作成",
"group.edit": "グループ編集",
"group.delete": "グループ削除",
"group.name": "グループ名",
"group.description": "説明",
"group.icon": "アイコン",
"group.background": "背景画像",
"group.color": "ヘッダー色",
"group.public": "公開",
"group.favorite": "お気に入り",
"group.expanded": "デフォルトで展開",
"group.removeIcon": "アイコンを削除",
"group.removeBackground": "背景を削除",
"link.create": "リンク作成",
"link.edit": "リンク編集",
"link.delete": "リンク削除",
"link.title": "リンクタイトル",
"link.url": "URL",
"link.description": "説明",
"link.icon": "アイコン",
"link.removeIcon": "アイコンを削除",
"link.public": "公開",
"link.featured": "おすすめ",
"profile.edit": "プロフィール編集",
"profile.username": "ユーザー名",
"profile.email": "メール",
"profile.firstName": "名",
"profile.lastName": "姓",
"profile.fullName": "氏名",
"profile.bio": "自己紹介",
"profile.avatar": "アバター",
"profile.cover": "カバー画像",
"profile.removeAvatar": "アバターを削除",
"profile.removeCover": "カバーを削除",
"customization.title": "カスタマイゼーション",
"customization.templates": "テンプレート",
"customization.layout": "レイアウト",
"customization.colors": "色",
"customization.groups": "グループ",
"customization.advanced": "詳細設定",
"customization.data": "データ",
"customization.layout.style": "グループとリンクの表示スタイル",
"customization.layout.list": "リスト",
"customization.layout.grid": "グリッド",
"customization.layout.cards": "カード",
"customization.layout.compact": "コンパクト",
"customization.layout.masonry": "メイソンリー",
"customization.layout.timeline": "タイムライン",
"customization.layout.magazine": "マガジン",
"customization.colors.theme": "テーマカラー",
"customization.colors.background": "背景色",
"customization.colors.backgroundImage": "背景画像",
"customization.colors.removeBackground": "背景を削除",
"customization.colors.header": "ヘッダーテキスト色",
"customization.colors.group": "グループテキスト色",
"customization.colors.link": "リンクテキスト色",
"customization.groups.showIcons": "グループアイコンを表示",
"customization.groups.showLinks": "リンクアイコンを表示",
"customization.groups.defaultExpanded": "グループをデフォルトで展開",
"customization.groups.showTitle": "グループタイトルを表示",
"customization.advanced.fonts": "フォント設定",
"customization.advanced.mainFont": "メインフォント",
"customization.advanced.headingFont": "見出しフォント",
"customization.advanced.bodyFont": "本文フォント",
"customization.advanced.customCSS": "カスタムCSS",
"customization.data.title": "プロフィールデータのエクスポートとインポート",
"customization.data.description": "プロフィールデータのバックアップを作成するか、アーカイブから復元します",
"customization.data.export.title": "データエクスポート",
"customization.data.export.description": "バックアップや転送のためのプロフィールデータアーカイブを作成",
"customization.data.export.button": "エクスポート作成",
"customization.data.import.title": "データインポート",
"customization.data.import.description": "エクスポートアーカイブからデータをアップロードして復元",
"customization.data.import.file": "アーカイブファイルを選択 (.zip)",
"customization.data.import.button": "インポートウィザードを開く",
"customization.data.history.title": "操作履歴",
"customization.data.history.description": "エクスポートとインポートの履歴がここに表示されます",
"customization.resetSettings": "設定をリセット",
"customization.resetConfirm": "すべてのインターフェース設定をデフォルト値にリセットしますか?この操作は元に戻せません。",
"export.title": "プロフィールデータのエクスポート",
"export.description": "エクスポートアーカイブに含めるデータを選択",
"export.general": "一般データ",
"export.profile": "プロフィールデータ(名前、自己紹介、アバター)",
"export.styles": "デザイン設定とスタイル",
"export.media": "メディアファイル(画像、アイコン)",
"export.groupsLinks": "グループとリンク",
"export.selectedCount": "{{groups}}個のグループ、{{links}}個のリンク",
"export.createButton": "作成してダウンロード",
"export.creating": "エクスポート作成中...",
"import.title": "プロフィールデータのインポート",
"import.selectFile": "インポートするアーカイブを選択",
"import.analyzing": "アーカイブ分析中...",
"import.content": "アーカイブ内容",
"import.exportInfo": "エクスポート情報",
"import.source": "ソース",
"import.exportDate": "エクスポート日",
"import.dataStats": "データ統計",
"import.groups": "グループ",
"import.links": "リンク",
"import.designSettings": "デザイン設定",
"import.mediaFiles": "メディアファイル",
"import.yes": "あり",
"import.no": "なし",
"import.groupsPreview": "グループ最初の5個",
"import.linksPreview": "リンク最初の10個",
"import.settings": "インポート設定",
"import.importGroups": "グループをインポート({{count}}個)",
"import.importLinks": "リンクをインポート({{count}}個)",
"import.importStyles": "デザイン設定をインポート",
"import.importMedia": "メディアファイルをインポート({{count}}個)",
"import.overwriteExisting": "既存データを上書き",
"import.overwriteHelp": "無効にすると、同じ名前の既存グループとリンクはスキップされます",
"import.unavailable": "(利用不可)",
"import.button": "インポート",
"import.importing": "インポート中...",
"theme.toggle": "テーマ切り替え",
"theme.light": "ライトテーマ",
"theme.dark": "ダークテーマ",
"language.select": "言語を選択",
"language.en": "English",
"language.ru": "Русский",
"language.ko": "한국어",
"language.zh": "中文",
"language.ja": "日本語",
"footer.about": "会社概要",
"footer.contact": "お問い合わせ",
"footer.terms": "利用規約",
"footer.privacy": "プライバシーポリシー",
"footer.copyright": "© CatLink 2025. 全ての権利を保有。"
}

View File

@@ -0,0 +1,275 @@
{
"common.cancel": "취소",
"common.save": "저장",
"common.saving": "저장 중...",
"common.loading": "로딩 중...",
"common.error": "오류",
"common.success": "성공",
"common.close": "닫기",
"common.edit": "편집",
"common.delete": "삭제",
"common.add": "추가",
"common.create": "생성",
"common.update": "업데이트",
"common.search": "검색",
"common.settings": "설정",
"common.profile": "프로필",
"common.logout": "로그아웃",
"common.login": "로그인",
"common.register": "회원가입",
"common.back": "뒤로",
"common.next": "다음",
"common.previous": "이전",
"common.submit": "제출",
"common.reset": "재설정",
"common.clear": "지우기",
"common.confirm": "확인",
"common.yes": "예",
"common.no": "아니오",
"common.menu": "메뉴",
"home.title": "당신의 링크. 당신의 스타일. 당신의 CatLink.",
"home.subtitle": "모든 중요한 링크를 한 곳에서 관리할 수 있는 아름다운 개인 페이지를 만드세요. 전문적이고 스타일리시하게 공유하세요!",
"home.emailPlaceholder": "이메일을 입력하세요",
"home.startFree": "무료 시작",
"home.haveAccount": "이미 계정이 있으신가요?",
"home.signIn": "로그인",
"home.features.title": "왜 CatLink를 선택해야 할까요?",
"home.features.subtitle": "디지털 존재감을 만들기 위한 간단하고 강력한 도구",
"home.features.links.title": "하나의 URL — 모든 링크",
"home.features.links.description": "모든 중요한 링크를 한 곳에 모아보세요. 소셜 미디어, 포트폴리오, 연락처 — 모든 것이 하나의 주소에.",
"home.features.customization.title": "커스터마이징",
"home.features.customization.description": "색상, 글꼴, 레이아웃을 맞춤 설정하세요. 당신의 개성이나 브랜드를 반영하는 독특한 스타일을 만드세요.",
"home.features.analytics.title": "분석",
"home.features.analytics.description": "클릭 수, 링크 인기도, 방문자 활동을 추적하세요. 당신의 청중을 더 잘 이해하세요.",
"home.useCases.title": "모두를 위한",
"home.useCases.bloggers": "블로거",
"home.useCases.bloggersDescription": "모든 소셜 미디어 모으기",
"home.useCases.business": "비즈니스",
"home.useCases.businessDescription": "서비스와 연락처 보여주기",
"home.useCases.musicians": "음악가",
"home.useCases.musiciansDescription": "창작물 공유하기",
"home.useCases.photographers": "사진가",
"home.useCases.photographersDescription": "포트폴리오 보여주기",
"home.useCases.exampleTitle": "개인 페이지",
"home.useCases.exampleSubtitle": "페이지 예시",
"home.useCases.personalSite": "개인 웹사이트",
"home.cta.title": "시작할 준비가 되셨나요?",
"home.cta.subtitle": "완벽한 링크 페이지를 만든 수천 명의 사용자와 함께하세요",
"home.cta.createFree": "무료 계정 만들기",
"home.cta.haveAccount": "계정이 있습니다",
"home.cta.features": "영원히 무료 • 제한 없음 • 빠른 설정",
"auth.welcome": "환영합니다!",
"auth.welcomeSubtitle": "CatLink 계정에 로그인하세요",
"auth.createAccount": "계정 만들기",
"auth.createAccountSubtitle": "오늘 CatLink와 함께하세요",
"auth.usernameLabel": "사용자명",
"auth.usernamePlaceholder": "사용자명을 입력하세요",
"auth.usernameRequired": "사용자명을 입력해주세요",
"auth.usernameHelp": "라틴 문자, 숫자 및 _ 만",
"auth.passwordLabel": "비밀번호",
"auth.passwordPlaceholder": "비밀번호를 입력하세요",
"auth.passwordRequired": "비밀번호를 입력해주세요",
"auth.passwordConfirmLabel": "비밀번호 확인",
"auth.passwordConfirmRequired": "비밀번호를 확인해주세요",
"auth.passwordMismatch": "비밀번호가 일치하지 않습니다",
"auth.emailLabel": "이메일",
"auth.emailRequired": "이메일을 입력해주세요",
"auth.firstNameLabel": "이름",
"auth.lastNameLabel": "성",
"auth.loginButton": "로그인",
"auth.registerButton": "계정 만들기",
"auth.loggingIn": "로그인 중...",
"auth.registering": "계정 생성 중...",
"auth.noAccount": "계정이 없으신가요?",
"auth.haveAccount": "이미 계정이 있으신가요?",
"auth.loginError": "로그인 오류",
"auth.networkError": "네트워크 오류",
"auth.registrationError": "등록 오류",
"auth.connectionError": "서버 연결 오류",
"auth.termsAgreement": "계정을 만들면 다음에 동의하는 것입니다",
"auth.termsLink": "서비스 약관",
"auth.privacyLink": "개인정보 보호정책",
"auth.and": "및",
"auth.login.title": "로그인",
"auth.login.email": "이메일",
"auth.login.password": "비밀번호",
"auth.login.remember": "기억하기",
"auth.login.forgot": "비밀번호를 잊으셨나요?",
"auth.login.noAccount": "계정이 없으신가요?",
"auth.login.signUp": "회원가입",
"auth.register.title": "회원가입",
"auth.register.username": "사용자명",
"auth.register.email": "이메일",
"auth.register.password": "비밀번호",
"auth.register.confirmPassword": "비밀번호 확인",
"auth.register.firstName": "이름",
"auth.register.lastName": "성",
"auth.register.haveAccount": "이미 계정이 있으신가요?",
"auth.register.signIn": "로그인",
"dashboard.title": "대시보드",
"dashboard.welcome": "환영합니다, {{name}}님!",
"dashboard.groups": "그룹",
"dashboard.links": "링크",
"dashboard.settings": "설정",
"dashboard.customize": "공유",
"dashboard.addGroup": "그룹 추가",
"dashboard.addLink": "링크 추가",
"dashboard.noGroups": "그룹이 아직 없습니다",
"dashboard.noLinks": "링크가 아직 없습니다",
"dashboard.createFirst": "첫 번째 만들기",
"dashboard.shareUrl.copied": "링크가 클립보드에 복사되었습니다",
"group.create": "그룹 생성",
"group.edit": "그룹 편집",
"group.delete": "그룹 삭제",
"group.name": "그룹 이름",
"group.description": "설명",
"group.icon": "아이콘",
"group.background": "배경 이미지",
"group.color": "헤더 색상",
"group.public": "공개",
"group.favorite": "즐겨찾기",
"group.expanded": "기본으로 펼침",
"group.removeIcon": "아이콘 제거",
"group.removeBackground": "배경 제거",
"link.create": "링크 생성",
"link.edit": "링크 편집",
"link.delete": "링크 삭제",
"link.title": "링크 제목",
"link.url": "URL",
"link.description": "설명",
"link.icon": "아이콘",
"link.removeIcon": "아이콘 제거",
"link.public": "공개",
"link.featured": "추천",
"profile.edit": "프로필 편집",
"profile.username": "사용자명",
"profile.email": "이메일",
"profile.firstName": "이름",
"profile.lastName": "성",
"profile.fullName": "전체 이름",
"profile.bio": "자기소개",
"profile.avatar": "아바타",
"profile.cover": "커버 이미지",
"profile.removeAvatar": "아바타 제거",
"profile.removeCover": "커버 제거",
"customization.title": "커스터마이제이션",
"customization.templates": "템플릿",
"customization.layout": "레이아웃",
"customization.colors": "색상",
"customization.groups": "그룹",
"customization.advanced": "고급",
"customization.data": "데이터",
"customization.layout.style": "그룹 및 링크 표시 스타일",
"customization.layout.list": "목록",
"customization.layout.grid": "그리드",
"customization.layout.cards": "카드",
"customization.layout.compact": "컴팩트",
"customization.layout.masonry": "메이슨리",
"customization.layout.timeline": "타임라인",
"customization.layout.magazine": "매거진",
"customization.colors.theme": "테마 색상",
"customization.colors.background": "배경 색상",
"customization.colors.backgroundImage": "배경 이미지",
"customization.colors.removeBackground": "배경 제거",
"customization.colors.header": "헤더 텍스트 색상",
"customization.colors.group": "그룹 텍스트 색상",
"customization.colors.link": "링크 텍스트 색상",
"customization.groups.showIcons": "그룹 아이콘 표시",
"customization.groups.showLinks": "링크 아이콘 표시",
"customization.groups.defaultExpanded": "그룹 기본 펼침",
"customization.groups.showTitle": "그룹 제목 표시",
"customization.advanced.fonts": "폰트 설정",
"customization.advanced.mainFont": "기본 폰트",
"customization.advanced.headingFont": "제목 폰트",
"customization.advanced.bodyFont": "본문 폰트",
"customization.advanced.customCSS": "사용자 정의 CSS",
"customization.data.title": "프로필 데이터 내보내기 및 가져오기",
"customization.data.description": "프로필 데이터의 백업을 생성하거나 아카이브에서 복원하세요",
"customization.data.export.title": "데이터 내보내기",
"customization.data.export.description": "백업이나 이전을 위한 프로필 데이터 아카이브 생성",
"customization.data.export.button": "내보내기 생성",
"customization.data.import.title": "데이터 가져오기",
"customization.data.import.description": "내보내기 아카이브에서 데이터 업로드 및 복원",
"customization.data.import.file": "아카이브 파일 선택 (.zip)",
"customization.data.import.button": "가져오기 마법사 열기",
"customization.data.history.title": "작업 기록",
"customization.data.history.description": "내보내기 및 가져오기 기록이 여기에 표시됩니다",
"customization.resetSettings": "설정 재설정",
"customization.resetConfirm": "모든 인터페이스 설정을 기본값으로 재설정하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
"export.title": "프로필 데이터 내보내기",
"export.description": "내보내기 아카이브에 포함할 데이터 선택",
"export.general": "일반 데이터",
"export.profile": "프로필 데이터 (이름, 소개, 아바타)",
"export.styles": "디자인 설정 및 스타일",
"export.media": "미디어 파일 (이미지, 아이콘)",
"export.groupsLinks": "그룹 및 링크",
"export.selectedCount": "{{groups}}개 그룹, {{links}}개 링크",
"export.createButton": "생성 및 다운로드",
"export.creating": "내보내기 생성 중...",
"import.title": "프로필 데이터 가져오기",
"import.selectFile": "가져올 아카이브 선택",
"import.analyzing": "아카이브 분석 중...",
"import.content": "아카이브 내용",
"import.exportInfo": "내보내기 정보",
"import.source": "소스",
"import.exportDate": "내보내기 날짜",
"import.dataStats": "데이터 통계",
"import.groups": "그룹",
"import.links": "링크",
"import.designSettings": "디자인 설정",
"import.mediaFiles": "미디어 파일",
"import.yes": "있음",
"import.no": "없음",
"import.groupsPreview": "그룹 (첫 5개)",
"import.linksPreview": "링크 (첫 10개)",
"import.settings": "가져오기 설정",
"import.importGroups": "그룹 가져오기 ({{count}}개)",
"import.importLinks": "링크 가져오기 ({{count}}개)",
"import.importStyles": "디자인 설정 가져오기",
"import.importMedia": "미디어 파일 가져오기 ({{count}}개)",
"import.overwriteExisting": "기존 데이터 덮어쓰기",
"import.overwriteHelp": "비활성화하면 같은 이름의 기존 그룹 및 링크를 건너뜁니다",
"import.unavailable": "(사용 불가)",
"import.button": "가져오기",
"import.importing": "가져오기 중...",
"theme.toggle": "테마 전환",
"theme.light": "밝은 테마",
"theme.dark": "어두운 테마",
"language.select": "언어 선택",
"language.en": "English",
"language.ru": "Русский",
"language.ko": "한국어",
"language.zh": "中文",
"language.ja": "日本語",
"footer.about": "회사 소개",
"footer.contact": "연락처",
"footer.terms": "이용약관",
"footer.privacy": "개인정보 보호정책",
"footer.copyright": "© CatLink 2025. 모든 권리 보유."
}

View File

@@ -0,0 +1,344 @@
{
"common.cancel": "Отмена",
"common.save": "Сохранить",
"common.saving": "Сохранение...",
"common.loading": "Загрузка...",
"common.error": "Ошибка",
"common.success": "Успешно",
"common.close": "Закрыть",
"common.edit": "Редактировать",
"common.delete": "Удалить",
"common.add": "Добавить",
"common.create": "Создать",
"common.update": "Обновить",
"common.search": "Поиск",
"common.settings": "Настройки",
"common.profile": "Профиль",
"common.logout": "Выйти",
"common.login": "Войти",
"common.register": "Регистрация",
"common.back": "Назад",
"common.next": "Далее",
"common.previous": "Предыдущий",
"common.submit": "Отправить",
"common.reset": "Сбросить",
"common.clear": "Очистить",
"common.confirm": "Подтвердить",
"common.yes": "Да",
"common.no": "Нет",
"common.menu": "Меню",
"common.optional": "опционально",
"common.closeModal": "Закрыть модальное окно",
"share.title": "Поделиться страницей",
"share.description": "Ваша публичная страница со ссылками доступна по адресу:",
"share.loading": "Загрузка...",
"share.urlAriaLabel": "URL публичной страницы",
"share.urlTitle": "URL публичной страницы",
"share.copy": "Копировать",
"share.note": "На этой странице будут видны все ваши группы и ссылки. Она обновляется автоматически при изменении данных.",
"share.openPage": "Открыть страницу",
"home.title": "Ваши ссылки. Ваш стиль. Ваш CatLink.",
"home.subtitle": "Создайте красивую персональную страницу со всеми важными ссылками в одном месте. Делитесь профессионально и стильно!",
"home.emailPlaceholder": "Введите ваш email",
"home.startFree": "Начать бесплатно",
"home.haveAccount": "Уже есть аккаунт?",
"home.signIn": "Войти",
"home.features.title": "Почему выбирают CatLink?",
"home.features.subtitle": "Простой и мощный инструмент для создания вашего цифрового присутствия",
"home.features.links.title": "Один URL — все ссылки",
"home.features.links.description": "Соберите все важные ссылки в одном месте. Социальные сети, портфолио, контакты — всё под одним адресом.",
"home.features.customization.title": "Персонализация",
"home.features.customization.description": "Настройте цвета, шрифты, макеты. Создайте уникальный стиль, который отражает вашу личность или бренд.",
"home.features.analytics.title": "Аналитика",
"home.features.analytics.description": "Отслеживайте клики, популярность ссылок и активность посетителей. Понимайте свою аудиторию лучше.",
"home.useCases.title": "Для всех и каждого",
"home.useCases.bloggers": "Блогеры",
"home.useCases.bloggersDescription": "Соберите все социальные сети",
"home.useCases.business": "Бизнес",
"home.useCases.businessDescription": "Покажите услуги и контакты",
"home.useCases.musicians": "Музыканты",
"home.useCases.musiciansDescription": "Поделитесь творчеством",
"home.useCases.photographers": "Фотографы",
"home.useCases.photographersDescription": "Покажите портфолио",
"home.useCases.exampleTitle": "Ваша персональная страница",
"home.useCases.exampleSubtitle": "Пример вашей страницы",
"home.useCases.personalSite": "Личный сайт",
"home.cta.title": "Готовы начать?",
"home.cta.subtitle": "Присоединяйтесь к тысячам пользователей, которые уже создали свою идеальную страницу ссылок",
"home.cta.createFree": "Создать аккаунт бесплатно",
"home.cta.haveAccount": "У меня есть аккаунт",
"home.cta.features": "Бесплатно навсегда • Без ограничений • Быстрая настройка",
"auth.welcome": "Добро пожаловать!",
"auth.welcomeSubtitle": "Войдите в свой аккаунт CatLink",
"auth.createAccount": "Создать аккаунт",
"auth.createAccountSubtitle": "Присоединяйтесь к CatLink сегодня",
"auth.usernameLabel": "Имя пользователя",
"auth.usernamePlaceholder": "Введите имя пользователя",
"auth.usernameRequired": "Введите имя пользователя",
"auth.usernameHelp": "Только латинские буквы, цифры и _",
"auth.passwordLabel": "Пароль",
"auth.passwordPlaceholder": "Введите пароль",
"auth.passwordRequired": "Введите пароль",
"auth.passwordConfirmLabel": "Подтвердите пароль",
"auth.passwordConfirmRequired": "Подтвердите пароль",
"auth.passwordMismatch": "Пароли не совпадают",
"auth.emailLabel": "Email",
"auth.emailRequired": "Введите email",
"auth.firstNameLabel": "Имя",
"auth.lastNameLabel": "Фамилия",
"auth.loginButton": "Войти",
"auth.registerButton": "Создать аккаунт",
"auth.loggingIn": "Входим...",
"auth.registering": "Создание аккаунта...",
"auth.noAccount": "Нет аккаунта?",
"auth.haveAccount": "Уже есть аккаунт?",
"auth.loginError": "Ошибка входа",
"auth.networkError": "Сетевая ошибка",
"auth.registrationError": "Ошибка регистрации",
"auth.connectionError": "Ошибка соединения с сервером",
"auth.termsAgreement": "Создавая аккаунт, вы соглашаетесь с",
"auth.termsLink": "Условиями использования",
"auth.privacyLink": "Политикой конфиденциальности",
"auth.and": "и",
"auth.login.title": "Вход",
"auth.login.email": "Email",
"auth.login.password": "Пароль",
"auth.login.remember": "Запомнить меня",
"auth.login.forgot": "Забыли пароль?",
"auth.login.noAccount": "Нет аккаунта?",
"auth.login.signUp": "Зарегистрироваться",
"auth.register.title": "Регистрация",
"auth.register.username": "Имя пользователя",
"auth.register.email": "Email",
"auth.register.password": "Пароль",
"auth.register.confirmPassword": "Подтвердите пароль",
"auth.register.firstName": "Имя",
"auth.register.lastName": "Фамилия",
"auth.register.haveAccount": "Уже есть аккаунт?",
"auth.register.signIn": "Войти",
"dashboard.title": "Панель управления",
"dashboard.welcome": "Добро пожаловать, {{name}}!",
"dashboard.groups": "Группы",
"dashboard.links": "Ссылки",
"dashboard.settings": "Настройки",
"dashboard.share": "Поделиться",
"dashboard.customize": "Персонализация",
"dashboard.panelOpen": "Открыта",
"dashboard.panelClosed": "Закрыта",
"dashboard.error": "Ошибка: ",
"dashboard.linkGroups": "Группы ссылок",
"dashboard.andMore": "и еще {{count}}...",
"dashboard.linksCount": "{{count}} ссылок",
"dashboard.linksInGroup": "{{count}} ссылок в этой группе",
"dashboard.addGroup": "Добавить группу",
"dashboard.addLink": "Добавить ссылку",
"dashboard.noGroups": "Групп пока нет",
"dashboard.noLinks": "Ссылок пока нет",
"dashboard.createFirst": "Создайте первую",
"dashboard.shareUrl.copied": "Ссылка скопирована в буфер обмена",
"group.create": "Создать группу",
"group.edit": "Редактировать группу",
"group.delete": "удалить группу",
"group.name": "Название группы",
"group.description": "Описание",
"group.icon": "Иконка",
"group.background": "Фоновое изображение",
"group.color": "Цвет заголовка",
"group.public": "Публичная",
"group.favorite": "Избранная",
"group.expanded": "Развернута по умолчанию",
"group.removeIcon": "Убрать иконку",
"group.removeBackground": "Убрать фон",
"group.descriptionPlaceholder": "Краткое описание группы ссылок",
"group.currentIcon": "Текущая иконка",
"group.confirmRemoveIcon": "Удалить текущую иконку группы?",
"group.iconSizeRecommendation": "Рекомендуемый размер: 32x32 пикселя",
"group.currentBackground": "Текущий фон",
"group.confirmRemoveBackground": "Удалить текущий фон группы?",
"group.imageSizeRecommendation": "Рекомендуемый размер изображения:",
"group.tip": "Совет:",
"group.borderTip": "Для групп с рамкой используйте изображения с отступами по краям (10-20px)",
"group.backgroundDescription": "Изображение будет использовано как фон для содержимого группы",
"link.create": "Создать ссылку",
"link.edit": "Редактировать ссылку",
"link.delete": "удалить ссылку",
"link.title": "Название ссылки",
"link.url": "URL",
"link.description": "Описание",
"link.icon": "Иконка",
"link.removeIcon": "Убрать иконку",
"link.public": "Публичная",
"link.featured": "Рекомендуемая",
"link.titlePlaceholder": "Название ссылки",
"link.descriptionPlaceholder": "Краткое описание ссылки",
"link.urlPlaceholder": "https://example.com",
"link.currentIcon": "Текущая иконка",
"link.confirmRemoveIcon": "Удалить текущую иконку ссылки?",
"link.iconSizeRecommendation": "Рекомендуемый размер: 24x24 пикселя",
"profile.edit": "Редактировать профиль",
"profile.username": "Имя пользователя",
"profile.email": "Email",
"profile.firstName": "Имя",
"profile.lastName": "Фамилия",
"profile.fullName": "Полное имя",
"profile.bio": "Биография",
"profile.avatar": "Аватар",
"profile.cover": "Обложка",
"profile.currentAvatar": "Текущий аватар",
"profile.removeAvatar": "Убрать аватар",
"profile.removeCover": "Убрать обложку",
"customization.title": "Настройки",
"customization.templates": "Шаблоны",
"customization.layout": "Макет",
"customization.colors": "Цвета",
"customization.groups": "Группы",
"customization.advanced": "Дополнительно",
"customization.data": "Данные",
"customization.layout.style": "Стиль отображения групп и ссылок",
"customization.layout.list": "Список",
"customization.layout.grid": "Сетка",
"customization.layout.cards": "Карточки",
"customization.layout.compact": "Компактный",
"customization.layout.masonry": "Кирпичная кладка",
"customization.layout.timeline": "Временная шкала",
"customization.layout.magazine": "Журнал",
"customization.layout.sidebar": "Боковая панель",
"customization.layout.testList": "Тестовый список",
"customization.layout.listDescription": "Классический вертикальный список",
"customization.layout.gridDescription": "Равномерная сетка карточек",
"customization.layout.cardsDescription": "Большие информативные карточки",
"customization.layout.compactDescription": "Компактное отображение без отступов",
"customization.layout.sidebarDescription": "Навигация в боковой панели",
"customization.layout.masonryDescription": "Динамическая сетка разной высоты",
"customization.layout.timelineDescription": "Хронологическое отображение",
"customization.layout.magazineDescription": "Стиль журнала с крупными изображениями",
"customization.layout.testListDescription": "Полный несворачиваемый список всех групп и ссылок",
"customization.layout.tip": "Совет:",
"customization.layout.tipText": "Попробуйте разные макеты, чтобы найти наиболее подходящий для вашего контента.",
"customization.colors.currentBackgroundAlt": "Текущий фон",
"customization.colors.groupDescription": "Цвет описаний групп",
"customization.colors.showGroupsTitle": "Показывать заголовок \"Группы ссылок\"",
"customization.colors.groupOverlay": "Цветовое перекрытие групп",
"customization.colors.overlayColor": "Цвет перекрытия",
"customization.colors.chooseOverlayColor": "Выберите цвет перекрытия",
"customization.colors.overlayOpacity": "Настройка прозрачности перекрытия",
"customization.colors.preview": "Предварительный просмотр",
"customization.colors.linkOverlay": "Цветовое перекрытие кнопок ссылок",
"customization.advanced.individualGroupSettings": "Настройки отдельных групп",
"customization.advanced.systemSansSerif": "Системный Sans Serif",
"customization.advanced.systemSerif": "Системный Serif",
"customization.advanced.sameAsMain": "Как основной", "customization.colors.theme": "Цвет темы",
"customization.colors.background": "Цвет фона",
"customization.colors.backgroundImage": "Фоновое изображение",
"customization.colors.removeBackground": "Убрать фон",
"customization.colors.backgroundImageHelp": "Выберите изображение для фона (JPG, PNG, GIF). Если не выбрано - текущее изображение останется без изменений.",
"customization.colors.currentImage": "Текущее изображение:",
"customization.colors.newImage": "Новое изображение (будет применено после сохранения):",
"customization.colors.header": "Цвет текста заголовков",
"customization.colors.group": "Цвет текста групп",
"customization.colors.link": "Цвет текста ссылок",
"customization.groups.showIcons": "Показывать иконки групп",
"customization.groups.showLinks": "Показывать иконки ссылок",
"customization.groups.defaultExpanded": "Группы развернуты по умолчанию",
"customization.groups.showTitle": "Показывать заголовки групп",
"customization.groups.displaySettings": "Настройки отображения групп",
"customization.advanced.fonts": "Настройки шрифтов",
"customization.advanced.fontSettings": "Настройки шрифтов",
"customization.advanced.additionalSettings": "Дополнительные настройки",
"customization.advanced.resetConfirm": "Вы уверены, что хотите сбросить все настройки интерфейса к значениям по умолчанию? Это действие нельзя отменить.",
"customization.advanced.mainFont": "Основной шрифт",
"customization.advanced.headingFont": "Шрифт заголовков",
"customization.advanced.bodyFont": "Шрифт текста",
"customization.advanced.customCSS": "Пользовательский CSS",
"customization.data.title": "Экспорт и импорт данных профиля",
"customization.data.description": "Создавайте резервные копии данных профиля или восстанавливайте их из архива",
"customization.data.export.title": "Экспорт данных",
"customization.data.export.description": "Создать архив с данными профиля для резервного копирования или переноса",
"customization.data.export.button": "Создать экспорт",
"customization.data.import.title": "Импорт данных",
"customization.data.import.description": "Загрузить и восстановить данные из архива экспорта",
"customization.data.import.file": "Выберите файл архива (.zip)",
"customization.data.import.button": "Открыть мастер импорта",
"customization.data.history.title": "История операций",
"customization.data.history.description": "Здесь будет отображаться история экспортов и импортов",
"customization.resetSettings": "Сбросить настройки",
"customization.resetConfirm": "Вы уверены, что хотите сбросить все настройки интерфейса к значениям по умолчанию? Это действие нельзя отменить.",
"export.title": "Экспорт данных профиля",
"export.description": "Выберите данные для включения в архив экспорта",
"export.general": "Общие данные",
"export.profile": "Данные профиля (имя, био, аватар)",
"export.styles": "Настройки дизайна и стили",
"export.media": "Медиафайлы (изображения, иконки)",
"export.groupsLinks": "Группы и ссылки",
"export.selectedCount": "{{groups}} групп, {{links}} ссылок",
"export.createButton": "Создать и скачать",
"export.creating": "Создание экспорта...",
"import.title": "Импорт данных профиля",
"import.selectFile": "Выберите архив для импорта",
"import.analyzing": "Анализ архива...",
"import.content": "Содержимое архива",
"import.exportInfo": "Информация об экспорте",
"import.source": "Источник",
"import.exportDate": "Дата экспорта",
"import.dataStats": "Статистика данных",
"import.groups": "Групп",
"import.links": "Ссылок",
"import.designSettings": "Настройки дизайна",
"import.mediaFiles": "Медиафайлов",
"import.yes": "Есть",
"import.no": "Нет",
"import.groupsPreview": "Группы (первые 5)",
"import.linksPreview": "Ссылки (первые 10)",
"import.settings": "Настройки импорта",
"import.importGroups": "Импортировать группы ({{count}})",
"import.importLinks": "Импортировать ссылки ({{count}})",
"import.importStyles": "Импортировать настройки дизайна",
"import.importMedia": "Импортировать медиафайлы ({{count}})",
"import.overwriteExisting": "Перезаписать существующие данные",
"import.overwriteHelp": "Если отключено, существующие группы и ссылки с такими же названиями будут пропущены",
"import.unavailable": "(недоступно)",
"import.button": "Импортировать",
"import.importing": "Импорт...",
"theme.toggle": "Переключить тему",
"theme.light": "Светлая тема",
"theme.dark": "Темная тема",
"language.select": "Выбрать язык",
"language.en": "English",
"language.ru": "Русский",
"language.ko": "한국어",
"language.zh": "中文",
"language.ja": "日本語",
"footer.about": "О нас",
"footer.contact": "Контакты",
"footer.terms": "Условия использования",
"footer.privacy": "Политика конфиденциальности",
"footer.copyright": "© CatLink 2025. Все права защищены."
}

View File

@@ -0,0 +1,275 @@
{
"common.cancel": "取消",
"common.save": "保存",
"common.saving": "保存中...",
"common.loading": "加载中...",
"common.error": "错误",
"common.success": "成功",
"common.close": "关闭",
"common.edit": "编辑",
"common.delete": "删除",
"common.add": "添加",
"common.create": "创建",
"common.update": "更新",
"common.search": "搜索",
"common.settings": "设置",
"common.profile": "个人资料",
"common.logout": "登出",
"common.login": "登录",
"common.register": "注册",
"common.back": "返回",
"common.next": "下一步",
"common.previous": "上一步",
"common.submit": "提交",
"common.reset": "重置",
"common.clear": "清除",
"common.confirm": "确认",
"common.yes": "是",
"common.no": "否",
"common.menu": "菜单",
"home.title": "您的链接。您的风格。您的CatLink。",
"home.subtitle": "创建一个美丽的个人页面,将所有重要链接集中在一处。专业且时尚地分享!",
"home.emailPlaceholder": "输入您的邮箱",
"home.startFree": "免费开始",
"home.haveAccount": "已有账户?",
"home.signIn": "登录",
"home.features.title": "为什么选择CatLink",
"home.features.subtitle": "创建数字存在感的简单而强大的工具",
"home.features.links.title": "一个网址 — 所有链接",
"home.features.links.description": "将所有重要链接汇聚在一处。社交媒体、作品集、联系方式——全部在一个地址下。",
"home.features.customization.title": "个性化定制",
"home.features.customization.description": "自定义颜色、字体、布局。创造反映您个性或品牌的独特风格。",
"home.features.analytics.title": "分析统计",
"home.features.analytics.description": "追踪点击量、链接热度和访客活动。更好地了解您的受众。",
"home.useCases.title": "适合所有人",
"home.useCases.bloggers": "博主",
"home.useCases.bloggersDescription": "汇聚所有社交媒体",
"home.useCases.business": "企业",
"home.useCases.businessDescription": "展示服务和联系方式",
"home.useCases.musicians": "音乐家",
"home.useCases.musiciansDescription": "分享您的作品",
"home.useCases.photographers": "摄影师",
"home.useCases.photographersDescription": "展示您的作品集",
"home.useCases.exampleTitle": "个人页面",
"home.useCases.exampleSubtitle": "页面示例",
"home.useCases.personalSite": "个人网站",
"home.cta.title": "准备开始了吗?",
"home.cta.subtitle": "加入数千名用户,他们已经创建了完美的链接页面",
"home.cta.createFree": "创建免费账户",
"home.cta.haveAccount": "我有账户",
"home.cta.features": "永久免费 • 无限制 • 快速设置",
"auth.welcome": "欢迎!",
"auth.welcomeSubtitle": "登录您的CatLink账户",
"auth.createAccount": "创建账户",
"auth.createAccountSubtitle": "今天就加入CatLink",
"auth.usernameLabel": "用户名",
"auth.usernamePlaceholder": "输入您的用户名",
"auth.usernameRequired": "请输入用户名",
"auth.usernameHelp": "只能使用拉丁字母、数字和_",
"auth.passwordLabel": "密码",
"auth.passwordPlaceholder": "输入您的密码",
"auth.passwordRequired": "请输入密码",
"auth.passwordConfirmLabel": "确认密码",
"auth.passwordConfirmRequired": "请确认密码",
"auth.passwordMismatch": "密码不匹配",
"auth.emailLabel": "邮箱",
"auth.emailRequired": "请输入邮箱",
"auth.firstNameLabel": "名字",
"auth.lastNameLabel": "姓氏",
"auth.loginButton": "登录",
"auth.registerButton": "创建账户",
"auth.loggingIn": "登录中...",
"auth.registering": "创建账户中...",
"auth.noAccount": "还没有账户?",
"auth.haveAccount": "已有账户?",
"auth.loginError": "登录错误",
"auth.networkError": "网络错误",
"auth.registrationError": "注册错误",
"auth.connectionError": "服务器连接错误",
"auth.termsAgreement": "创建账户即表示您同意",
"auth.termsLink": "服务条款",
"auth.privacyLink": "隐私政策",
"auth.and": "和",
"auth.login.title": "登录",
"auth.login.email": "邮箱",
"auth.login.password": "密码",
"auth.login.remember": "记住我",
"auth.login.forgot": "忘记密码?",
"auth.login.noAccount": "还没有账户?",
"auth.login.signUp": "注册",
"auth.register.title": "注册",
"auth.register.username": "用户名",
"auth.register.email": "邮箱",
"auth.register.password": "密码",
"auth.register.confirmPassword": "确认密码",
"auth.register.firstName": "名",
"auth.register.lastName": "姓",
"auth.register.haveAccount": "已有账户?",
"auth.register.signIn": "登录",
"dashboard.title": "仪表板",
"dashboard.welcome": "欢迎,{{name}}",
"dashboard.groups": "分组",
"dashboard.links": "链接",
"dashboard.settings": "设置",
"dashboard.customize": "分享",
"dashboard.addGroup": "添加分组",
"dashboard.addLink": "添加链接",
"dashboard.noGroups": "还没有分组",
"dashboard.noLinks": "还没有链接",
"dashboard.createFirst": "创建您的第一个",
"dashboard.shareUrl.copied": "链接已复制到剪贴板",
"group.create": "创建分组",
"group.edit": "编辑分组",
"group.delete": "删除分组",
"group.name": "分组名称",
"group.description": "描述",
"group.icon": "图标",
"group.background": "背景图片",
"group.color": "标题颜色",
"group.public": "公开",
"group.favorite": "收藏",
"group.expanded": "默认展开",
"group.removeIcon": "移除图标",
"group.removeBackground": "移除背景",
"link.create": "创建链接",
"link.edit": "编辑链接",
"link.delete": "删除链接",
"link.title": "链接标题",
"link.url": "网址",
"link.description": "描述",
"link.icon": "图标",
"link.removeIcon": "移除图标",
"link.public": "公开",
"link.featured": "精选",
"profile.edit": "编辑个人资料",
"profile.username": "用户名",
"profile.email": "邮箱",
"profile.firstName": "名",
"profile.lastName": "姓",
"profile.fullName": "全名",
"profile.bio": "个人简介",
"profile.avatar": "头像",
"profile.cover": "封面图片",
"profile.removeAvatar": "移除头像",
"profile.removeCover": "移除封面",
"customization.title": "自定义",
"customization.templates": "模板",
"customization.layout": "布局",
"customization.colors": "颜色",
"customization.groups": "分组",
"customization.advanced": "高级",
"customization.data": "数据",
"customization.layout.style": "分组和链接的显示样式",
"customization.layout.list": "列表",
"customization.layout.grid": "网格",
"customization.layout.cards": "卡片",
"customization.layout.compact": "紧凑",
"customization.layout.masonry": "瀑布流",
"customization.layout.timeline": "时间线",
"customization.layout.magazine": "杂志",
"customization.colors.theme": "主题颜色",
"customization.colors.background": "背景颜色",
"customization.colors.backgroundImage": "背景图片",
"customization.colors.removeBackground": "移除背景",
"customization.colors.header": "标题文字颜色",
"customization.colors.group": "分组文字颜色",
"customization.colors.link": "链接文字颜色",
"customization.groups.showIcons": "显示分组图标",
"customization.groups.showLinks": "显示链接图标",
"customization.groups.defaultExpanded": "分组默认展开",
"customization.groups.showTitle": "显示分组标题",
"customization.advanced.fonts": "字体设置",
"customization.advanced.mainFont": "主字体",
"customization.advanced.headingFont": "标题字体",
"customization.advanced.bodyFont": "正文字体",
"customization.advanced.customCSS": "自定义CSS",
"customization.data.title": "个人资料数据导出和导入",
"customization.data.description": "创建个人资料数据的备份或从归档文件中恢复",
"customization.data.export.title": "导出数据",
"customization.data.export.description": "为备份或转移创建包含个人资料数据的归档文件",
"customization.data.export.button": "创建导出",
"customization.data.import.title": "导入数据",
"customization.data.import.description": "从导出归档文件上传和恢复数据",
"customization.data.import.file": "选择归档文件 (.zip)",
"customization.data.import.button": "打开导入向导",
"customization.data.history.title": "操作历史",
"customization.data.history.description": "导出和导入历史将显示在这里",
"customization.resetSettings": "重置设置",
"customization.resetConfirm": "您确定要将所有界面设置重置为默认值吗?此操作无法撤消。",
"export.title": "导出个人资料数据",
"export.description": "选择要包含在导出归档中的数据",
"export.general": "通用数据",
"export.profile": "个人资料数据(姓名、简介、头像)",
"export.styles": "设计设置和样式",
"export.media": "媒体文件(图片、图标)",
"export.groupsLinks": "分组和链接",
"export.selectedCount": "{{groups}} 个分组,{{links}} 个链接",
"export.createButton": "创建并下载",
"export.creating": "创建导出中...",
"import.title": "导入个人资料数据",
"import.selectFile": "选择要导入的归档文件",
"import.analyzing": "分析归档中...",
"import.content": "归档内容",
"import.exportInfo": "导出信息",
"import.source": "来源",
"import.exportDate": "导出日期",
"import.dataStats": "数据统计",
"import.groups": "分组",
"import.links": "链接",
"import.designSettings": "设计设置",
"import.mediaFiles": "媒体文件",
"import.yes": "有",
"import.no": "无",
"import.groupsPreview": "分组前5个",
"import.linksPreview": "链接前10个",
"import.settings": "导入设置",
"import.importGroups": "导入分组({{count}}个)",
"import.importLinks": "导入链接({{count}}个)",
"import.importStyles": "导入设计设置",
"import.importMedia": "导入媒体文件({{count}}个)",
"import.overwriteExisting": "覆盖现有数据",
"import.overwriteHelp": "如果禁用,将跳过具有相同名称的现有分组和链接",
"import.unavailable": "(不可用)",
"import.button": "导入",
"import.importing": "导入中...",
"theme.toggle": "切换主题",
"theme.light": "浅色主题",
"theme.dark": "深色主题",
"language.select": "选择语言",
"language.en": "English",
"language.ru": "Русский",
"language.ko": "한국어",
"language.zh": "中文",
"language.ja": "日本語",
"footer.about": "关于我们",
"footer.contact": "联系我们",
"footer.terms": "服务条款",
"footer.privacy": "隐私政策",
"footer.copyright": "© CatLink 2025. 保留所有权利。"
}

View File

@@ -4,12 +4,13 @@
import { useState } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import Image from 'next/image'
import { useLocale } from './contexts/LocaleContext'
export default function HomePage() {
const [email, setEmail] = useState('')
const [loading, setLoading] = useState(false)
const router = useRouter()
const { t } = useLocale()
const handleQuickStart = async (e: React.FormEvent) => {
e.preventDefault()
@@ -17,10 +18,7 @@ export default function HomePage() {
setLoading(true)
// Сохраняем email в локальном хранилище для автозаполнения формы регистрации
localStorage.setItem('quickStartEmail', email)
// Перенаправляем на страницу регистрации
router.push('/auth/register')
}
@@ -38,21 +36,11 @@ export default function HomePage() {
<div className="container py-5">
<div className="row">
<div className="col-xl-10 mx-auto">
<div className="mb-4">
<Image
src="/assets/img/CAT.png"
alt="CatLink"
width={120}
height={120}
className="mb-4"
/>
</div>
<h1 className="display-4 fw-bold mb-4">
Ваши ссылки. Ваш стиль. Ваш CatLink.
{t('home.title')}
</h1>
<p className="lead mb-5 fs-4">
Создайте красивую персональную страницу со всеми важными ссылками в одном месте.
Делитесь профессионально и стильно!
{t('home.subtitle')}
</p>
</div>
<div className="col-md-10 col-lg-8 col-xl-6 mx-auto">
@@ -60,7 +48,7 @@ export default function HomePage() {
<input
className="form-control form-control-lg"
type="email"
placeholder="Введите ваш email"
placeholder={t('home.emailPlaceholder')}
value={email}
onChange={(e) => setEmail(e.target.value)}
required
@@ -74,180 +62,51 @@ export default function HomePage() {
{loading ? (
<span className="spinner-border spinner-border-sm"></span>
) : (
'Начать бесплатно'
t('home.startFree')
)}
</button>
</form>
<div className="mt-3">
<small className="text-white-50">
Уже есть аккаунт? <Link href="/auth/login" className="text-warning text-decoration-none fw-bold">Войти</Link>
{t('home.haveAccount')} <Link href="/auth/login" className="text-warning text-decoration-none fw-bold">{t('home.signIn')}</Link>
</small>
</div>
</div>
</div>
{/* Анимированная стрелка вниз */}
<div className="position-absolute bottom-0 start-50 translate-middle-x mb-4">
<div className="animate-bounce">
<i className="bi bi-chevron-down text-white fs-3"></i>
</div>
</div>
</div>
</header>
{/* Преимущества */}
<section className="py-5 bg-light">
<div className="container">
<div className="row text-center mb-5">
<div className="col-lg-8 mx-auto">
<h2 className="display-5 fw-bold mb-3">Почему выбирают CatLink?</h2>
<h2 className="display-5 fw-bold mb-3">{t('home.features.title')}</h2>
<p className="lead text-muted">
Простой и мощный инструмент для создания вашего цифрового присутствия
{t('home.features.subtitle')}
</p>
</div>
</div>
<div className="row">
<div className="col-lg-4 mb-4">
<div className="text-center h-100">
<div className="bg-primary rounded-circle d-inline-flex align-items-center justify-content-center mb-4" style={{ width: '80px', height: '80px' }}>
<i className="bi bi-link-45deg text-white fs-1"></i>
</div>
<h4 className="fw-bold">Один URL все ссылки</h4>
<p className="text-muted">
Соберите все важные ссылки в одном месте. Социальные сети, портфолио,
контакты всё под одним адресом.
</p>
</div>
</div>
<div className="col-lg-4 mb-4">
<div className="text-center h-100">
<div className="bg-success rounded-circle d-inline-flex align-items-center justify-content-center mb-4" style={{ width: '80px', height: '80px' }}>
<i className="bi bi-palette text-white fs-1"></i>
</div>
<h4 className="fw-bold">Персонализация</h4>
<p className="text-muted">
Настройте цвета, шрифты, макеты. Создайте уникальный стиль,
который отражает вашу личность или бренд.
</p>
</div>
</div>
<div className="col-lg-4 mb-4">
<div className="text-center h-100">
<div className="bg-warning rounded-circle d-inline-flex align-items-center justify-content-center mb-4" style={{ width: '80px', height: '80px' }}>
<i className="bi bi-graph-up text-white fs-1"></i>
</div>
<h4 className="fw-bold">Аналитика</h4>
<p className="text-muted">
Отслеживайте клики, популярность ссылок и активность посетителей.
Понимайте свою аудиторию лучше.
</p>
</div>
</div>
</div>
</div>
</section>
{/* Примеры использования */}
<section className="py-5">
<div className="container">
<div className="row align-items-center">
<div className="col-lg-6 mb-4">
<h2 className="display-6 fw-bold mb-4">Для всех и каждого</h2>
<div className="row">
<div className="col-sm-6 mb-3">
<div className="d-flex align-items-start">
<div className="bg-primary rounded-circle me-3 d-flex align-items-center justify-content-center" style={{ width: '40px', height: '40px', minWidth: '40px' }}>
<i className="bi bi-person text-white"></i>
</div>
<div>
<h6 className="fw-bold mb-1">Блогеры</h6>
<small className="text-muted">Соберите все социальные сети</small>
</div>
</div>
</div>
<div className="col-sm-6 mb-3">
<div className="d-flex align-items-start">
<div className="bg-success rounded-circle me-3 d-flex align-items-center justify-content-center" style={{ width: '40px', height: '40px', minWidth: '40px' }}>
<i className="bi bi-briefcase text-white"></i>
</div>
<div>
<h6 className="fw-bold mb-1">Бизнес</h6>
<small className="text-muted">Покажите услуги и контакты</small>
</div>
</div>
</div>
<div className="col-sm-6 mb-3">
<div className="d-flex align-items-start">
<div className="bg-warning rounded-circle me-3 d-flex align-items-center justify-content-center" style={{ width: '40px', height: '40px', minWidth: '40px' }}>
<i className="bi bi-music-note text-white"></i>
</div>
<div>
<h6 className="fw-bold mb-1">Музыканты</h6>
<small className="text-muted">Поделитесь творчеством</small>
</div>
</div>
</div>
<div className="col-sm-6 mb-3">
<div className="d-flex align-items-start">
<div className="bg-info rounded-circle me-3 d-flex align-items-center justify-content-center" style={{ width: '40px', height: '40px', minWidth: '40px' }}>
<i className="bi bi-camera text-white"></i>
</div>
<div>
<h6 className="fw-bold mb-1">Фотографы</h6>
<small className="text-muted">Покажите портфолио</small>
</div>
</div>
</div>
</div>
</div>
<div className="col-lg-6 text-center">
<div className="bg-light rounded-3 p-4 shadow-sm">
<div className="bg-white rounded-3 p-4 mb-3 border">
<div className="d-flex align-items-center mb-3">
<div className="bg-primary rounded-circle me-3" style={{ width: '40px', height: '40px' }}></div>
<div>
<h6 className="mb-0">@your_username</h6>
<small className="text-muted">Ваша персональная страница</small>
</div>
</div>
<div className="d-grid gap-2">
<div className="bg-light rounded-2 p-2 text-start">
<small><i className="bi bi-instagram text-danger me-2"></i>Instagram</small>
</div>
<div className="bg-light rounded-2 p-2 text-start">
<small><i className="bi bi-youtube text-danger me-2"></i>YouTube</small>
</div>
<div className="bg-light rounded-2 p-2 text-start">
<small><i className="bi bi-globe text-primary me-2"></i>Личный сайт</small>
</div>
</div>
</div>
<small className="text-muted">Пример вашей страницы</small>
</div>
</div>
</div>
</div>
</section>
{/* CTA секция */}
<section className="py-5 bg-primary text-white">
<div className="container text-center">
<div className="row">
<div className="col-lg-8 mx-auto">
<h2 className="display-6 fw-bold mb-4">Готовы начать?</h2>
<h2 className="display-6 fw-bold mb-4">{t('home.cta.title')}</h2>
<p className="lead mb-4">
Присоединяйтесь к тысячам пользователей, которые уже создали свою идеальную страницу ссылок
{t('home.cta.subtitle')}
</p>
<div className="d-flex flex-column flex-sm-row gap-3 justify-content-center">
<Link href="/auth/register" className="btn btn-warning btn-lg px-4 fw-bold">
Создать аккаунт бесплатно
{t('home.cta.createFree')}
</Link>
<Link href="/auth/login" className="btn btn-outline-light btn-lg px-4">
У меня есть аккаунт
{t('home.cta.haveAccount')}
</Link>
</div>
<div className="mt-3">
<small className="text-white-75">Бесплатно навсегда Без ограничений Быстрая настройка</small>
<small className="text-white-75">{t('home.cta.features')}</small>
</div>
</div>
</div>

View File

@@ -0,0 +1,311 @@
'use client'
import React, { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { useLocale } from '../contexts/LocaleContext'
interface UserProfile {
id: number
username: string
email: string
full_name: string
bio: string
avatar: string | null
cover: string | null
}
export default function ProfilePage() {
const { t } = useLocale()
const router = useRouter()
const [profile, setProfile] = useState<UserProfile | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [avatarFile, setAvatarFile] = useState<File | null>(null)
const [coverFile, setCoverFile] = useState<File | null>(null)
useEffect(() => {
loadProfile()
}, [])
const loadProfile = async () => {
try {
const token = localStorage.getItem('token')
if (!token) {
router.push('/auth/login')
return
}
const response = await fetch('/api/auth/user', {
headers: { 'Authorization': `Bearer ${token}` }
})
if (!response.ok) {
throw new Error('Failed to load profile')
}
const data = await response.json()
setProfile(data)
} catch (error) {
console.error('Error loading profile:', error)
} finally {
setLoading(false)
}
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!profile) return
setSaving(true)
try {
const token = localStorage.getItem('token')
const formData = new FormData()
formData.append('username', profile.username)
formData.append('email', profile.email)
formData.append('full_name', profile.full_name)
formData.append('bio', profile.bio)
if (avatarFile) {
formData.append('avatar', avatarFile)
}
if (coverFile) {
formData.append('cover', coverFile)
}
const response = await fetch('/api/auth/user', {
method: 'PUT',
headers: {
'Authorization': `Bearer ${token}`
},
body: formData
})
if (response.ok) {
const updatedProfile = await response.json()
setProfile(updatedProfile)
// Очистить выбранные файлы
setAvatarFile(null)
setCoverFile(null)
}
} catch (error) {
console.error('Error saving profile:', error)
} finally {
setSaving(false)
}
}
const removeAvatar = () => {
if (profile) {
setProfile({ ...profile, avatar: null })
}
}
const removeCover = () => {
if (profile) {
setProfile({ ...profile, cover: null })
}
}
if (loading) {
return (
<div className="container mt-4">
<div className="d-flex justify-content-center">
<div className="spinner-border" role="status">
<span className="visually-hidden">{t('common.loading')}</span>
</div>
</div>
</div>
)
}
if (!profile) {
return (
<div className="container mt-4">
<div className="alert alert-danger">
{t('common.error')}: Profile not found
</div>
</div>
)
}
return (
<div className="container mt-4">
<div className="row justify-content-center">
<div className="col-md-8">
<div className="card">
<div className="card-header">
<h4 className="mb-0">
<i className="fas fa-user me-2"></i>
{t('profile.edit')}
</h4>
</div>
<div className="card-body">
<form onSubmit={handleSubmit}>
{/* Avatar */}
<div className="mb-4 text-center">
<div className="mb-3">
<img
src={
avatarFile
? URL.createObjectURL(avatarFile)
: profile.avatar
? `${process.env.NEXT_PUBLIC_API_URL || 'https://links.shareon.kr'}${profile.avatar}`
: '/assets/img/avatar-dhg.png'
}
alt="Avatar"
className="rounded-circle profile-avatar"
width={120}
height={120}
/>
</div>
<div className="d-flex gap-2 justify-content-center">
<label className="btn btn-outline-primary btn-sm">
<i className="fas fa-camera me-1"></i>
{t('profile.avatar')}
<input
type="file"
className="d-none"
accept="image/*"
onChange={(e) => setAvatarFile(e.target.files?.[0] || null)}
/>
</label>
{(profile.avatar || avatarFile) && (
<button
type="button"
className="btn btn-outline-danger btn-sm"
onClick={removeAvatar}
>
<i className="fas fa-times me-1"></i>
{t('profile.removeAvatar')}
</button>
)}
</div>
</div>
{/* Cover Image */}
<div className="mb-4">
<label className="form-label">{t('profile.cover')}</label>
{(profile.cover || coverFile) && (
<div className="mb-2">
<img
src={
coverFile
? URL.createObjectURL(coverFile)
: `${process.env.NEXT_PUBLIC_API_URL || 'https://links.shareon.kr'}${profile.cover}`
}
alt="Cover"
className="img-fluid rounded profile-cover"
/>
</div>
)}
<div className="d-flex gap-2">
<label className="btn btn-outline-primary btn-sm">
<i className="fas fa-image me-1"></i>
{profile.cover ? t('common.update') : t('common.add')}
<input
type="file"
className="d-none"
accept="image/*"
onChange={(e) => setCoverFile(e.target.files?.[0] || null)}
/>
</label>
{(profile.cover || coverFile) && (
<button
type="button"
className="btn btn-outline-danger btn-sm"
onClick={removeCover}
>
<i className="fas fa-times me-1"></i>
{t('profile.removeCover')}
</button>
)}
</div>
</div>
{/* Basic Info */}
<div className="row">
<div className="col-md-6">
<div className="mb-3">
<label className="form-label">{t('profile.username')}</label>
<input
type="text"
className="form-control"
value={profile.username}
onChange={(e) => setProfile({ ...profile, username: e.target.value })}
required
/>
</div>
</div>
<div className="col-md-6">
<div className="mb-3">
<label className="form-label">{t('profile.email')}</label>
<input
type="email"
className="form-control"
value={profile.email}
onChange={(e) => setProfile({ ...profile, email: e.target.value })}
required
/>
</div>
</div>
</div>
<div className="mb-3">
<label className="form-label">{t('profile.fullName')}</label>
<input
type="text"
className="form-control"
value={profile.full_name}
onChange={(e) => setProfile({ ...profile, full_name: e.target.value })}
/>
</div>
<div className="mb-4">
<label className="form-label">{t('profile.bio')}</label>
<textarea
className="form-control"
rows={4}
value={profile.bio}
onChange={(e) => setProfile({ ...profile, bio: e.target.value })}
placeholder={t('profile.bio')}
/>
</div>
{/* Actions */}
<div className="d-flex justify-content-between">
<button
type="button"
className="btn btn-secondary"
onClick={() => router.back()}
>
<i className="fas fa-arrow-left me-2"></i>
{t('common.back')}
</button>
<button
type="submit"
className="btn btn-primary"
disabled={saving}
>
{saving ? (
<>
<span className="spinner-border spinner-border-sm me-2"></span>
{t('common.saving')}
</>
) : (
<>
<i className="fas fa-save me-2"></i>
{t('common.save')}
</>
)}
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,217 @@
/* Комфортные стили для темной темы - дополнительные настройки для лучшего UX */
/* Адаптация к системным настройкам пользователя */
@media (prefers-reduced-motion: reduce) {
[data-theme="dark"] * {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
/* Улучшенная читаемость текста */
@media (prefers-contrast: high) {
[data-theme="dark"] {
--text: #f0f0f8;
--text-secondary: #c8c8d8;
--border: #4a4a5a;
}
}
/* Адаптация к размеру экрана для комфорта */
@media (max-width: 768px) {
[data-theme="dark"] {
--card-bg: #1e1e2a;
--background-secondary: #1e1e2a;
}
[data-theme="dark"] .card {
border-radius: 16px;
margin-bottom: 1rem;
}
[data-theme="dark"] .btn {
padding: 12px 20px;
border-radius: 12px;
font-size: 16px;
}
}
/* Специальные стили для больших экранов */
@media (min-width: 1200px) {
[data-theme="dark"] .container-fluid {
max-width: 1400px;
}
[data-theme="dark"] .card {
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.15);
}
}
/* Улучшенная фокусировка для пользователей клавиатуры */
@media (prefers-reduced-transparency: no-preference) {
[data-theme="dark"] .btn:focus,
[data-theme="dark"] .form-control:focus,
[data-theme="dark"] a:focus {
outline: 3px solid rgba(124, 58, 237, 0.5);
outline-offset: 2px;
background: rgba(124, 58, 237, 0.05);
}
}
/* Мягкая анимация появления элементов */
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
[data-theme="dark"] .fade-in {
animation: fadeInUp 0.6s cubic-bezier(0.4, 0, 0.2, 1);
}
/* Улучшенные состояния загрузки */
[data-theme="dark"] .loading {
background: linear-gradient(
90deg,
var(--background-secondary) 25%,
var(--background-tertiary) 37%,
var(--background-secondary) 63%
);
background-size: 400% 100%;
animation: shimmer 1.5s ease-in-out infinite;
}
@keyframes shimmer {
0% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
/* Специальная палитра для состояний */
[data-theme="dark"] .state-success {
background: linear-gradient(135deg, rgba(34, 197, 94, 0.1), rgba(34, 197, 94, 0.05));
border-left: 4px solid #22c55e;
color: #86efac;
}
[data-theme="dark"] .state-warning {
background: linear-gradient(135deg, rgba(251, 191, 36, 0.1), rgba(251, 191, 36, 0.05));
border-left: 4px solid #fbbf24;
color: #fde68a;
}
[data-theme="dark"] .state-error {
background: linear-gradient(135deg, rgba(248, 113, 113, 0.1), rgba(248, 113, 113, 0.05));
border-left: 4px solid #f87171;
color: #fca5a5;
}
[data-theme="dark"] .state-info {
background: linear-gradient(135deg, rgba(56, 189, 248, 0.1), rgba(56, 189, 248, 0.05));
border-left: 4px solid #38bdf8;
color: #7dd3fc;
}
/* Комфортные тултипы */
[data-theme="dark"] .tooltip {
background: var(--background-tertiary);
color: var(--text);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
}
/* Стили для уведомлений */
[data-theme="dark"] .notification {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 12px;
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
/* Улучшенный выбор текста */
[data-theme="dark"] ::selection {
background: rgba(124, 58, 237, 0.3);
color: var(--text);
}
[data-theme="dark"] ::-moz-selection {
background: rgba(124, 58, 237, 0.3);
color: var(--text);
}
/* Специальные эффекты для интерактивных элементов */
[data-theme="dark"] .interactive-element {
position: relative;
overflow: hidden;
}
[data-theme="dark"] .interactive-element::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(
90deg,
transparent,
rgba(124, 58, 237, 0.1),
transparent
);
transition: left 0.5s;
}
[data-theme="dark"] .interactive-element:hover::before {
left: 100%;
}
/* Мягкое появление модальных окон */
[data-theme="dark"] .modal.fade .modal-dialog {
transform: scale(0.9);
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
[data-theme="dark"] .modal.show .modal-dialog {
transform: scale(1);
}
/* Комфортные разделители */
[data-theme="dark"] hr {
border: none;
height: 1px;
background: linear-gradient(
90deg,
transparent,
var(--border),
transparent
);
margin: 2rem 0;
}
/* Улучшенные списки */
[data-theme="dark"] .list-group-item {
background: var(--card-bg);
border-color: var(--border);
transition: all 0.3s ease;
}
[data-theme="dark"] .list-group-item:hover {
background: linear-gradient(135deg, rgba(124, 58, 237, 0.05), rgba(124, 58, 237, 0.02));
transform: translateX(4px);
border-left: 3px solid var(--primary);
}

View File

@@ -0,0 +1,568 @@
/* CSS Custom Properties для темизации */
:root {
/* Светлая тема (обновленная) */
--background: #fafafa;
--background-secondary: #f5f5f7;
--background-tertiary: #e8e8ed;
--text: #1a1a1f;
--text-secondary: #6e6e73;
--text-muted: #86868b;
--border: #d2d2d7;
--border-light: #e8e8ed;
--primary: #8b5cf6;
--primary-hover: #7c3aed;
--secondary: #6366f1;
--success: #10b981;
--danger: #ef4444;
--warning: #f59e0b;
--info: #06b6d4;
--card-bg: #ffffff;
--card-border: #d2d2d7;
--input-bg: #ffffff;
--input-border: #d2d2d7;
--input-focus-border: #8b5cf6;
--dropdown-bg: #ffffff;
--dropdown-border: #d2d2d7;
--modal-bg: #ffffff;
--modal-backdrop: rgba(26, 26, 31, 0.5);
--navbar-bg: #ffffff;
--sidebar-bg: #f5f5f7;
--shadow: rgba(26, 26, 31, 0.1);
--shadow-lg: rgba(26, 26, 31, 0.12);
}
[data-theme="dark"] {
/* Темная тема - Комфортная ночная палитра */
--background: #1a1a23;
--background-secondary: #22222f;
--background-tertiary: #2a2a3a;
--text: #e8e8f0;
--text-secondary: #b8b8c8;
--text-muted: #9090a0;
--border: #3a3a4a;
--border-light: #32323f;
--primary: #7c3aed;
--primary-hover: #8b5cf6;
--secondary: #64748b;
--success: #22c55e;
--danger: #f87171;
--warning: #fbbf24;
--info: #38bdf8;
--card-bg: #22222f;
--card-border: #3a3a4a;
--input-bg: #1a1a23;
--input-border: #3a3a4a;
--input-focus-border: #7c3aed;
--dropdown-bg: #22222f;
--dropdown-border: #3a3a4a;
--modal-bg: #22222f;
--modal-backdrop: rgba(26, 26, 35, 0.75);
--navbar-bg: #22222f;
--sidebar-bg: #1a1a23;
--shadow: rgba(0, 0, 0, 0.2);
--shadow-lg: rgba(0, 0, 0, 0.3);
--accent-glow: rgba(124, 58, 237, 0.1);
--warm-accent: #fbbf24;
--cool-accent: #38bdf8;
}
/* Применение темы к основным элементам */
body {
background-color: var(--background);
color: var(--text);
transition: background-color 0.3s ease, color 0.3s ease;
}
/* Bootstrap переопределения для темной темы */
.card {
background-color: var(--card-bg);
border-color: var(--card-border);
color: var(--text);
}
.modal-content {
background-color: var(--modal-bg);
border-color: var(--card-border);
color: var(--text);
}
.modal-header {
border-bottom-color: var(--border);
}
.modal-footer {
border-top-color: var(--border);
}
.form-control {
background-color: var(--input-bg);
border-color: var(--input-border);
color: var(--text);
}
.form-control:focus {
background-color: var(--input-bg);
border-color: var(--input-focus-border);
color: var(--text);
box-shadow: 0 0 0 0.25rem rgba(139, 92, 246, 0.25);
}
[data-theme="dark"] .form-control:focus {
box-shadow: 0 0 0 0.25rem rgba(124, 58, 237, 0.25);
}
.form-select {
background-color: var(--input-bg);
border-color: var(--input-border);
color: var(--text);
}
.form-select:focus {
background-color: var(--input-bg);
border-color: var(--input-focus-border);
color: var(--text);
}
.btn-outline-secondary {
color: var(--text);
border-color: var(--border);
}
.btn-outline-secondary:hover {
background-color: var(--background-secondary);
border-color: var(--border);
color: var(--text);
}
.dropdown-menu {
background-color: var(--dropdown-bg);
border-color: var(--dropdown-border);
}
.dropdown-item {
color: var(--text);
}
.dropdown-item:hover,
.dropdown-item:focus {
background-color: var(--background-secondary);
color: var(--text);
}
.nav-tabs {
border-bottom-color: var(--border);
}
.nav-tabs .nav-link {
color: var(--text-secondary);
border-color: transparent;
}
.nav-tabs .nav-link:hover {
border-color: var(--border-light) var(--border-light) var(--border);
color: var(--text);
}
.nav-tabs .nav-link.active {
color: var(--text);
background-color: var(--background);
border-color: var(--border) var(--border) var(--background);
}
.list-group-item {
background-color: var(--card-bg);
border-color: var(--border);
color: var(--text);
}
.text-muted {
color: var(--text-muted) !important;
}
.text-secondary {
color: var(--text-secondary) !important;
}
.border {
border-color: var(--border) !important;
}
.border-top {
border-top-color: var(--border) !important;
}
.border-bottom {
border-bottom-color: var(--border) !important;
}
.border-start {
border-left-color: var(--border) !important;
}
.border-end {
border-right-color: var(--border) !important;
}
/* Специальные стили для темной темы */
[data-theme="dark"] .bg-light {
background-color: var(--background-secondary) !important;
}
[data-theme="dark"] .bg-white {
background-color: var(--card-bg) !important;
}
[data-theme="dark"] .text-dark {
color: var(--text) !important;
}
/* Специальные стили для светлой темы */
[data-theme="light"] .btn-primary,
:root .btn-primary {
background-color: var(--primary);
border-color: var(--primary);
color: #ffffff;
}
[data-theme="light"] .btn-primary:hover,
:root .btn-primary:hover {
background-color: var(--primary-hover);
border-color: var(--primary-hover);
color: #ffffff;
}
[data-theme="light"] .btn-outline-primary,
:root .btn-outline-primary {
color: var(--primary);
border-color: var(--primary);
}
[data-theme="light"] .btn-outline-primary:hover,
:root .btn-outline-primary:hover {
background-color: var(--primary);
border-color: var(--primary);
color: #ffffff;
}
[data-theme="light"] .card:hover,
:root .card:hover {
box-shadow: 0 0.5rem 1rem rgba(139, 92, 246, 0.08);
transition: box-shadow 0.3s ease;
}
[data-theme="light"] .nav-tabs .nav-link:hover,
:root .nav-tabs .nav-link:hover {
color: var(--primary);
}
[data-theme="light"] .nav-tabs .nav-link.active,
:root .nav-tabs .nav-link.active {
color: var(--primary);
border-bottom-color: var(--primary);
}
/* Дополнительные стили для фиолетовой темы */
[data-theme="dark"] .btn-primary {
background: linear-gradient(135deg, var(--primary), #9333ea);
border-color: var(--primary);
color: #ffffff;
box-shadow: 0 2px 8px rgba(124, 58, 237, 0.3);
}
[data-theme="dark"] .btn-primary:hover {
background: linear-gradient(135deg, var(--primary-hover), #a855f7);
border-color: var(--primary-hover);
color: #ffffff;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(124, 58, 237, 0.4);
}
[data-theme="dark"] .btn-secondary {
background-color: var(--secondary);
border-color: var(--secondary);
color: #ffffff;
}
[data-theme="dark"] .btn-secondary:hover {
background-color: #475569;
border-color: #475569;
color: #ffffff;
transform: translateY(-1px);
}
[data-theme="dark"] .btn-outline-primary {
color: var(--primary);
border-color: var(--primary);
background: rgba(124, 58, 237, 0.05);
}
[data-theme="dark"] .btn-outline-primary:hover {
background: var(--primary);
border-color: var(--primary);
color: #ffffff;
box-shadow: 0 4px 12px rgba(124, 58, 237, 0.3);
}
[data-theme="dark"] .navbar {
background-color: var(--navbar-bg);
border-bottom: 1px solid var(--border);
-webkit-backdrop-filter: blur(8px);
backdrop-filter: blur(8px);
}
[data-theme="dark"] .card {
background: linear-gradient(145deg, var(--card-bg), #2a2a3a);
border: 1px solid var(--card-border);
border-radius: 12px;
}
[data-theme="dark"] .card:hover {
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
transform: translateY(-2px);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
[data-theme="dark"] .modal-content {
background: var(--modal-bg);
border: 1px solid var(--border);
border-radius: 16px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3);
}
[data-theme="dark"] .form-control,
[data-theme="dark"] .form-select {
background-color: var(--input-bg);
border: 1px solid var(--input-border);
color: var(--text);
border-radius: 8px;
}
[data-theme="dark"] .form-control:focus,
[data-theme="dark"] .form-select:focus {
background-color: var(--input-bg);
border-color: var(--primary);
box-shadow: 0 0 0 0.25rem rgba(124, 58, 237, 0.25);
outline: none;
}
[data-theme="dark"] .dropdown-item:hover,
[data-theme="dark"] .dropdown-item:focus {
background: linear-gradient(135deg, rgba(124, 58, 237, 0.1), rgba(124, 58, 237, 0.05));
color: var(--text);
border-radius: 6px;
}
[data-theme="dark"] .nav-tabs .nav-link:hover {
color: var(--primary);
border-bottom: 2px solid transparent;
}
[data-theme="dark"] .nav-tabs .nav-link.active {
color: var(--primary);
background: rgba(124, 58, 237, 0.1);
border-bottom: 2px solid var(--primary);
border-radius: 8px 8px 0 0;
}
[data-theme="dark"] .list-group-item:hover {
background: linear-gradient(135deg, rgba(124, 58, 237, 0.05), rgba(124, 58, 237, 0.02));
border-radius: 8px;
}
/* Анимация переключения темы */
* {
transition:
background-color 0.3s cubic-bezier(0.4, 0, 0.2, 1),
color 0.3s cubic-bezier(0.4, 0, 0.2, 1),
border-color 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
/* Кастомные утилитарные классы для темизации */
.theme-bg {
background-color: var(--background);
}
.theme-bg-secondary {
background-color: var(--background-secondary);
}
.theme-bg-tertiary {
background-color: var(--background-tertiary);
}
.theme-text {
color: var(--text);
}
.theme-text-secondary {
color: var(--text-secondary);
}
.theme-text-muted {
color: var(--text-muted);
}
.theme-border {
border-color: var(--border);
}
.theme-shadow {
box-shadow: 0 0.125rem 0.25rem var(--shadow);
}
.theme-shadow-lg {
box-shadow: 0 1rem 3rem var(--shadow-lg);
}
/* Комфортные градиенты для темной темы */
[data-theme="dark"] .gradient-primary {
background: linear-gradient(135deg, var(--primary) 0%, #9333ea 100%);
}
[data-theme="dark"] .gradient-card {
background: linear-gradient(145deg, var(--card-bg) 0%, var(--background-tertiary) 100%);
border: 1px solid rgba(124, 58, 237, 0.1);
}
/* Улучшенные тени для комфортного восприятия */
[data-theme="dark"] .enhanced-shadow {
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -1px rgba(0, 0, 0, 0.06);
}
[data-theme="dark"] .enhanced-shadow-lg {
box-shadow:
0 20px 25px -5px rgba(0, 0, 0, 0.15),
0 10px 10px -5px rgba(0, 0, 0, 0.04);
}
/* Специальные классы для ночного режима */
[data-theme="dark"] .night-comfort {
filter: brightness(0.95) contrast(1.05);
}
[data-theme="dark"] .text-emphasis {
color: var(--text);
font-weight: 500;
}
[data-theme="dark"] .soft-glow {
box-shadow: 0 0 20px rgba(124, 58, 237, 0.1);
}
/* Адаптивная яркость для различных элементов */
[data-theme="dark"] .bright-element {
filter: brightness(1.1);
}
[data-theme="dark"] .dim-element {
filter: brightness(0.9);
}
/* Гладкие переходы для всех элементов */
[data-theme="dark"] * {
transition:
background-color 0.3s cubic-bezier(0.4, 0, 0.2, 1),
color 0.3s cubic-bezier(0.4, 0, 0.2, 1),
border-color 0.3s cubic-bezier(0.4, 0, 0.2, 1),
box-shadow 0.3s cubic-bezier(0.4, 0, 0.2, 1),
transform 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
/* Комфортная прокрутка для темной темы */
[data-theme="dark"] ::-webkit-scrollbar {
width: 12px;
height: 12px;
}
[data-theme="dark"] ::-webkit-scrollbar-track {
background: var(--background-secondary);
border-radius: 6px;
}
[data-theme="dark"] ::-webkit-scrollbar-thumb {
background: linear-gradient(135deg, var(--primary), #9333ea);
border-radius: 6px;
border: 2px solid var(--background-secondary);
}
[data-theme="dark"] ::-webkit-scrollbar-thumb:hover {
background: linear-gradient(135deg, var(--primary-hover), #a855f7);
}
/* Мягкие стили для фокуса */
[data-theme="dark"] :focus-visible {
outline: 2px solid var(--primary);
outline-offset: 2px;
border-radius: 4px;
}
/* Приятные ссылки */
[data-theme="dark"] a {
color: var(--primary);
text-decoration: none;
transition: color 0.2s ease;
}
[data-theme="dark"] a:hover {
color: var(--primary-hover);
text-decoration: underline;
}
/* Улучшенная типография для ночного чтения */
[data-theme="dark"] body {
line-height: 1.6;
letter-spacing: 0.01em;
}
[data-theme="dark"] h1,
[data-theme="dark"] h2,
[data-theme="dark"] h3,
[data-theme="dark"] h4,
[data-theme="dark"] h5,
[data-theme="dark"] h6 {
color: var(--text);
font-weight: 600;
line-height: 1.4;
}
/* Мягкие акценты для важных элементов */
[data-theme="dark"] .alert {
border: none;
border-radius: 12px;
-webkit-backdrop-filter: blur(8px);
backdrop-filter: blur(8px);
}
[data-theme="dark"] .alert-info {
background: linear-gradient(135deg, rgba(56, 189, 248, 0.1), rgba(56, 189, 248, 0.05));
border-left: 4px solid var(--cool-accent);
color: var(--text);
}
[data-theme="dark"] .alert-warning {
background: linear-gradient(135deg, rgba(251, 191, 36, 0.1), rgba(251, 191, 36, 0.05));
border-left: 4px solid var(--warm-accent);
color: var(--text);
}
[data-theme="dark"] .alert-danger {
background: linear-gradient(135deg, rgba(248, 113, 113, 0.1), rgba(248, 113, 113, 0.05));
border-left: 4px solid var(--danger);
color: var(--text);
}
[data-theme="dark"] .alert-success {
background: linear-gradient(135deg, rgba(34, 197, 94, 0.1), rgba(34, 197, 94, 0.05));
border-left: 4px solid var(--success);
color: var(--text);
}
/* Комфортные тени и подсветки */
[data-theme="dark"] .btn:focus,
[data-theme="dark"] .form-control:focus,
[data-theme="dark"] .form-select:focus {
box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.2);
}

BIN
profile_export.zip Normal file

Binary file not shown.

BIN
profile_export_full.zip Normal file

Binary file not shown.

Binary file not shown.