+ Приведены все функции приложения в рабочий вид
+ Наведен порядок в файлах проекта + Наведен порядок в документации + Настроены скрипты установки, развертки и так далее, расширен MakeFile
This commit is contained in:
@@ -0,0 +1,672 @@
|
||||
'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 */ .my-custom-class { color: #333; }"
|
||||
/>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
309
frontend/linktree-frontend/src/app/components/GroupEditModal.tsx
Normal file
309
frontend/linktree-frontend/src/app/components/GroupEditModal.tsx
Normal file
@@ -0,0 +1,309 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useRef } from 'react'
|
||||
|
||||
interface GroupCustomization {
|
||||
id?: number
|
||||
name: string
|
||||
description?: string
|
||||
header_color: string
|
||||
background_image?: string | null
|
||||
is_expanded: boolean
|
||||
display_style: 'grid' | 'list' | 'cards'
|
||||
icon?: File | string | null
|
||||
}
|
||||
|
||||
interface GroupEditModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onSave: (group: GroupCustomization, iconFile?: File) => void
|
||||
group?: GroupCustomization | null
|
||||
mode: 'add' | 'edit'
|
||||
}
|
||||
|
||||
export function GroupEditModal({ isOpen, onClose, onSave, group, mode }: GroupEditModalProps) {
|
||||
const [formData, setFormData] = useState<GroupCustomization>({
|
||||
name: '',
|
||||
description: '',
|
||||
header_color: '#ffffff',
|
||||
is_expanded: true,
|
||||
display_style: 'list'
|
||||
})
|
||||
const [iconFile, setIconFile] = useState<File | null>(null)
|
||||
const [iconPreview, setIconPreview] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isOpen) {
|
||||
if (mode === 'edit' && group) {
|
||||
setFormData({
|
||||
...group
|
||||
})
|
||||
if (typeof group.icon === 'string' && group.icon) {
|
||||
setIconPreview(group.icon)
|
||||
}
|
||||
} else {
|
||||
setFormData({
|
||||
name: '',
|
||||
description: '',
|
||||
header_color: '#ffffff',
|
||||
is_expanded: true,
|
||||
display_style: 'list'
|
||||
})
|
||||
setIconFile(null)
|
||||
setIconPreview(null)
|
||||
}
|
||||
}
|
||||
}, [isOpen, mode, group])
|
||||
|
||||
const handleChange = (field: keyof GroupCustomization, value: any) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[field]: value
|
||||
}))
|
||||
}
|
||||
|
||||
const handleIconChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) {
|
||||
setIconFile(file)
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
setIconPreview(reader.result as string)
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
}
|
||||
|
||||
const removeIcon = () => {
|
||||
setIconFile(null)
|
||||
setIconPreview(null)
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!formData.name.trim()) {
|
||||
alert('Название группы обязательно')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
await onSave(formData, iconFile || undefined)
|
||||
onClose()
|
||||
} catch (error) {
|
||||
console.error('Error saving group:', error)
|
||||
alert('Ошибка при сохранении группы')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
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">
|
||||
{mode === 'add' ? (
|
||||
<>
|
||||
<i className="bi bi-plus-circle me-2"></i>
|
||||
Создать группу
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<i className="bi bi-pencil me-2"></i>
|
||||
Редактировать группу
|
||||
</>
|
||||
)}
|
||||
</h5>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-close"
|
||||
onClick={onClose}
|
||||
aria-label="Закрыть"
|
||||
></button>
|
||||
</div>
|
||||
|
||||
<div className="modal-body">
|
||||
<div className="row">
|
||||
{/* Основные настройки */}
|
||||
<div className="col-md-8">
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Название группы *</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
value={formData.name}
|
||||
onChange={(e) => handleChange('name', e.target.value)}
|
||||
placeholder="Введите название группы"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Описание</label>
|
||||
<textarea
|
||||
className="form-control"
|
||||
rows={3}
|
||||
value={formData.description || ''}
|
||||
onChange={(e) => handleChange('description', e.target.value)}
|
||||
placeholder="Краткое описание группы"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Цвет заголовка</label>
|
||||
<div className="input-group">
|
||||
<input
|
||||
type="color"
|
||||
className="form-control form-control-color"
|
||||
value={formData.header_color}
|
||||
onChange={(e) => handleChange('header_color', e.target.value)}
|
||||
title="Выберите цвет заголовка"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
value={formData.header_color}
|
||||
onChange={(e) => handleChange('header_color', e.target.value)}
|
||||
placeholder="#ffffff"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Стиль отображения</label>
|
||||
<div className="row">
|
||||
{[
|
||||
{ value: 'list', label: 'Список', icon: 'bi-list-ul' },
|
||||
{ value: 'grid', label: 'Сетка', icon: 'bi-grid-3x3' },
|
||||
{ value: 'cards', label: 'Карточки', icon: 'bi-card-text' }
|
||||
].map((style) => (
|
||||
<div key={style.value} className="col-4">
|
||||
<div
|
||||
className={`card text-center h-100 ${formData.display_style === style.value ? 'border-primary bg-primary bg-opacity-10' : ''}`}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => handleChange('display_style', style.value)}
|
||||
>
|
||||
<div className="card-body p-2">
|
||||
<i className={`${style.icon} fs-4 mb-1`}></i>
|
||||
<p className="card-text small mb-0">{style.label}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<div className="form-check form-switch">
|
||||
<input
|
||||
className="form-check-input"
|
||||
type="checkbox"
|
||||
checked={formData.is_expanded}
|
||||
onChange={(e) => handleChange('is_expanded', e.target.checked)}
|
||||
id="groupExpanded"
|
||||
/>
|
||||
<label className="form-check-label" htmlFor="groupExpanded">
|
||||
Развернуть группу по умолчанию
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Иконка группы */}
|
||||
<div className="col-md-4">
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Иконка группы</label>
|
||||
|
||||
{iconPreview && (
|
||||
<div className="text-center mb-3">
|
||||
<img
|
||||
src={iconPreview}
|
||||
alt="Preview"
|
||||
className="img-thumbnail"
|
||||
style={{ maxWidth: '100px', maxHeight: '100px' }}
|
||||
/>
|
||||
<div className="mt-2">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-outline-danger"
|
||||
onClick={removeIcon}
|
||||
>
|
||||
<i className="bi bi-trash"></i> Удалить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
className="form-control"
|
||||
accept="image/*"
|
||||
onChange={handleIconChange}
|
||||
/>
|
||||
<div className="form-text">
|
||||
Поддерживаются форматы: PNG, JPG, SVG
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Фоновое изображение</label>
|
||||
<input
|
||||
type="file"
|
||||
className="form-control"
|
||||
accept="image/*"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) {
|
||||
// TODO: Implement background image upload
|
||||
console.log('Background image selected:', file)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="form-text">
|
||||
Фоновое изображение для заголовка группы
|
||||
</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={handleSave}
|
||||
disabled={loading || !formData.name.trim()}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<span className="spinner-border spinner-border-sm me-2"></span>
|
||||
Сохранение...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<i className="bi bi-check-lg me-2"></i>
|
||||
{mode === 'add' ? 'Создать' : 'Сохранить'}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import Script from 'next/script'
|
||||
|
||||
interface User {
|
||||
username: string
|
||||
avatar: string
|
||||
avatar: string | null
|
||||
}
|
||||
|
||||
export function LayoutWrapper({ children }: { children: ReactNode }) {
|
||||
@@ -44,7 +44,10 @@ export function LayoutWrapper({ children }: { children: ReactNode }) {
|
||||
}, [])
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('token')
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem('token')
|
||||
}
|
||||
setUser(null)
|
||||
router.push('/')
|
||||
}
|
||||
|
||||
@@ -80,9 +83,11 @@ export function LayoutWrapper({ children }: { children: ReactNode }) {
|
||||
<div className="ms-auto d-flex align-items-center gap-3">
|
||||
<Image
|
||||
src={
|
||||
user.avatar.startsWith('http')
|
||||
user.avatar && user.avatar.startsWith('http')
|
||||
? user.avatar
|
||||
: `http://localhost:8000${user.avatar}`
|
||||
: user.avatar
|
||||
? `http://localhost:8000${user.avatar}`
|
||||
: '/assets/img/avatar-dhg.png'
|
||||
}
|
||||
alt="Avatar"
|
||||
width={32}
|
||||
@@ -115,8 +120,8 @@ export function LayoutWrapper({ children }: { children: ReactNode }) {
|
||||
|
||||
{/* Подвал не выводим на публичных страницах */}
|
||||
{!isPublicUserPage && (
|
||||
<footer className="bg-light footer fixed-bottom border-top">
|
||||
<div className="container py-2">
|
||||
<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">
|
||||
@@ -143,15 +148,12 @@ export function LayoutWrapper({ children }: { children: ReactNode }) {
|
||||
)}
|
||||
|
||||
{/* Bootstrap JS */}
|
||||
<Script
|
||||
src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.0/jquery.min.js"
|
||||
strategy="beforeInteractive"
|
||||
/>
|
||||
{/* 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="beforeInteractive"
|
||||
strategy="afterInteractive"
|
||||
/>
|
||||
<Script src="/assets/js/bs-init.js" strategy="lazyOnload" />
|
||||
<Script src="/assets/js/bs-init.js" strategy="afterInteractive" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user