Files
links/frontend/linktree-frontend/src/app/components/CustomizationPanel.tsx
Andrey K. Choi 2e535513b5 + Приведены все функции приложения в рабочий вид
+ Наведен порядок в файлах проекта
+ Наведен порядок в документации
+ Настроены скрипты установки, развертки и так далее, расширен MakeFile
2025-11-02 06:09:55 +09:00

672 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client'
import React, { useState, useEffect } from 'react'
interface DesignSettings {
id?: number
theme_color: string
background_image?: string
background_image_url?: string
dashboard_layout: 'sidebar' | 'grid' | 'list' | 'cards' | 'compact' | 'masonry' | 'timeline' | 'magazine'
groups_default_expanded: boolean
show_group_icons: boolean
show_link_icons: boolean
dashboard_background_color: string
font_family: string
custom_css: string
group_text_color?: string
link_text_color?: string
header_text_color?: string
cover_overlay_enabled?: boolean
cover_overlay_color?: string
cover_overlay_opacity?: number
}
interface CustomizationPanelProps {
isOpen: boolean
onClose: () => void
onSettingsUpdate: (settings: DesignSettings) => void
}
export function CustomizationPanel({ isOpen, onClose, onSettingsUpdate }: CustomizationPanelProps) {
const [settings, setSettings] = useState<DesignSettings>({
theme_color: '#ffffff',
dashboard_layout: 'list',
groups_default_expanded: true,
show_group_icons: true,
show_link_icons: true,
dashboard_background_color: '#f8f9fa',
font_family: 'sans-serif',
custom_css: '',
group_text_color: '#333333',
link_text_color: '#666666',
header_text_color: '#000000'
})
const [loading, setLoading] = useState(false)
const [activeTab, setActiveTab] = useState<'layout' | 'colors' | 'groups' | 'advanced'>('layout')
const [backgroundImageFile, setBackgroundImageFile] = useState<File | null>(null)
useEffect(() => {
if (isOpen) {
loadSettings()
}
}, [isOpen])
const loadSettings = async () => {
try {
const token = localStorage.getItem('token')
const API = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'
const response = await fetch(`${API}/api/customization/settings/`, {
headers: {
'Authorization': `Bearer ${token}`
}
})
if (response.ok) {
const data = await response.json()
setSettings(data)
}
} catch (error) {
console.error('Error loading settings:', error)
}
}
const saveSettings = async () => {
setLoading(true)
try {
const token = localStorage.getItem('token')
const API = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'
// Если есть новый файл фоновой картинки, отправляем через FormData
if (backgroundImageFile) {
const formData = new FormData()
// Добавляем все настройки
formData.append('theme_color', settings.theme_color)
formData.append('dashboard_layout', settings.dashboard_layout)
formData.append('groups_default_expanded', settings.groups_default_expanded.toString())
formData.append('show_group_icons', settings.show_group_icons.toString())
formData.append('show_link_icons', settings.show_link_icons.toString())
formData.append('dashboard_background_color', settings.dashboard_background_color)
formData.append('font_family', settings.font_family)
formData.append('custom_css', settings.custom_css)
formData.append('header_text_color', settings.header_text_color || '#000000')
formData.append('group_text_color', settings.group_text_color || '#333333')
formData.append('link_text_color', settings.link_text_color || '#666666')
formData.append('cover_overlay_enabled', (settings.cover_overlay_enabled || false).toString())
formData.append('cover_overlay_color', settings.cover_overlay_color || '#000000')
formData.append('cover_overlay_opacity', (settings.cover_overlay_opacity || 0.3).toString())
formData.append('background_image', backgroundImageFile)
const response = await fetch(`${API}/api/customization/settings/`, {
method: 'PUT',
headers: {
'Authorization': `Bearer ${token}`
},
body: formData
})
if (response.ok) {
const updatedSettings = await response.json()
onSettingsUpdate(updatedSettings)
setBackgroundImageFile(null) // Сбрасываем выбранный файл
onClose()
} else {
const errorData = await response.json()
console.error('Server error:', errorData)
}
} else {
// Если файл не выбран, отправляем только JSON настройки (картинка остается прежней)
const editableSettings = {
theme_color: settings.theme_color,
dashboard_layout: settings.dashboard_layout,
groups_default_expanded: settings.groups_default_expanded,
show_group_icons: settings.show_group_icons,
show_link_icons: settings.show_link_icons,
dashboard_background_color: settings.dashboard_background_color,
font_family: settings.font_family,
custom_css: settings.custom_css,
header_text_color: settings.header_text_color || '#000000',
header_text_color: settings.header_text_color || '#000000',
group_text_color: settings.group_text_color || '#333333',
link_text_color: settings.link_text_color || '#666666',
cover_overlay_enabled: settings.cover_overlay_enabled || false,
cover_overlay_color: settings.cover_overlay_color || '#000000',
cover_overlay_opacity: settings.cover_overlay_opacity || 0.3
}
const response = await fetch(`${API}/api/customization/settings/`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(editableSettings)
})
if (response.ok) {
const updatedSettings = await response.json()
onSettingsUpdate(updatedSettings)
onClose()
} else {
const errorData = await response.json()
console.error('Server error:', errorData)
}
}
} catch (error) {
console.error('Error saving settings:', error)
} finally {
setLoading(false)
}
}
const handleChange = (field: keyof DesignSettings, value: any) => {
setSettings(prev => ({
...prev,
[field]: value
}))
}
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-palette me-2"></i>
Настройки дашборда
</h5>
<button
type="button"
className="btn-close"
onClick={onClose}
></button>
</div>
<div className="modal-body">
{/* Вкладки */}
<ul className="nav nav-tabs mb-3">
<li className="nav-item">
<button
className={`nav-link ${activeTab === 'layout' ? 'active' : ''}`}
onClick={() => setActiveTab('layout')}
>
<i className="bi bi-layout-sidebar me-1"></i>
Макет
</button>
</li>
<li className="nav-item">
<button
className={`nav-link ${activeTab === 'colors' ? 'active' : ''}`}
onClick={() => setActiveTab('colors')}
>
<i className="bi bi-palette-fill me-1"></i>
Цвета
</button>
</li>
<li className="nav-item">
<button
className={`nav-link ${activeTab === 'groups' ? 'active' : ''}`}
onClick={() => setActiveTab('groups')}
>
<i className="bi bi-collection me-1"></i>
Группы
</button>
</li>
<li className="nav-item">
<button
className={`nav-link ${activeTab === 'advanced' ? 'active' : ''}`}
onClick={() => setActiveTab('advanced')}
>
<i className="bi bi-gear me-1"></i>
Дополнительно
</button>
</li>
</ul>
{/* Содержимое вкладок */}
<div className="tab-content">
{/* Вкладка: Макет */}
{activeTab === 'layout' && (
<div className="tab-pane fade show active">
<div className="row">
<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>
Стиль отображения групп и ссылок
</label>
<div className="row g-3">
{[
{
value: 'list',
label: 'Список',
icon: 'bi-list-ul',
description: 'Классический вертикальный список'
},
{
value: 'grid',
label: 'Сетка',
icon: 'bi-grid-3x3',
description: 'Равномерная сетка карточек'
},
{
value: 'cards',
label: 'Карточки',
icon: 'bi-card-heading',
description: 'Большие информативные карточки'
},
{
value: 'compact',
label: 'Компактный',
icon: 'bi-layout-text-sidebar',
description: 'Компактное отображение без отступов'
},
{
value: 'sidebar',
label: 'Боковая панель',
icon: 'bi-layout-sidebar',
description: 'Навигация в боковой панели'
},
{
value: 'masonry',
label: 'Кладка',
icon: 'bi-bricks',
description: 'Динамическая сетка разной высоты'
},
{
value: 'timeline',
label: 'Лента времени',
icon: 'bi-clock-history',
description: 'Хронологическое отображение'
},
{
value: 'magazine',
label: 'Журнальный',
icon: 'bi-newspaper',
description: 'Стиль журнала с крупными изображениями'
}
].map((layout) => (
<div key={layout.value} className="col-md-6 col-lg-4">
<div
className={`card text-center h-100 layout-option ${settings.dashboard_layout === layout.value ? 'border-primary bg-primary bg-opacity-10 selected' : 'border-secondary'}`}
style={{ cursor: 'pointer', transition: 'all 0.2s ease' }}
onClick={() => handleChange('dashboard_layout', layout.value)}
>
<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>
{settings.dashboard_layout === layout.value && (
<div className="mt-2">
<span className="badge bg-primary">
<i className="bi bi-check-lg me-1"></i>
Выбрано
</span>
</div>
)}
</div>
</div>
</div>
))}
</div>
</div>
<div className="col-12 mb-3">
<div className="alert alert-info">
<i className="bi bi-info-circle me-2"></i>
<strong>Совет:</strong> Попробуйте разные макеты, чтобы найти наиболее подходящий для вашего контента.
Каждый стиль имеет свои преимущества в зависимости от количества ссылок и их типа.
</div>
</div>
</div>
</div>
)}
{/* Вкладка: Цвета */}
{activeTab === 'colors' && (
<div className="tab-pane fade show active">
<div className="row">
<div className="col-md-6 mb-3">
<label className="form-label">Основной цвет темы</label>
<div className="input-group">
<input
type="color"
className="form-control form-control-color"
value={settings.theme_color}
onChange={(e) => handleChange('theme_color', e.target.value)}
/>
<input
type="text"
className="form-control"
value={settings.theme_color}
onChange={(e) => handleChange('theme_color', e.target.value)}
/>
</div>
</div>
<div className="col-md-6 mb-3">
<label className="form-label">Цвет фона дашборда</label>
<div className="input-group">
<input
type="color"
className="form-control form-control-color"
value={settings.dashboard_background_color}
onChange={(e) => handleChange('dashboard_background_color', e.target.value)}
/>
<input
type="text"
className="form-control"
value={settings.dashboard_background_color}
onChange={(e) => handleChange('dashboard_background_color', e.target.value)}
/>
</div>
</div>
<div className="col-12 mb-3">
<label className="form-label">Фоновое изображение</label>
<div className="mb-2">
<input
type="file"
className="form-control"
accept="image/*"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) {
setBackgroundImageFile(file)
}
}}
/>
<div className="form-text">
Выберите изображение для фона (JPG, PNG, GIF). Если не выбрано - текущее изображение останется без изменений.
</div>
</div>
{settings.background_image_url && (
<div className="mb-2">
<label className="form-label small">Текущее изображение:</label>
<div>
<img
src={settings.background_image_url}
alt="Текущий фон"
className="img-thumbnail"
style={{ maxWidth: '200px', maxHeight: '100px', objectFit: 'cover' }}
/>
</div>
</div>
)}
{backgroundImageFile && (
<div className="mb-2">
<label className="form-label small">Новое изображение (будет применено после сохранения):</label>
<div className="text-success">
<i className="bi bi-file-earmark-image me-1"></i>
{backgroundImageFile.name}
</div>
</div>
)}
</div>
<div className="col-md-4 mb-3">
<label className="form-label">Цвет заголовков</label>
<div className="input-group">
<input
type="color"
className="form-control form-control-color"
value={settings.header_text_color || '#000000'}
onChange={(e) => handleChange('header_text_color', e.target.value)}
/>
<input
type="text"
className="form-control"
value={settings.header_text_color || '#000000'}
onChange={(e) => handleChange('header_text_color', e.target.value)}
/>
</div>
</div>
<div className="col-md-4 mb-3">
<label className="form-label">Цвет названий групп</label>
<div className="input-group">
<input
type="color"
className="form-control form-control-color"
value={settings.group_text_color || '#333333'}
onChange={(e) => handleChange('group_text_color', e.target.value)}
/>
<input
type="text"
className="form-control"
value={settings.group_text_color || '#333333'}
onChange={(e) => handleChange('group_text_color', e.target.value)}
/>
</div>
</div>
<div className="col-md-4 mb-3">
<label className="form-label">Цвет названий ссылок</label>
<div className="input-group">
<input
type="color"
className="form-control form-control-color"
value={settings.link_text_color || '#666666'}
onChange={(e) => handleChange('link_text_color', e.target.value)}
/>
<input
type="text"
className="form-control"
value={settings.link_text_color || '#666666'}
onChange={(e) => handleChange('link_text_color', e.target.value)}
/>
</div>
</div>
</div>
</div>
)}
{/* Вкладка: Группы */}
{activeTab === 'groups' && (
<div className="tab-pane fade show active">
<div className="row">
<div className="col-12 mb-3">
<h6 className="text-muted">Настройки отображения групп</h6>
</div>
<div className="col-12 mb-3">
<div className="form-check form-switch">
<input
className="form-check-input"
type="checkbox"
checked={settings.groups_default_expanded}
onChange={(e) => handleChange('groups_default_expanded', e.target.checked)}
/>
<label className="form-check-label">
Развернуть группы по умолчанию
</label>
</div>
</div>
<div className="col-12 mb-3">
<div className="form-check form-switch">
<input
className="form-check-input"
type="checkbox"
checked={settings.show_group_icons}
onChange={(e) => handleChange('show_group_icons', e.target.checked)}
/>
<label className="form-check-label">
Показывать иконки групп
</label>
</div>
</div>
<div className="col-12 mb-3">
<div className="form-check form-switch">
<input
className="form-check-input"
type="checkbox"
checked={settings.show_link_icons}
onChange={(e) => handleChange('show_link_icons', e.target.checked)}
/>
<label className="form-check-label">
Показывать иконки ссылок
</label>
</div>
</div>
<div className="col-12">
<div className="alert alert-info">
<i className="bi bi-info-circle me-2"></i>
<strong>Настройки отдельных групп</strong><br/>
Чтобы настроить конкретную группу (публичность, избранное, разворачивание), используйте кнопку редактирования рядом с названием группы в основном списке.
</div>
</div>
</div>
</div>
)}
{/* Вкладка: Дополнительно */}
{activeTab === 'advanced' && (
<div className="tab-pane fade show active">
<div className="row">
<div className="col-12 mb-3">
<label className="form-label">Шрифт</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="monospace">Monospace</option>
<option value="Inter, sans-serif">Inter</option>
<option value="Roboto, sans-serif">Roboto</option>
<option value="Open Sans, sans-serif">Open Sans</option>
</select>
</div>
<div className="col-12 mb-3">
<label className="form-label">Дополнительный CSS</label>
<textarea
className="form-control font-monospace"
rows={6}
value={settings.custom_css}
onChange={(e) => handleChange('custom_css', e.target.value)}
placeholder="/* Ваш дополнительный CSS */&#10;.my-custom-class {&#10; color: #333;&#10;}"
/>
</div>
{/* Cover Overlay Section */}
<div className="col-12 mb-3">
<div className="card">
<div className="card-header">
<h6 className="mb-0">Перекрытие обложки</h6>
</div>
<div className="card-body">
<div className="form-check mb-3">
<input
className="form-check-input"
type="checkbox"
id="coverOverlayEnabled"
checked={settings.cover_overlay_enabled || false}
onChange={(e) => handleChange('cover_overlay_enabled', e.target.checked)}
/>
<label className="form-check-label" htmlFor="coverOverlayEnabled">
Включить цветовое перекрытие обложки
</label>
</div>
{settings.cover_overlay_enabled && (
<>
<div className="row">
<div className="col-6 mb-3">
<label className="form-label">Цвет перекрытия</label>
<div className="d-flex gap-2">
<input
type="color"
className="form-control form-control-color"
value={settings.cover_overlay_color || '#000000'}
onChange={(e) => handleChange('cover_overlay_color', e.target.value)}
title="Выберите цвет перекрытия"
/>
<input
type="text"
className="form-control"
value={settings.cover_overlay_color || '#000000'}
onChange={(e) => handleChange('cover_overlay_color', e.target.value)}
placeholder="#000000"
title="Hex код цвета"
/>
</div>
</div>
<div className="col-6 mb-3">
<label className="form-label">
Прозрачность ({Math.round((settings.cover_overlay_opacity || 0.3) * 100)}%)
</label>
<input
type="range"
className="form-range"
min="0"
max="1"
step="0.1"
value={settings.cover_overlay_opacity || 0.3}
onChange={(e) => handleChange('cover_overlay_opacity', parseFloat(e.target.value))}
title="Настройка прозрачности перекрытия"
/>
</div>
</div>
{/* Preview */}
<div className="mb-3">
<label className="form-label">Предварительный просмотр</label>
<div className="position-relative" style={{ height: '100px', border: '1px solid #dee2e6', borderRadius: '0.375rem', overflow: 'hidden' }}>
<div
className="w-100 h-100"
style={{
background: 'linear-gradient(45deg, #ccc 25%, transparent 25%), linear-gradient(-45deg, #ccc 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #ccc 75%), linear-gradient(-45deg, transparent 75%, #ccc 75%)',
backgroundSize: '20px 20px',
backgroundPosition: '0 0, 0 10px, 10px -10px, -10px 0px'
}}
></div>
<div
className="position-absolute top-0 start-0 w-100 h-100"
style={{
backgroundColor: settings.cover_overlay_color || '#000000',
opacity: settings.cover_overlay_opacity || 0.3
}}
></div>
</div>
</div>
</>
)}
</div>
</div>
</div>
</div>
</div>
)}
</div>
</div>
<div className="modal-footer">
<button
type="button"
className="btn btn-secondary"
onClick={onClose}
>
Отмена
</button>
<button
type="button"
className="btn btn-primary"
onClick={saveSettings}
disabled={loading}
>
{loading ? (
<>
<span className="spinner-border spinner-border-sm me-2"></span>
Сохранение...
</>
) : (
<>
<i className="bi bi-check-lg me-2"></i>
Сохранить
</>
)}
</button>
</div>
</div>
</div>
</div>
)
}