mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
bento-grids v0.1
This commit is contained in:
@@ -1,297 +0,0 @@
|
|||||||
# Bento Grids Refactor
|
|
||||||
|
|
||||||
> **Ветка:** `bento-grids`
|
|
||||||
> **Статус:** В работе
|
|
||||||
> **Последнее обновление:** 2026-01-20
|
|
||||||
|
|
||||||
## Референс
|
|
||||||
|
|
||||||
- **Стиль:** Bento Grids (карточки разных размеров в сетке)
|
|
||||||
- **Цветовая схема:** Зеленый неон на темном фоне, чистота
|
|
||||||
- **Шрифт:** Manrope (современный гротеск, заменён с Urbanist)
|
|
||||||
- **Скругления:** Крупные (24-32px)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## План рефакторинга
|
|
||||||
|
|
||||||
### Этап 1: Фундамент (Vibe Check)
|
|
||||||
> Цель: Пустая страница уже выглядит "секси"
|
|
||||||
|
|
||||||
| # | Задача | Статус | Заметки |
|
|
||||||
|---|--------|--------|---------|
|
|
||||||
| 1.1 | Подключить Urbanist шрифт | `[x]` | Google Fonts, заменить Inter |
|
|
||||||
| 1.2 | Tailwind: bento radius/spacing | `[x]` | `rounded-bento`, `rounded-4xl`, `spacing.bento` |
|
|
||||||
| 1.3 | globals.css: CSS переменные Bento | `[x]` | `--bento-radius`, `--bento-gap`, `--bento-padding` |
|
|
||||||
|
|
||||||
**Файлы:**
|
|
||||||
- `src/styles/globals.css`
|
|
||||||
- `tailwind.config.js`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Этап 2: Атом (BentoCard)
|
|
||||||
> Цель: Один идеальный компонент-карточка
|
|
||||||
|
|
||||||
| # | Задача | Статус | Заметки |
|
|
||||||
|---|--------|--------|---------|
|
|
||||||
| 2.1 | Создать BentoCard компонент | `[x]` | С вариантами размеров |
|
|
||||||
| 2.2 | Обновить .card в globals.css | `[x]` | Новые классы .bento-card, .bento-card-hover, .bento-card-glow |
|
|
||||||
|
|
||||||
**Файлы:**
|
|
||||||
- `src/components/ui/BentoCard.tsx` (новый)
|
|
||||||
- `src/styles/globals.css`
|
|
||||||
|
|
||||||
**API компонента:**
|
|
||||||
```tsx
|
|
||||||
type BentoSize = 'sm' | 'md' | 'lg' | 'xl' // 1x1, 2x1, 1x2, 2x2
|
|
||||||
|
|
||||||
interface BentoCardProps {
|
|
||||||
size?: BentoSize
|
|
||||||
children: React.ReactNode
|
|
||||||
className?: string
|
|
||||||
hover?: boolean
|
|
||||||
as?: 'div' | 'Link' | 'button'
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Этап 3: Скелет (Floating TabBar)
|
|
||||||
> Цель: Bottom navigation как "парящий остров"
|
|
||||||
|
|
||||||
| # | Задача | Статус | Заметки |
|
|
||||||
|---|--------|--------|---------|
|
|
||||||
| 3.1 | Floating island стиль | `[x]` | margin 16px от краев, shadow |
|
|
||||||
| 3.2 | Active state с pill | `[x]` | rounded-2xl фон под активной иконкой |
|
|
||||||
| 3.3 | Убрать border-top | `[x]` | Заменено на shadow |
|
|
||||||
|
|
||||||
**Файлы:**
|
|
||||||
- `src/components/layout/Layout.tsx` (секция bottom-nav)
|
|
||||||
- `src/styles/globals.css` (`.bottom-nav` классы)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Этап 4: Мясо (Dashboard Grid)
|
|
||||||
> Цель: Главная страница в стиле Bento
|
|
||||||
|
|
||||||
| # | Задача | Статус | Заметки |
|
|
||||||
|---|--------|--------|---------|
|
|
||||||
| 4.1 | Создать .bento-grid контейнер | `[x]` | CSS Grid с gap, 2 колонки mobile, 4 desktop |
|
|
||||||
| 4.2 | Subscription Status → bento-card | `[x]` | Главная карточка подписки |
|
|
||||||
| 4.3 | Stats Grid → bento-grid + bento-card-hover | `[x]` | Balance, Subscription, Referrals, Earnings |
|
|
||||||
| 4.4 | Quick Actions → bento-card | `[x]` | Контейнер для кнопок |
|
|
||||||
| 4.5 | Trial + Wheel Banner → bento-card-glow/hover | `[x]` | Акцентные карточки |
|
|
||||||
|
|
||||||
**Файлы:**
|
|
||||||
- `src/pages/Dashboard.tsx`
|
|
||||||
- `src/styles/globals.css`
|
|
||||||
|
|
||||||
**Правило:** Логику JS/TS НЕ трогаем — только UI обёртки.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Решения и обоснования
|
|
||||||
|
|
||||||
### Почему Urbanist, а не Inter?
|
|
||||||
- Urbanist более геометричный, лучше подходит под Bento-эстетику
|
|
||||||
- Хорошая читаемость на мобильных
|
|
||||||
- Бесплатный, Google Fonts
|
|
||||||
|
|
||||||
### Почему rounded-3xl (24px)?
|
|
||||||
- Стандарт Bento — крупные скругления
|
|
||||||
- 16px (текущий rounded-2xl) выглядит слишком "приложенечно"
|
|
||||||
- 32px (rounded-4xl) для особо крупных элементов
|
|
||||||
|
|
||||||
### Floating TabBar vs прилипший
|
|
||||||
- Floating выглядит премиально
|
|
||||||
- Отделяет навигацию от контента визуально
|
|
||||||
- Работает с safe-area на iOS
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Что НЕ делаем
|
|
||||||
|
|
||||||
- ✅ ~~Модалки (ConnectionModal, TopUpModal)~~ — Phase 6 complete
|
|
||||||
- ✅ ~~Страницы кроме Dashboard~~ — Phase 5 complete
|
|
||||||
- ✅ ~~Header~~ — Phase 7 complete
|
|
||||||
- ❌ Рефакторинг логики — только UI
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Прогресс
|
|
||||||
|
|
||||||
```
|
|
||||||
Этап 1: ██████████ 100%
|
|
||||||
Этап 2: ██████████ 100%
|
|
||||||
Этап 3: ██████████ 100%
|
|
||||||
Этап 4: ██████████ 100%
|
|
||||||
Этап 5: ██████████ 100%
|
|
||||||
Этап 6: ██████████ 100%
|
|
||||||
Этап 7: ██████████ 100%
|
|
||||||
Этап 8: ██████████ 100%
|
|
||||||
Этап 9: ██████████ 100%
|
|
||||||
─────────────────────
|
|
||||||
Общий: ██████████ 100%
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Заметки для агентов
|
|
||||||
|
|
||||||
**Контекст:** Это Mini App для Telegram (VPN кабинет). Мобильный фокус.
|
|
||||||
|
|
||||||
**Текущий стек:**
|
|
||||||
- React + Vite + TypeScript
|
|
||||||
- Tailwind CSS
|
|
||||||
- React Query для данных
|
|
||||||
|
|
||||||
**Ключевые файлы:**
|
|
||||||
- `tailwind.config.js` — цвета через CSS переменные
|
|
||||||
- `src/styles/globals.css` — компонентные классы (.card, .btn, etc)
|
|
||||||
- `src/components/layout/Layout.tsx` — шапка + таббар
|
|
||||||
- `src/pages/Dashboard.tsx` — главный экран (монстр, но логику не трогаем)
|
|
||||||
|
|
||||||
**Темы:** Есть dark и light (champagne). Bento-стили должны работать в обеих.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Следующие этапы (Phase 2)
|
|
||||||
|
|
||||||
### Этап 5: Остальные страницы пользователя
|
|
||||||
> Применить bento-стили к основным страницам
|
|
||||||
|
|
||||||
| # | Страница | Приоритет | Объём | Статус |
|
|
||||||
|---|----------|-----------|-------|--------|
|
|
||||||
| 5.1 | Subscription.tsx | High | Большая — формы, тарифы, карточки | `[x]` |
|
|
||||||
| 5.2 | Balance.tsx | High | Средняя — баланс, транзакции, методы оплаты | `[x]` |
|
|
||||||
| 5.3 | Referral.tsx | Medium | Средняя — статистика, ссылка, условия | `[x]` |
|
|
||||||
| 5.4 | Support.tsx | Medium | Малая — тикеты | `[x]` |
|
|
||||||
| 5.5 | Profile.tsx | Low | Малая — настройки | `[x]` |
|
|
||||||
| 5.6 | Info.tsx | Low | Малая — статичный контент | `[x]` |
|
|
||||||
|
|
||||||
### Этап 6: Модалки
|
|
||||||
> Обновить модальные окна в bento-стиле
|
|
||||||
|
|
||||||
| # | Компонент | Статус | Заметки |
|
|
||||||
|---|-----------|--------|---------|
|
|
||||||
| 6.1 | ConnectionModal | `[x]` | ✅ z-[60], rounded-3xl, backdrop-blur-xl (desktop + mobile) |
|
|
||||||
| 6.2 | TopUpModal | `[x]` | ✅ z-[60], rounded-3xl, backdrop-blur-xl |
|
|
||||||
| 6.3 | InsufficientBalancePrompt | `[x]` | ✅ z-[60], rounded-3xl, backdrop-blur-xl (PaymentMethodModal тоже) |
|
|
||||||
| 6.4 | Wheel Result Modal | `[x]` | ✅ z-[60] |
|
|
||||||
| 6.5 | Contests Modal | `[x]` | ✅ z-[60], bento-card, backdrop-blur-sm |
|
|
||||||
|
|
||||||
**Выборка модалок (select/close overlap):** Не найдено — все селекты в Subscription.tsx используют кнопки вместо `<select>` элемента.
|
|
||||||
|
|
||||||
### Этап 7: Header
|
|
||||||
> Обновить шапку (опционально)
|
|
||||||
|
|
||||||
| # | Задача | Статус | Заметки |
|
|
||||||
|---|--------|--------|---------|
|
|
||||||
| 7.0 | Смена шрифта Urbanist → Manrope | `[x]` | Google Fonts, tailwind.config.js, globals.css |
|
|
||||||
| 7.1 | Glass container + shadow | `[x]` | `glass shadow-lg shadow-black/10` (без border-b) |
|
|
||||||
| 7.2 | Logo в bento-контейнере | `[x]` | `rounded-xl bg-dark-800/80 border border-dark-700/50` (app icon style) |
|
|
||||||
| 7.3 | Controls в bento-стиле | `[x]` | Все кнопки: `rounded-xl bg-dark-800/50 hover:bg-dark-700 border border-dark-700/50` |
|
|
||||||
| 7.4 | Z-Index = 50 | `[x]` | Подтверждено (выше контента, ниже модалок z-[60]) |
|
|
||||||
|
|
||||||
### Этап 9: Visual Mastery (Final)
|
|
||||||
> Премиум визуальная полировка
|
|
||||||
|
|
||||||
| # | Задача | Статус | Заметки |
|
|
||||||
|---|--------|--------|---------|
|
|
||||||
| 9.1 | Background Noise | `[x]` | SVG fractalNoise, 3% opacity, mix-blend-mode: overlay, z-index: 0 |
|
|
||||||
| 9.2 | Spotlight Effect | `[x]` | `::after` radial-gradient на hover, opacity transition |
|
|
||||||
| 9.3 | BentoSkeleton | `[x]` | Компонент с animate-pulse, поддержка count prop |
|
|
||||||
| 9.4 | Glass Border | `[x]` | `box-shadow: inset 0 1px 0 0 rgba(255,255,255,0.05)` на base, 0.1 на hover |
|
|
||||||
|
|
||||||
### Этап 8: Полировка
|
|
||||||
> Финальные штрихи
|
|
||||||
|
|
||||||
| # | Задача | Статус | Заметки |
|
|
||||||
|---|--------|--------|---------|
|
|
||||||
| 8.1 | Stagger animation для карточек | `[x]` | `bentoFadeIn` keyframe + `--stagger` CSS var (50ms delay каждая) |
|
|
||||||
| 8.2 | Micro-interactions | `[x]` | hover: `translateY(-4px)`, active: `scale(0.98)` |
|
|
||||||
| 8.3 | Кастомный scrollbar | `[x]` | 6px thin, dark-700 thumb, transparent track |
|
|
||||||
| 8.4 | prefers-reduced-motion | `[x]` | Анимации отключаются для accessibility |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Changelog
|
|
||||||
|
|
||||||
### 2026-01-20 — MVP Complete
|
|
||||||
- ✅ Urbanist шрифт
|
|
||||||
- ✅ CSS переменные Bento
|
|
||||||
- ✅ BentoCard компонент
|
|
||||||
- ✅ Floating TabBar
|
|
||||||
- ✅ Dashboard в bento-стиле
|
|
||||||
- ✅ Commit: `bf0bcfb`
|
|
||||||
|
|
||||||
### 2026-01-20 — Subscription.tsx Refactor
|
|
||||||
- ✅ Все секции `card` → `bento-card` (6 шт):
|
|
||||||
- Current Subscription (line 429)
|
|
||||||
- Daily Pause (line 634)
|
|
||||||
- Additional Options (line 733)
|
|
||||||
- My Devices (line 1153)
|
|
||||||
- Tariffs section (line 1223)
|
|
||||||
- Classic mode purchase (line 1925)
|
|
||||||
- ✅ Tariff cards: `bento-card-hover` + `bento-card-glow` для выбранного
|
|
||||||
- ✅ Period selection cards: `bento-card-hover` + `bento-card-glow`
|
|
||||||
- ✅ Traffic selection cards: `bento-card-hover` + `bento-card-glow`
|
|
||||||
- ✅ Исправлен `.bento-grid` — добавлен breakpoint для xs (<375px)
|
|
||||||
|
|
||||||
### 2026-01-20 — Phase 2 Complete (Все страницы пользователя)
|
|
||||||
- ✅ **Balance.tsx**: 4 карточки → `bento-card`, методы оплаты → `bento-card-hover`
|
|
||||||
- ✅ **Referral.tsx**: stats grid → `bento-grid` + `bento-card-hover`, 5 секций → `bento-card`
|
|
||||||
- ✅ **Support.tsx**: 3 карточки → `bento-card`, tickets list items → `rounded-bento`
|
|
||||||
- ✅ **Profile.tsx**: 3 карточки → `bento-card`
|
|
||||||
- ✅ **Info.tsx**: FAQ items, rules, privacy, offer → `bento-card`
|
|
||||||
|
|
||||||
### 2026-01-20 — Phase 9 Complete (Visual Mastery)
|
|
||||||
- ✅ **Background Noise:** SVG `fractalNoise` pattern, 3% opacity, `mix-blend-mode: overlay`
|
|
||||||
- ✅ **Spotlight Effect:** `::after` pseudo-element с `radial-gradient(circle at top, rgba(255,255,255,0.06), transparent 60%)`
|
|
||||||
- ✅ **BentoSkeleton Component:** `src/components/ui/BentoSkeleton.tsx` — `animate-pulse`, supports `count` prop
|
|
||||||
- ✅ **Glass Border:** `box-shadow: inset 0 1px 0 0 rgba(255,255,255,0.05)` (0.1 on hover)
|
|
||||||
- ✅ **Light Theme:** Warm spotlight `rgba(255,253,249,0.8)`, champagne glass borders
|
|
||||||
|
|
||||||
**My Fixes (Inspector Review):**
|
|
||||||
- Fixed `z-index: 9999` → `z-index: 0` on noise overlay (was blocking modals!)
|
|
||||||
- Fixed BentoSkeleton — removed `bento-card` class to avoid entrance animation on skeleton
|
|
||||||
|
|
||||||
### 2026-01-20 — Phase 8 Complete (Полировка)
|
|
||||||
- ✅ **Stagger Animation:** `@keyframes bentoFadeIn` (fade-in + translateY(16px→0))
|
|
||||||
- ✅ **CSS Variable `--stagger`:** Для delay между карточками (50ms increments)
|
|
||||||
- ✅ **Micro-interactions:**
|
|
||||||
- Hover: `translateY(-4px)` + enhanced shadow
|
|
||||||
- Active: `scale(0.98)` + faster transition (150ms)
|
|
||||||
- ✅ **Custom Scrollbar:** 6px thin, dark-700 thumb, transparent track
|
|
||||||
- ✅ **Accessibility:** `prefers-reduced-motion` disables animations
|
|
||||||
- ✅ **Light Theme:** Matching hover/active states for `.light` mode
|
|
||||||
|
|
||||||
**Использование stagger в JSX:**
|
|
||||||
```jsx
|
|
||||||
<div className="bento-card" style={{ '--stagger': 0 }}>Card 1</div>
|
|
||||||
<div className="bento-card" style={{ '--stagger': 1 }}>Card 2</div>
|
|
||||||
<div className="bento-card" style={{ '--stagger': 2 }}>Card 3</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2026-01-20 — Phase 7 Complete (Header + Font)
|
|
||||||
- ✅ **Шрифт:** Urbanist → Manrope (Google Fonts, tailwind.config.js, globals.css)
|
|
||||||
- ✅ **Header Container:** `glass` + `shadow-lg shadow-black/10` (убран border-b)
|
|
||||||
- ✅ **Logo:** App icon style — `rounded-xl bg-dark-800/80 border border-dark-700/50`
|
|
||||||
- ✅ **Theme Toggle:** Bento style — `rounded-xl bg-dark-800/50 hover:bg-dark-700 border border-dark-700/50`
|
|
||||||
- ✅ **Language Switcher:** Bento style — то же
|
|
||||||
- ✅ **Notification Bell:** Bento style — то же
|
|
||||||
- ✅ **Z-Index:** Header = z-50 (верифицировано)
|
|
||||||
- ✅ **ConnectionModal.tsx**: z-[60], rounded-3xl, backdrop-blur-xl (desktop + mobile wrapper)
|
|
||||||
- ✅ **TopUpModal.tsx**: z-[60], rounded-3xl, backdrop-blur-xl
|
|
||||||
- ✅ **InsufficientBalancePrompt.tsx**: z-[60], rounded-3xl, backdrop-blur-xl (PaymentMethodModal тоже)
|
|
||||||
- ✅ **Wheel.tsx**: Result modal z-[60]
|
|
||||||
- ✅ **Contests.tsx**: Game modal z-[60], bento-card, backdrop-blur-sm
|
|
||||||
|
|
||||||
**Z-Index Hierarchy (фиксировано):**
|
|
||||||
- z-[9999]: ConnectionModal mobile view
|
|
||||||
- z-[9998]: ConnectionModal mobile backdrop
|
|
||||||
- z-[100]: Toast, MaintenanceScreen, ChannelSubscriptionScreen
|
|
||||||
- z-[60]: Все модальные окна (TopUpModal, InsufficientBalancePrompt, ConnectionModal desktop, Wheel, Contests)
|
|
||||||
- z-50: Header, dropdowns
|
|
||||||
- z-40: TabBar (bottom nav)
|
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -43,3 +43,6 @@ coverage/
|
|||||||
tmp/
|
tmp/
|
||||||
temp/
|
temp/
|
||||||
miniapp/
|
miniapp/
|
||||||
|
|
||||||
|
# AI & Agents context
|
||||||
|
.ai/
|
||||||
|
|||||||
555
src/components/ThemeBentoPicker.tsx
Normal file
555
src/components/ThemeBentoPicker.tsx
Normal file
@@ -0,0 +1,555 @@
|
|||||||
|
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { COLOR_PRESETS, ColorPreset } from '../data/colorPresets'
|
||||||
|
import { hexToHsl, hslToHex, isValidHex, HSLColor } from '../utils/colorConversion'
|
||||||
|
import { ThemeColors, DEFAULT_THEME_COLORS } from '../types/theme'
|
||||||
|
import { applyThemeColors } from '../hooks/useThemeColors'
|
||||||
|
|
||||||
|
interface ThemeBentoPickerProps {
|
||||||
|
currentColors: ThemeColors
|
||||||
|
onColorsChange: (colors: ThemeColors) => void
|
||||||
|
onSave: () => void
|
||||||
|
isSaving: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const CheckIcon = () => (
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const ChevronDownIcon = () => (
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const MoonIcon = () => (
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const SunIcon = () => (
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const StatusIcon = () => (
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9 5.25h.008v.008H12v-.008z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const PaletteIcon = () => (
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M4.098 19.902a3.75 3.75 0 005.304 0l6.401-6.402M6.75 21A3.75 3.75 0 013 17.25V4.125C3 3.504 3.504 3 4.125 3h5.25c.621 0 1.125.504 1.125 1.125v4.072M6.75 21a3.75 3.75 0 003.75-3.75V8.197M6.75 21h13.125c.621 0 1.125-.504 1.125-1.125v-5.25c0-.621-.504-1.125-1.125-1.125h-4.072M10.5 8.197l2.88-2.88c.438-.439 1.15-.439 1.59 0l3.712 3.713c.44.44.44 1.152 0 1.59l-2.879 2.88M6.75 17.25h.008v.008H6.75v-.008z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
function PresetCard({
|
||||||
|
preset,
|
||||||
|
isSelected,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
preset: ColorPreset
|
||||||
|
isSelected: boolean
|
||||||
|
onClick: () => void
|
||||||
|
}) {
|
||||||
|
const { i18n } = useTranslation()
|
||||||
|
const isRu = i18n.language === 'ru'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className={`relative h-full w-full p-3 text-left transition-all duration-200 group rounded-2xl flex flex-col ${
|
||||||
|
isSelected
|
||||||
|
? 'bg-dark-800/90 border-2 border-accent-500 shadow-lg shadow-accent-500/20 scale-[1.02] z-10'
|
||||||
|
: 'bg-dark-900/60 border border-dark-700/50 hover:bg-dark-800/70 hover:border-dark-600/60 hover:scale-[1.01]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-full h-12 rounded-xl mb-2.5 relative overflow-hidden shrink-0"
|
||||||
|
style={{ backgroundColor: preset.preview.background }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="absolute bottom-1.5 left-1.5 w-6 h-6 rounded-lg shadow-md"
|
||||||
|
style={{ backgroundColor: preset.preview.accent }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="absolute bottom-2.5 right-2 w-10 h-1 rounded-full opacity-60"
|
||||||
|
style={{ backgroundColor: preset.preview.text }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="absolute bottom-5 right-2 w-7 h-1 rounded-full opacity-40"
|
||||||
|
style={{ backgroundColor: preset.preview.text }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<h4 className="text-xs font-semibold text-dark-100 truncate">
|
||||||
|
{isRu ? preset.nameRu : preset.name}
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
{isSelected && (
|
||||||
|
<div className="w-5 h-5 rounded-full bg-accent-500 flex items-center justify-center text-white shrink-0">
|
||||||
|
<CheckIcon />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function HSLSlider({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
max,
|
||||||
|
gradient,
|
||||||
|
suffix = '',
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
value: number
|
||||||
|
onChange: (value: number) => void
|
||||||
|
max: number
|
||||||
|
gradient: string
|
||||||
|
suffix?: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="text-xs font-medium text-dark-300">{label}</label>
|
||||||
|
<span className="text-xs text-dark-500 font-mono tabular-nums">
|
||||||
|
{value}
|
||||||
|
{suffix}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max={max}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(parseInt(e.target.value))}
|
||||||
|
className="w-full h-2.5 rounded-full appearance-none cursor-pointer"
|
||||||
|
style={{ background: gradient }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CompactColorInput({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
onChange: (color: string) => void
|
||||||
|
}) {
|
||||||
|
const [localValue, setLocalValue] = useState(value)
|
||||||
|
const [isEditing, setIsEditing] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLocalValue(value)
|
||||||
|
}, [value])
|
||||||
|
|
||||||
|
const handleChange = (newValue: string) => {
|
||||||
|
let formatted = newValue.toUpperCase()
|
||||||
|
if (!formatted.startsWith('#')) {
|
||||||
|
formatted = '#' + formatted
|
||||||
|
}
|
||||||
|
setLocalValue(formatted)
|
||||||
|
if (isValidHex(formatted)) {
|
||||||
|
onChange(formatted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBlur = () => {
|
||||||
|
setIsEditing(false)
|
||||||
|
if (!isValidHex(localValue)) {
|
||||||
|
setLocalValue(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 p-2 rounded-xl bg-dark-800/40 hover:bg-dark-800/60 transition-colors group">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsEditing(true)}
|
||||||
|
className="w-8 h-8 rounded-lg border border-dark-600/50 shadow-inner shrink-0 transition-transform hover:scale-105"
|
||||||
|
style={{ backgroundColor: value }}
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<label className="text-[10px] uppercase tracking-wide text-dark-500 block leading-none mb-0.5">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
{isEditing ? (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={localValue}
|
||||||
|
onChange={(e) => handleChange(e.target.value)}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleBlur()}
|
||||||
|
autoFocus
|
||||||
|
className="bg-transparent text-xs font-mono text-dark-200 w-full outline-none"
|
||||||
|
maxLength={7}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsEditing(true)}
|
||||||
|
className="text-xs font-mono text-dark-300 hover:text-dark-100 transition-colors text-left"
|
||||||
|
>
|
||||||
|
{value.toUpperCase()}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CollapsibleSection({
|
||||||
|
title,
|
||||||
|
icon,
|
||||||
|
isOpen,
|
||||||
|
onToggle,
|
||||||
|
children,
|
||||||
|
badge,
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
icon: React.ReactNode
|
||||||
|
isOpen: boolean
|
||||||
|
onToggle: () => void
|
||||||
|
children: React.ReactNode
|
||||||
|
badge?: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl bg-dark-900/50 border border-dark-700/40 overflow-hidden">
|
||||||
|
<button
|
||||||
|
onClick={onToggle}
|
||||||
|
className="w-full flex items-center justify-between px-4 py-3 hover:bg-dark-800/30 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className="text-dark-400">{icon}</div>
|
||||||
|
<span className="text-sm font-medium text-dark-200">{title}</span>
|
||||||
|
{badge && (
|
||||||
|
<span className="text-[10px] px-1.5 py-0.5 rounded-md bg-dark-700/50 text-dark-400 font-mono">
|
||||||
|
{badge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`text-dark-500 transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`}
|
||||||
|
>
|
||||||
|
<ChevronDownIcon />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`grid transition-all duration-200 ease-out ${
|
||||||
|
isOpen ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="overflow-hidden">
|
||||||
|
<div className="px-4 pb-4 pt-1 border-t border-dark-700/30">{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ThemeBentoPicker({
|
||||||
|
currentColors,
|
||||||
|
onColorsChange,
|
||||||
|
onSave,
|
||||||
|
isSaving,
|
||||||
|
}: ThemeBentoPickerProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const [hsl, setHsl] = useState<HSLColor>(() => hexToHsl(currentColors.accent))
|
||||||
|
const [hexInput, setHexInput] = useState(currentColors.accent)
|
||||||
|
const [hasChanges, setHasChanges] = useState(false)
|
||||||
|
|
||||||
|
const [isAccentOpen, setIsAccentOpen] = useState(false)
|
||||||
|
const [isDarkOpen, setIsDarkOpen] = useState(false)
|
||||||
|
const [isLightOpen, setIsLightOpen] = useState(false)
|
||||||
|
const [isStatusOpen, setIsStatusOpen] = useState(false)
|
||||||
|
|
||||||
|
const selectedPresetId = useMemo(() => {
|
||||||
|
const match = COLOR_PRESETS.find(
|
||||||
|
(p) =>
|
||||||
|
p.colors.accent.toLowerCase() === currentColors.accent.toLowerCase() &&
|
||||||
|
p.colors.darkBackground.toLowerCase() === currentColors.darkBackground.toLowerCase() &&
|
||||||
|
p.colors.lightBackground.toLowerCase() === currentColors.lightBackground.toLowerCase()
|
||||||
|
)
|
||||||
|
return match?.id ?? null
|
||||||
|
}, [currentColors.accent, currentColors.darkBackground, currentColors.lightBackground])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setHsl(hexToHsl(currentColors.accent))
|
||||||
|
setHexInput(currentColors.accent)
|
||||||
|
}, [currentColors.accent])
|
||||||
|
|
||||||
|
const updateColor = useCallback(
|
||||||
|
(key: keyof ThemeColors, value: string) => {
|
||||||
|
const newColors = { ...currentColors, [key]: value }
|
||||||
|
onColorsChange(newColors)
|
||||||
|
applyThemeColors(newColors)
|
||||||
|
setHasChanges(true)
|
||||||
|
},
|
||||||
|
[currentColors, onColorsChange]
|
||||||
|
)
|
||||||
|
|
||||||
|
const updateAccentFromHsl = useCallback(
|
||||||
|
(newHsl: HSLColor) => {
|
||||||
|
setHsl(newHsl)
|
||||||
|
const newHex = hslToHex(newHsl.h, newHsl.s, newHsl.l)
|
||||||
|
setHexInput(newHex)
|
||||||
|
updateColor('accent', newHex)
|
||||||
|
},
|
||||||
|
[updateColor]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleHexInputChange = (value: string) => {
|
||||||
|
setHexInput(value)
|
||||||
|
if (isValidHex(value)) {
|
||||||
|
const newHsl = hexToHsl(value)
|
||||||
|
setHsl(newHsl)
|
||||||
|
updateColor('accent', value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePresetSelect = (preset: ColorPreset) => {
|
||||||
|
onColorsChange(preset.colors)
|
||||||
|
applyThemeColors(preset.colors)
|
||||||
|
setHasChanges(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const hueGradient = useMemo(() => {
|
||||||
|
return `linear-gradient(to right,
|
||||||
|
hsl(0, ${hsl.s}%, ${hsl.l}%),
|
||||||
|
hsl(60, ${hsl.s}%, ${hsl.l}%),
|
||||||
|
hsl(120, ${hsl.s}%, ${hsl.l}%),
|
||||||
|
hsl(180, ${hsl.s}%, ${hsl.l}%),
|
||||||
|
hsl(240, ${hsl.s}%, ${hsl.l}%),
|
||||||
|
hsl(300, ${hsl.s}%, ${hsl.l}%),
|
||||||
|
hsl(360, ${hsl.s}%, ${hsl.l}%)
|
||||||
|
)`
|
||||||
|
}, [hsl.s, hsl.l])
|
||||||
|
|
||||||
|
const saturationGradient = useMemo(() => {
|
||||||
|
return `linear-gradient(to right,
|
||||||
|
hsl(${hsl.h}, 0%, ${hsl.l}%),
|
||||||
|
hsl(${hsl.h}, 100%, ${hsl.l}%)
|
||||||
|
)`
|
||||||
|
}, [hsl.h, hsl.l])
|
||||||
|
|
||||||
|
const lightnessGradient = useMemo(() => {
|
||||||
|
return `linear-gradient(to right,
|
||||||
|
hsl(${hsl.h}, ${hsl.s}%, 0%),
|
||||||
|
hsl(${hsl.h}, ${hsl.s}%, 50%),
|
||||||
|
hsl(${hsl.h}, ${hsl.s}%, 100%)
|
||||||
|
)`
|
||||||
|
}, [hsl.h, hsl.s])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xs font-medium text-dark-400 uppercase tracking-wide mb-3">
|
||||||
|
{t('admin.theme.quickPresets', 'Quick Presets')}
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 auto-rows-fr gap-4 p-2">
|
||||||
|
{COLOR_PRESETS.map((preset, index) => (
|
||||||
|
<div key={preset.id} className="min-h-[100px]" style={{ '--stagger': index } as React.CSSProperties}>
|
||||||
|
<PresetCard
|
||||||
|
preset={preset}
|
||||||
|
isSelected={selectedPresetId === preset.id}
|
||||||
|
onClick={() => handlePresetSelect(preset)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="text-xs font-medium text-dark-400 uppercase tracking-wide">
|
||||||
|
{t('admin.theme.customizeColors', 'Customize Colors')}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<CollapsibleSection
|
||||||
|
title={t('admin.theme.accentColor', 'Accent Color')}
|
||||||
|
icon={<PaletteIcon />}
|
||||||
|
badge={hexInput.toUpperCase()}
|
||||||
|
isOpen={isAccentOpen}
|
||||||
|
onToggle={() => setIsAccentOpen(!isAccentOpen)}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div
|
||||||
|
className="w-full h-14 rounded-xl shadow-inner relative overflow-hidden"
|
||||||
|
style={{
|
||||||
|
background: `linear-gradient(135deg, ${hexInput} 0%, ${hslToHex(hsl.h, hsl.s, Math.max(20, hsl.l - 20))} 100%)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-black/10 to-transparent" />
|
||||||
|
<div className="absolute bottom-2 right-3 text-white/80 text-xs font-mono drop-shadow">
|
||||||
|
{hexInput.toUpperCase()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<HSLSlider
|
||||||
|
label={t('admin.theme.hue', 'Hue')}
|
||||||
|
value={hsl.h}
|
||||||
|
onChange={(h) => updateAccentFromHsl({ ...hsl, h })}
|
||||||
|
max={360}
|
||||||
|
gradient={hueGradient}
|
||||||
|
suffix="°"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<HSLSlider
|
||||||
|
label={t('admin.theme.saturation', 'Saturation')}
|
||||||
|
value={hsl.s}
|
||||||
|
onChange={(s) => updateAccentFromHsl({ ...hsl, s })}
|
||||||
|
max={100}
|
||||||
|
gradient={saturationGradient}
|
||||||
|
suffix="%"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<HSLSlider
|
||||||
|
label={t('admin.theme.lightness', 'Lightness')}
|
||||||
|
value={hsl.l}
|
||||||
|
onChange={(l) => updateAccentFromHsl({ ...hsl, l })}
|
||||||
|
max={100}
|
||||||
|
gradient={lightnessGradient}
|
||||||
|
suffix="%"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium text-dark-300 mb-1.5 block">
|
||||||
|
{t('admin.theme.hexCode', 'HEX Code')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={hexInput}
|
||||||
|
onChange={(e) => handleHexInputChange(e.target.value)}
|
||||||
|
placeholder="#3b82f6"
|
||||||
|
maxLength={7}
|
||||||
|
className="input w-full text-sm font-mono uppercase"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CollapsibleSection>
|
||||||
|
|
||||||
|
<CollapsibleSection
|
||||||
|
title={t('theme.darkTheme', 'Dark Theme')}
|
||||||
|
icon={<MoonIcon />}
|
||||||
|
isOpen={isDarkOpen}
|
||||||
|
onToggle={() => setIsDarkOpen(!isDarkOpen)}
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<CompactColorInput
|
||||||
|
label={t('theme.background', 'Background')}
|
||||||
|
value={currentColors.darkBackground || DEFAULT_THEME_COLORS.darkBackground}
|
||||||
|
onChange={(c) => updateColor('darkBackground', c)}
|
||||||
|
/>
|
||||||
|
<CompactColorInput
|
||||||
|
label={t('theme.surface', 'Surface')}
|
||||||
|
value={currentColors.darkSurface || DEFAULT_THEME_COLORS.darkSurface}
|
||||||
|
onChange={(c) => updateColor('darkSurface', c)}
|
||||||
|
/>
|
||||||
|
<CompactColorInput
|
||||||
|
label={t('theme.text', 'Text')}
|
||||||
|
value={currentColors.darkText || DEFAULT_THEME_COLORS.darkText}
|
||||||
|
onChange={(c) => updateColor('darkText', c)}
|
||||||
|
/>
|
||||||
|
<CompactColorInput
|
||||||
|
label={t('theme.textSecondary', 'Secondary')}
|
||||||
|
value={currentColors.darkTextSecondary || DEFAULT_THEME_COLORS.darkTextSecondary}
|
||||||
|
onChange={(c) => updateColor('darkTextSecondary', c)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CollapsibleSection>
|
||||||
|
|
||||||
|
<CollapsibleSection
|
||||||
|
title={t('theme.lightTheme', 'Light Theme')}
|
||||||
|
icon={<SunIcon />}
|
||||||
|
isOpen={isLightOpen}
|
||||||
|
onToggle={() => setIsLightOpen(!isLightOpen)}
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<CompactColorInput
|
||||||
|
label={t('theme.background', 'Background')}
|
||||||
|
value={currentColors.lightBackground || DEFAULT_THEME_COLORS.lightBackground}
|
||||||
|
onChange={(c) => updateColor('lightBackground', c)}
|
||||||
|
/>
|
||||||
|
<CompactColorInput
|
||||||
|
label={t('theme.surface', 'Surface')}
|
||||||
|
value={currentColors.lightSurface || DEFAULT_THEME_COLORS.lightSurface}
|
||||||
|
onChange={(c) => updateColor('lightSurface', c)}
|
||||||
|
/>
|
||||||
|
<CompactColorInput
|
||||||
|
label={t('theme.text', 'Text')}
|
||||||
|
value={currentColors.lightText || DEFAULT_THEME_COLORS.lightText}
|
||||||
|
onChange={(c) => updateColor('lightText', c)}
|
||||||
|
/>
|
||||||
|
<CompactColorInput
|
||||||
|
label={t('theme.textSecondary', 'Secondary')}
|
||||||
|
value={currentColors.lightTextSecondary || DEFAULT_THEME_COLORS.lightTextSecondary}
|
||||||
|
onChange={(c) => updateColor('lightTextSecondary', c)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CollapsibleSection>
|
||||||
|
|
||||||
|
<CollapsibleSection
|
||||||
|
title={t('theme.statusColors', 'Status Colors')}
|
||||||
|
icon={<StatusIcon />}
|
||||||
|
isOpen={isStatusOpen}
|
||||||
|
onToggle={() => setIsStatusOpen(!isStatusOpen)}
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
<CompactColorInput
|
||||||
|
label={t('theme.success', 'Success')}
|
||||||
|
value={currentColors.success || DEFAULT_THEME_COLORS.success}
|
||||||
|
onChange={(c) => updateColor('success', c)}
|
||||||
|
/>
|
||||||
|
<CompactColorInput
|
||||||
|
label={t('theme.warning', 'Warning')}
|
||||||
|
value={currentColors.warning || DEFAULT_THEME_COLORS.warning}
|
||||||
|
onChange={(c) => updateColor('warning', c)}
|
||||||
|
/>
|
||||||
|
<CompactColorInput
|
||||||
|
label={t('theme.error', 'Error')}
|
||||||
|
value={currentColors.error || DEFAULT_THEME_COLORS.error}
|
||||||
|
onChange={(c) => updateColor('error', c)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CollapsibleSection>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl bg-dark-900/50 border border-dark-700/40 p-4">
|
||||||
|
<h4 className="text-xs font-medium text-dark-400 uppercase tracking-wide mb-3">
|
||||||
|
{t('theme.preview', 'Preview')}
|
||||||
|
</h4>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<button className="btn-primary text-sm">{t('theme.previewButton', 'Primary')}</button>
|
||||||
|
<button className="btn-secondary text-sm">
|
||||||
|
{t('theme.previewSecondary', 'Secondary')}
|
||||||
|
</button>
|
||||||
|
<span className="badge-success">{t('theme.success', 'Success')}</span>
|
||||||
|
<span className="badge-warning">{t('theme.warning', 'Warning')}</span>
|
||||||
|
<span className="badge-error">{t('theme.error', 'Error')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasChanges && (
|
||||||
|
<div className="flex justify-end animate-fade-in">
|
||||||
|
<button onClick={onSave} disabled={isSaving} className="btn-primary">
|
||||||
|
{isSaving ? t('common.saving', 'Saving...') : t('common.save', 'Save')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
179
src/data/colorPresets.ts
Normal file
179
src/data/colorPresets.ts
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
import { ThemeColors } from '../types/theme'
|
||||||
|
|
||||||
|
export interface ColorPreset {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
nameRu: string
|
||||||
|
description: string
|
||||||
|
descriptionRu: string
|
||||||
|
colors: ThemeColors
|
||||||
|
// Preview colors for the card
|
||||||
|
preview: {
|
||||||
|
background: string
|
||||||
|
accent: string
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const COLOR_PRESETS: ColorPreset[] = [
|
||||||
|
{
|
||||||
|
id: 'electric-blue',
|
||||||
|
name: 'Electric Blue',
|
||||||
|
nameRu: 'Электрик',
|
||||||
|
description: 'Classic tech blue, reliable and clean',
|
||||||
|
descriptionRu: 'Классический технологичный синий',
|
||||||
|
colors: {
|
||||||
|
accent: '#3b82f6',
|
||||||
|
darkBackground: '#0a0f1a',
|
||||||
|
darkSurface: '#0f172a',
|
||||||
|
darkText: '#f1f5f9',
|
||||||
|
darkTextSecondary: '#94a3b8',
|
||||||
|
lightBackground: '#f8fafc',
|
||||||
|
lightSurface: '#ffffff',
|
||||||
|
lightText: '#0f172a',
|
||||||
|
lightTextSecondary: '#64748b',
|
||||||
|
success: '#22c55e',
|
||||||
|
warning: '#f59e0b',
|
||||||
|
error: '#ef4444',
|
||||||
|
},
|
||||||
|
preview: {
|
||||||
|
background: '#0a0f1a',
|
||||||
|
accent: '#3b82f6',
|
||||||
|
text: '#f1f5f9',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'toxic-neon',
|
||||||
|
name: 'Toxic Neon',
|
||||||
|
nameRu: 'Токсичный неон',
|
||||||
|
description: 'Cyberpunk vibes, high energy',
|
||||||
|
descriptionRu: 'Киберпанк атмосфера, высокая энергия',
|
||||||
|
colors: {
|
||||||
|
accent: '#22c55e',
|
||||||
|
darkBackground: '#030712',
|
||||||
|
darkSurface: '#0a0f14',
|
||||||
|
darkText: '#e2e8f0',
|
||||||
|
darkTextSecondary: '#64748b',
|
||||||
|
lightBackground: '#f0fdf4',
|
||||||
|
lightSurface: '#ffffff',
|
||||||
|
lightText: '#052e16',
|
||||||
|
lightTextSecondary: '#166534',
|
||||||
|
success: '#22c55e',
|
||||||
|
warning: '#eab308',
|
||||||
|
error: '#ef4444',
|
||||||
|
},
|
||||||
|
preview: {
|
||||||
|
background: '#030712',
|
||||||
|
accent: '#22c55e',
|
||||||
|
text: '#e2e8f0',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'royal-purple',
|
||||||
|
name: 'Royal Purple',
|
||||||
|
nameRu: 'Королевский пурпур',
|
||||||
|
description: 'Premium, sophisticated, Stripe-like',
|
||||||
|
descriptionRu: 'Премиальный, утончённый, как Stripe',
|
||||||
|
colors: {
|
||||||
|
accent: '#8b5cf6',
|
||||||
|
darkBackground: '#0c0a14',
|
||||||
|
darkSurface: '#13111c',
|
||||||
|
darkText: '#f1f0f5',
|
||||||
|
darkTextSecondary: '#a1a1aa',
|
||||||
|
lightBackground: '#faf5ff',
|
||||||
|
lightSurface: '#ffffff',
|
||||||
|
lightText: '#1e1b29',
|
||||||
|
lightTextSecondary: '#6b21a8',
|
||||||
|
success: '#22c55e',
|
||||||
|
warning: '#f59e0b',
|
||||||
|
error: '#ef4444',
|
||||||
|
},
|
||||||
|
preview: {
|
||||||
|
background: '#0c0a14',
|
||||||
|
accent: '#8b5cf6',
|
||||||
|
text: '#f1f0f5',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sunset-orange',
|
||||||
|
name: 'Sunset Orange',
|
||||||
|
nameRu: 'Закатный оранж',
|
||||||
|
description: 'Warm, energetic, action-oriented',
|
||||||
|
descriptionRu: 'Тёплый, энергичный, призыв к действию',
|
||||||
|
colors: {
|
||||||
|
accent: '#f97316',
|
||||||
|
darkBackground: '#0f0906',
|
||||||
|
darkSurface: '#1a120d',
|
||||||
|
darkText: '#fef3e2',
|
||||||
|
darkTextSecondary: '#a3a3a3',
|
||||||
|
lightBackground: '#fff7ed',
|
||||||
|
lightSurface: '#ffffff',
|
||||||
|
lightText: '#1c1917',
|
||||||
|
lightTextSecondary: '#c2410c',
|
||||||
|
success: '#22c55e',
|
||||||
|
warning: '#f59e0b',
|
||||||
|
error: '#ef4444',
|
||||||
|
},
|
||||||
|
preview: {
|
||||||
|
background: '#0f0906',
|
||||||
|
accent: '#f97316',
|
||||||
|
text: '#fef3e2',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ocean-teal',
|
||||||
|
name: 'Ocean Teal',
|
||||||
|
nameRu: 'Океанский бирюзовый',
|
||||||
|
description: 'Calm, trustworthy, health-tech',
|
||||||
|
descriptionRu: 'Спокойный, надёжный, медтех',
|
||||||
|
colors: {
|
||||||
|
accent: '#14b8a6',
|
||||||
|
darkBackground: '#042f2e',
|
||||||
|
darkSurface: '#0d3d3b',
|
||||||
|
darkText: '#f0fdfa',
|
||||||
|
darkTextSecondary: '#5eead4',
|
||||||
|
lightBackground: '#f0fdfa',
|
||||||
|
lightSurface: '#ffffff',
|
||||||
|
lightText: '#134e4a',
|
||||||
|
lightTextSecondary: '#0f766e',
|
||||||
|
success: '#22c55e',
|
||||||
|
warning: '#f59e0b',
|
||||||
|
error: '#ef4444',
|
||||||
|
},
|
||||||
|
preview: {
|
||||||
|
background: '#042f2e',
|
||||||
|
accent: '#14b8a6',
|
||||||
|
text: '#f0fdfa',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'champagne-gold',
|
||||||
|
name: 'Champagne Gold',
|
||||||
|
nameRu: 'Шампанское золото',
|
||||||
|
description: 'Luxury, premium, elegant',
|
||||||
|
descriptionRu: 'Роскошный, премиальный, элегантный',
|
||||||
|
colors: {
|
||||||
|
accent: '#b8860b',
|
||||||
|
darkBackground: '#0a0f1a',
|
||||||
|
darkSurface: '#0f172a',
|
||||||
|
darkText: '#f1f5f9',
|
||||||
|
darkTextSecondary: '#94a3b8',
|
||||||
|
lightBackground: '#F7E7CE',
|
||||||
|
lightSurface: '#FEF9F0',
|
||||||
|
lightText: '#1F1A12',
|
||||||
|
lightTextSecondary: '#7D6B48',
|
||||||
|
success: '#22c55e',
|
||||||
|
warning: '#f59e0b',
|
||||||
|
error: '#ef4444',
|
||||||
|
},
|
||||||
|
preview: {
|
||||||
|
background: '#F7E7CE',
|
||||||
|
accent: '#b8860b',
|
||||||
|
text: '#1F1A12',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export function getPresetById(id: string): ColorPreset | undefined {
|
||||||
|
return COLOR_PRESETS.find((preset) => preset.id === id)
|
||||||
|
}
|
||||||
@@ -7,8 +7,8 @@ import { brandingApi, setCachedBranding } from '../api/branding'
|
|||||||
import { setCachedAnimationEnabled } from '../components/AnimatedBackground'
|
import { setCachedAnimationEnabled } from '../components/AnimatedBackground'
|
||||||
import { setCachedFullscreenEnabled } from '../hooks/useTelegramWebApp'
|
import { setCachedFullscreenEnabled } from '../hooks/useTelegramWebApp'
|
||||||
import { themeColorsApi } from '../api/themeColors'
|
import { themeColorsApi } from '../api/themeColors'
|
||||||
import { DEFAULT_THEME_COLORS, DEFAULT_ENABLED_THEMES } from '../types/theme'
|
import { DEFAULT_THEME_COLORS, DEFAULT_ENABLED_THEMES, ThemeColors } from '../types/theme'
|
||||||
import { ColorPicker } from '../components/ColorPicker'
|
import { ThemeBentoPicker } from '../components/ThemeBentoPicker'
|
||||||
import { applyThemeColors } from '../hooks/useThemeColors'
|
import { applyThemeColors } from '../hooks/useThemeColors'
|
||||||
import { updateEnabledThemesCache } from '../hooks/useTheme'
|
import { updateEnabledThemesCache } from '../hooks/useTheme'
|
||||||
|
|
||||||
@@ -858,6 +858,7 @@ export default function AdminSettings() {
|
|||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
const [editingName, setEditingName] = useState(false)
|
const [editingName, setEditingName] = useState(false)
|
||||||
const [newName, setNewName] = useState('')
|
const [newName, setNewName] = useState('')
|
||||||
|
const [localColors, setLocalColors] = useState<ThemeColors | null>(null)
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
// Branding query and mutations
|
// Branding query and mutations
|
||||||
@@ -1355,113 +1356,18 @@ export default function AdminSettings() {
|
|||||||
Включите нужные темы для пользователей. Минимум одна тема должна быть активна.
|
Включите нужные темы для пользователей. Минимум одна тема должна быть активна.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{/* Accent Color */}
|
|
||||||
<ColorPicker
|
<ThemeBentoPicker
|
||||||
label={t('theme.accent')}
|
currentColors={localColors || themeColors || DEFAULT_THEME_COLORS}
|
||||||
description={t('theme.accentDescription')}
|
onColorsChange={(colors) => setLocalColors(colors)}
|
||||||
value={themeColors?.accent || DEFAULT_THEME_COLORS.accent}
|
onSave={() => {
|
||||||
onChange={(color) => updateColorsMutation.mutate({ accent: color })}
|
if (localColors) {
|
||||||
disabled={updateColorsMutation.isPending}
|
updateColorsMutation.mutate(localColors)
|
||||||
|
setLocalColors(null)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
isSaving={updateColorsMutation.isPending}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Dark Theme Section */}
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-medium text-dark-300 mb-3">{t('theme.darkTheme')}</h3>
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
||||||
<ColorPicker
|
|
||||||
label={t('theme.background')}
|
|
||||||
value={themeColors?.darkBackground || DEFAULT_THEME_COLORS.darkBackground}
|
|
||||||
onChange={(color) => updateColorsMutation.mutate({ darkBackground: color })}
|
|
||||||
disabled={updateColorsMutation.isPending}
|
|
||||||
/>
|
|
||||||
<ColorPicker
|
|
||||||
label={t('theme.surface')}
|
|
||||||
value={themeColors?.darkSurface || DEFAULT_THEME_COLORS.darkSurface}
|
|
||||||
onChange={(color) => updateColorsMutation.mutate({ darkSurface: color })}
|
|
||||||
disabled={updateColorsMutation.isPending}
|
|
||||||
/>
|
|
||||||
<ColorPicker
|
|
||||||
label={t('theme.text')}
|
|
||||||
value={themeColors?.darkText || DEFAULT_THEME_COLORS.darkText}
|
|
||||||
onChange={(color) => updateColorsMutation.mutate({ darkText: color })}
|
|
||||||
disabled={updateColorsMutation.isPending}
|
|
||||||
/>
|
|
||||||
<ColorPicker
|
|
||||||
label={t('theme.textSecondary')}
|
|
||||||
value={themeColors?.darkTextSecondary || DEFAULT_THEME_COLORS.darkTextSecondary}
|
|
||||||
onChange={(color) => updateColorsMutation.mutate({ darkTextSecondary: color })}
|
|
||||||
disabled={updateColorsMutation.isPending}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Light Theme Section */}
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-medium text-dark-300 mb-3">{t('theme.lightTheme')}</h3>
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
||||||
<ColorPicker
|
|
||||||
label={t('theme.background')}
|
|
||||||
value={themeColors?.lightBackground || DEFAULT_THEME_COLORS.lightBackground}
|
|
||||||
onChange={(color) => updateColorsMutation.mutate({ lightBackground: color })}
|
|
||||||
disabled={updateColorsMutation.isPending}
|
|
||||||
/>
|
|
||||||
<ColorPicker
|
|
||||||
label={t('theme.surface')}
|
|
||||||
value={themeColors?.lightSurface || DEFAULT_THEME_COLORS.lightSurface}
|
|
||||||
onChange={(color) => updateColorsMutation.mutate({ lightSurface: color })}
|
|
||||||
disabled={updateColorsMutation.isPending}
|
|
||||||
/>
|
|
||||||
<ColorPicker
|
|
||||||
label={t('theme.text')}
|
|
||||||
value={themeColors?.lightText || DEFAULT_THEME_COLORS.lightText}
|
|
||||||
onChange={(color) => updateColorsMutation.mutate({ lightText: color })}
|
|
||||||
disabled={updateColorsMutation.isPending}
|
|
||||||
/>
|
|
||||||
<ColorPicker
|
|
||||||
label={t('theme.textSecondary')}
|
|
||||||
value={themeColors?.lightTextSecondary || DEFAULT_THEME_COLORS.lightTextSecondary}
|
|
||||||
onChange={(color) => updateColorsMutation.mutate({ lightTextSecondary: color })}
|
|
||||||
disabled={updateColorsMutation.isPending}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Status Colors */}
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-medium text-dark-300 mb-3">{t('theme.statusColors')}</h3>
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
|
||||||
<ColorPicker
|
|
||||||
label={t('theme.success')}
|
|
||||||
value={themeColors?.success || DEFAULT_THEME_COLORS.success}
|
|
||||||
onChange={(color) => updateColorsMutation.mutate({ success: color })}
|
|
||||||
disabled={updateColorsMutation.isPending}
|
|
||||||
/>
|
|
||||||
<ColorPicker
|
|
||||||
label={t('theme.warning')}
|
|
||||||
value={themeColors?.warning || DEFAULT_THEME_COLORS.warning}
|
|
||||||
onChange={(color) => updateColorsMutation.mutate({ warning: color })}
|
|
||||||
disabled={updateColorsMutation.isPending}
|
|
||||||
/>
|
|
||||||
<ColorPicker
|
|
||||||
label={t('theme.error')}
|
|
||||||
value={themeColors?.error || DEFAULT_THEME_COLORS.error}
|
|
||||||
onChange={(color) => updateColorsMutation.mutate({ error: color })}
|
|
||||||
disabled={updateColorsMutation.isPending}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Preview */}
|
|
||||||
<div className="p-4 bg-dark-800/50 rounded-xl">
|
|
||||||
<h4 className="text-sm font-medium text-dark-300 mb-3">{t('theme.preview')}</h4>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<button className="btn-primary text-sm">{t('theme.previewButton')}</button>
|
|
||||||
<button className="btn-secondary text-sm">{t('theme.previewSecondary')}</button>
|
|
||||||
<span className="badge-success">{t('theme.success')}</span>
|
|
||||||
<span className="badge-warning">{t('theme.warning')}</span>
|
|
||||||
<span className="badge-error">{t('theme.error')}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
102
src/utils/colorConversion.ts
Normal file
102
src/utils/colorConversion.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
export interface HSLColor {
|
||||||
|
h: number
|
||||||
|
s: number
|
||||||
|
l: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RGBColor {
|
||||||
|
r: number
|
||||||
|
g: number
|
||||||
|
b: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hexToRgb(hex: string): RGBColor {
|
||||||
|
if (hex.length === 4) {
|
||||||
|
hex = '#' + hex[1] + hex[1] + hex[2] + hex[2] + hex[3] + hex[3]
|
||||||
|
}
|
||||||
|
const r = parseInt(hex.slice(1, 3), 16)
|
||||||
|
const g = parseInt(hex.slice(3, 5), 16)
|
||||||
|
const b = parseInt(hex.slice(5, 7), 16)
|
||||||
|
return { r, g, b }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rgbToHex(r: number, g: number, b: number): string {
|
||||||
|
return (
|
||||||
|
'#' +
|
||||||
|
[r, g, b]
|
||||||
|
.map((x) => {
|
||||||
|
const hex = Math.round(Math.max(0, Math.min(255, x))).toString(16)
|
||||||
|
return hex.length === 1 ? '0' + hex : hex
|
||||||
|
})
|
||||||
|
.join('')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hexToHsl(hex: string): HSLColor {
|
||||||
|
const { r, g, b } = hexToRgb(hex)
|
||||||
|
const rNorm = r / 255
|
||||||
|
const gNorm = g / 255
|
||||||
|
const bNorm = b / 255
|
||||||
|
|
||||||
|
const max = Math.max(rNorm, gNorm, bNorm)
|
||||||
|
const min = Math.min(rNorm, gNorm, bNorm)
|
||||||
|
let h = 0
|
||||||
|
let s = 0
|
||||||
|
const l = (max + min) / 2
|
||||||
|
|
||||||
|
if (max !== min) {
|
||||||
|
const d = max - min
|
||||||
|
s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
|
||||||
|
|
||||||
|
switch (max) {
|
||||||
|
case rNorm:
|
||||||
|
h = ((gNorm - bNorm) / d + (gNorm < bNorm ? 6 : 0)) / 6
|
||||||
|
break
|
||||||
|
case gNorm:
|
||||||
|
h = ((bNorm - rNorm) / d + 2) / 6
|
||||||
|
break
|
||||||
|
case bNorm:
|
||||||
|
h = ((rNorm - gNorm) / d + 4) / 6
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
h: Math.round(h * 360),
|
||||||
|
s: Math.round(s * 100),
|
||||||
|
l: Math.round(l * 100),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hslToRgb(h: number, s: number, l: number): RGBColor {
|
||||||
|
const sNorm = s / 100
|
||||||
|
const lNorm = l / 100
|
||||||
|
|
||||||
|
const a = sNorm * Math.min(lNorm, 1 - lNorm)
|
||||||
|
const f = (n: number) => {
|
||||||
|
const k = (n + h / 30) % 12
|
||||||
|
const color = lNorm - a * Math.max(Math.min(k - 3, 9 - k, 1), -1)
|
||||||
|
return Math.round(255 * color)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { r: f(0), g: f(8), b: f(4) }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hslToHex(h: number, s: number, l: number): string {
|
||||||
|
const { r, g, b } = hslToRgb(h, s, l)
|
||||||
|
return rgbToHex(r, g, b)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValidHex(hex: string): boolean {
|
||||||
|
return /^#[0-9A-Fa-f]{6}$/.test(hex)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeHex(hex: string): string {
|
||||||
|
if (!hex.startsWith('#')) {
|
||||||
|
hex = '#' + hex
|
||||||
|
}
|
||||||
|
if (hex.length === 4) {
|
||||||
|
hex = '#' + hex[1] + hex[1] + hex[2] + hex[2] + hex[3] + hex[3]
|
||||||
|
}
|
||||||
|
return hex.toLowerCase()
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user