Files
links/frontend/linktree-frontend/src/app/(protected)/dashboard/DashboardClient.tsx
Andrey K. Choi 92e2854575 Add comprehensive group customization features
- Add group overlay color and opacity settings
- Add font customization (body and heading fonts)
- Add group description text color control
- Add option to hide 'Groups' title
- Update frontend DesignSettings interface
- Update CustomizationPanel with new UI controls
- Update Django model with new fields
- Create migration for new customization options
- Update DRF serializer with validation
2025-11-09 10:27:04 +09:00

1531 lines
60 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, { useEffect, useState, Fragment } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import Image from 'next/image'
import { ProfileCard } from '../../components/ProfileCard'
import { CustomizationPanel } from '../../components/CustomizationPanel'
interface UserProfile {
id: number
username: string
email: string
full_name: string
bio?: string
avatar: string
avatar_url?: string
last_login: string
date_joined: string
}
interface LinkItem {
id: number
title: string
url: string
icon?: string
icon_url?: string
group: number
}
interface Group {
id: number
name: string
description?: string
icon?: string
icon_url?: string
header_color?: string
background_image?: string
background_image_url?: string
is_expanded?: boolean
display_style?: 'grid' | 'list' | 'cards'
is_public?: boolean
is_favorite?: boolean
links: LinkItem[]
}
interface DesignSettings {
id?: number
theme_color: string
background_image?: 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
header_text_color?: string
group_text_color?: string
link_text_color?: string
cover_overlay_enabled?: boolean
cover_overlay_color?: string
cover_overlay_opacity?: number
// Новые опции кастомизации
group_overlay_enabled?: boolean
group_overlay_color?: string
group_overlay_opacity?: number
show_groups_title?: boolean
group_description_text_color?: string
body_font_family?: string
heading_font_family?: string
}
export default function DashboardClient() {
const router = useRouter()
const [user, setUser] = useState<UserProfile | null>(null)
const [groups, setGroups] = useState<Group[]>([])
const [expandedGroup, setExpandedGroup] = useState<number | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// Состояния для кастомизации
const [designSettings, setDesignSettings] = 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: '',
header_text_color: '#000000',
group_text_color: '#333333',
link_text_color: '#666666'
})
const [showCustomizationPanel, setShowCustomizationPanel] = useState(false)
const [showShareModal, setShowShareModal] = useState(false)
// === Для модалки профиля ===
const [showProfileModal, setShowProfileModal] = useState(false)
const [profileForm, setProfileForm] = useState<{
email: string
first_name: string
last_name: string
full_name: string
bio: string
avatarFile: File | null
coverFile: File | null
}>({
email: '',
first_name: '',
last_name: '',
full_name: '',
bio: '',
avatarFile: null,
coverFile: null
})
// === Для модалок групп ===
const [showGroupModal, setShowGroupModal] = useState(false)
const [groupModalMode, setGroupModalMode] = useState<'add' | 'edit'>('add')
const [editingGroup, setEditingGroup] = useState<Group | null>(null)
const [groupForm, setGroupForm] = useState<{
name: string
description: string
header_color: string
iconFile: File | null
backgroundFile: File | null
is_public: boolean
is_favorite: boolean
is_expanded: boolean
}>({
name: '',
description: '',
header_color: '#ffffff',
iconFile: null,
backgroundFile: null,
is_public: false,
is_favorite: false,
is_expanded: true
})
// === Для модалок ссылок ===
const [showLinkModal, setShowLinkModal] = useState(false)
const [linkModalMode, setLinkModalMode] = useState<'add' | 'edit'>('add')
const [editingLink, setEditingLink] = useState<LinkItem | null>(null)
const [currentGroupIdForLink, setCurrentGroupIdForLink] = useState<number | null>(null)
const [linkForm, setLinkForm] = useState<{
title: string
url: string
description: string
iconFile: File | null
}>({
title: '',
url: '',
description: '',
iconFile: null
})
const API = ''
// Получаем публичную ссылку пользователя (только на клиенте)
const [shareUrl, setShareUrl] = useState('')
useEffect(() => {
if (user && typeof window !== 'undefined') {
setShareUrl(`${window.location.origin}/${user.username}`)
}
}, [user])
// Функция копирования ссылки в буфер обмена
async function copyShareUrl() {
if (!shareUrl) return
try {
await navigator.clipboard.writeText(shareUrl)
alert('Ссылка скопирована в буфер обмена!')
} catch (err) {
// Fallback для старых браузеров
const textArea = document.createElement('textarea')
textArea.value = shareUrl
document.body.appendChild(textArea)
textArea.select()
document.execCommand('copy')
document.body.removeChild(textArea)
alert('Ссылка скопирована в буфер обмена!')
}
}
// Стили для основного контейнера с учетом настроек дизайна
const containerStyle = {
backgroundColor: designSettings.dashboard_background_color,
fontFamily: designSettings.font_family,
backgroundImage: designSettings.background_image ? `url(${designSettings.background_image})` : 'none',
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundAttachment: 'fixed',
minHeight: '100vh'
}
// Отладка состояния панели кастомизации
useEffect(() => {
console.log('showCustomizationPanel changed to:', showCustomizationPanel)
}, [showCustomizationPanel])
useEffect(() => {
console.log('designSettings updated:', designSettings)
}, [designSettings])
useEffect(() => {
const token = localStorage.getItem('token')
if (!token) {
router.push('/auth/login')
return
}
// загружаем профиль, группы, ссылки и настройки дизайна
Promise.all([
fetch('/api/auth/user', { headers: { Authorization: `Bearer ${token}` } }),
fetch('/api/groups', { headers: { Authorization: `Bearer ${token}` } }),
fetch('/api/links', { headers: { Authorization: `Bearer ${token}` } }),
fetch('/api/customization/settings/', { headers: { Authorization: `Bearer ${token}` } }),
])
.then(async ([uRes, gRes, lRes, dRes]) => {
if (!uRes.ok) throw new Error('Не удалось получить профиль')
if (!gRes.ok) throw new Error('Не удалось загрузить группы')
if (!lRes.ok) throw new Error('Не удалось загрузить ссылки')
const userData = await uRes.json()
const groupsData = await gRes.json()
const linksData = await lRes.json()
// Загружаем настройки дизайна (может вернуть 404, если их еще нет)
let designData = designSettings // default
if (dRes.ok) {
designData = await dRes.json()
}
// «привязываем» ссылки к группам
const enrichedGroups: Group[] = groupsData.map((grp: any) => ({
...grp,
links: linksData.filter((link: LinkItem) => link.group === grp.id),
}))
setUser(userData)
setGroups(enrichedGroups)
setDesignSettings(designData)
})
.catch(err => setError((err as Error).message))
.finally(() => setLoading(false))
}, [router])
// Перезагрузка списка групп и ссылок
async function reloadData() {
const token = localStorage.getItem('token')!
const [gRes, lRes] = await Promise.all([
fetch(`${API}/api/groups/`, { headers: { Authorization: `Bearer ${token}` } }),
fetch(`${API}/api/links/`, { headers: { Authorization: `Bearer ${token}` } }),
])
const groupsData = await gRes.json()
const linksData = await lRes.json()
const enrichedGroups: Group[] = groupsData.map((grp: any) => ({
...grp,
links: linksData.filter((link: LinkItem) => link.group === grp.id),
}))
setGroups(enrichedGroups)
}
// === Обработчики групп ===
function openAddGroup() {
setGroupModalMode('add')
setGroupForm({
name: '',
description: '',
header_color: '#ffffff',
iconFile: null,
backgroundFile: null,
is_public: false,
is_favorite: false,
is_expanded: true
})
setShowGroupModal(true)
}
function openEditGroup(grp: Group) {
setGroupModalMode('edit')
setEditingGroup(grp)
setGroupForm({
name: grp.name,
description: grp.description || '',
header_color: grp.header_color || '#ffffff',
iconFile: null,
backgroundFile: null,
is_public: grp.is_public || false,
is_favorite: grp.is_favorite || false,
is_expanded: grp.is_expanded || true
})
setShowGroupModal(true)
}
async function handleGroupSubmit() {
const token = localStorage.getItem('token')!
const fd = new FormData()
fd.append('name', groupForm.name)
fd.append('description', groupForm.description)
fd.append('header_color', groupForm.header_color)
fd.append('is_public', groupForm.is_public.toString())
fd.append('is_favorite', groupForm.is_favorite.toString())
fd.append('is_expanded', groupForm.is_expanded.toString())
if (groupForm.iconFile) fd.append('icon', groupForm.iconFile)
if (groupForm.backgroundFile) fd.append('background_image', groupForm.backgroundFile)
const url = groupModalMode === 'add'
? `${API}/api/groups/`
: `${API}/api/groups/${editingGroup?.id}/`
const method = groupModalMode === 'add' ? 'POST' : 'PATCH'
await fetch(url, {
method,
headers: { Authorization: `Bearer ${token}` },
body: fd,
})
setShowGroupModal(false)
await reloadData()
}
async function handleDeleteGroup(grp: Group) {
if (!confirm(`Удалить группу "${grp.name}"?`)) return
const token = localStorage.getItem('token')!
await fetch(`${API}/api/groups/${grp.id}/`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
})
await reloadData()
}
// === Обработчики ссылок ===
function openAddLink(grp: Group) {
setLinkModalMode('add')
setCurrentGroupIdForLink(grp.id)
setLinkForm({
title: '',
url: '',
description: '',
iconFile: null
})
setShowLinkModal(true)
}
function openEditLink(link: LinkItem) {
setLinkModalMode('edit')
setEditingLink(link)
setCurrentGroupIdForLink(link.group)
setLinkForm({
title: link.title,
url: link.url,
description: '', // TODO: получить из API когда будет поле
iconFile: null
})
setShowLinkModal(true)
}
async function handleLinkSubmit() {
const token = localStorage.getItem('token')!
const fd = new FormData()
fd.append('title', linkForm.title)
fd.append('url', linkForm.url)
fd.append('description', linkForm.description)
if (linkForm.iconFile) fd.append('icon', linkForm.iconFile)
fd.append('group', String(currentGroupIdForLink))
const url = linkModalMode === 'add'
? `${API}/api/links/`
: `${API}/api/links/${editingLink?.id}/`
const method = linkModalMode === 'add' ? 'POST' : 'PATCH'
await fetch(url, {
method,
headers: { Authorization: `Bearer ${token}` },
body: fd,
})
setShowLinkModal(false)
await reloadData()
}
async function handleDeleteLink(link: LinkItem) {
if (!confirm(`Удалить ссылку "${link.title}"?`)) return
const token = localStorage.getItem('token')!
await fetch(`${API}/api/links/${link.id}/`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
})
await reloadData()
}
// === Обработчики профиля ===
function openEditProfile() {
if (!user) return
setProfileForm({
email: user.email,
first_name: user.first_name || '',
last_name: user.last_name || '',
full_name: user.full_name || '',
bio: user.bio || '',
avatarFile: null,
coverFile: null
})
setShowProfileModal(true)
}
async function handleProfileSubmit() {
const token = localStorage.getItem('token')!
const fd = new FormData()
fd.append('email', profileForm.email)
fd.append('first_name', profileForm.first_name)
fd.append('last_name', profileForm.last_name)
fd.append('full_name', profileForm.full_name)
fd.append('bio', profileForm.bio)
if (profileForm.avatarFile) fd.append('avatar', profileForm.avatarFile)
if (profileForm.coverFile) fd.append('cover', profileForm.coverFile)
const res = await fetch(`${API}/api/users/profile/`, {
method: 'PUT',
headers: { Authorization: `Bearer ${token}` },
body: fd,
})
if (res.ok) {
const userData = await res.json()
setUser(userData)
setShowProfileModal(false)
} else {
const error = await res.json()
alert('Ошибка: ' + JSON.stringify(error))
}
}
if (loading) return <div className="flex items-center justify-center h-screen">Загрузка...</div>
if (error) return <div className="p-6 text-red-600 max-w-xl mx-auto">{error}</div>
// Функция расчета оптимального размера изображения для группы
const calculateOptimalImageSize = (linksCount: number, layout: string = 'list') => {
const baseHeight = 60 // высота заголовка группы
const linkHeight = layout === 'compact' ? 40 : layout === 'cards' ? 120 : 60
const padding = 24 // отступы
const totalHeight = baseHeight + (linksCount * linkHeight) + padding
const width = layout === 'sidebar' ? 300 : layout === 'grid' ? 350 : 400
return {
width: Math.min(width, 500),
height: Math.min(totalHeight, 400),
aspectRatio: width / totalHeight
}
}
const totalGroups = groups.length
const totalLinks = groups.reduce((sum, grp) => sum + grp.links.length, 0)
// Функция рендеринга групп в зависимости от выбранного макета
const renderGroupsLayout = () => {
const layout = designSettings.dashboard_layout || 'list'
console.log('Current layout:', layout, 'from designSettings:', designSettings)
switch (layout) {
case 'grid':
return renderGridLayout()
case 'cards':
return renderCardsLayout()
case 'compact':
return renderCompactLayout()
case 'sidebar':
return renderSidebarLayout()
case 'masonry':
return renderMasonryLayout()
case 'timeline':
return renderTimelineLayout()
case 'magazine':
return renderMagazineLayout()
default:
console.log('Using default list layout for:', layout)
return renderListLayout()
}
}
// Базовый список (по умолчанию)
const renderListLayout = () => (
<div className="card">
<div className="card-header d-flex justify-content-between align-items-center">
<h5 className="mb-0">Группы ссылок</h5>
<button className="btn btn-sm btn-success" onClick={openAddGroup}>
<i className="bi bi-plus-lg"></i> Добавить группу
</button>
</div>
<div className="list-group list-group-flush">
{groups.map(group => (
<Fragment key={group.id}>
<div className="list-group-item d-flex justify-content-between align-items-center">
<div
className="d-flex align-items-center"
style={{ cursor: 'pointer' }}
onClick={() => setExpandedGroup(expandedGroup === group.id ? null : group.id)}
>
{group.icon_url && designSettings.show_group_icons && (
<img
src={group.icon_url}
width={32}
height={32}
className="me-2 rounded"
alt={group.name}
/>
)}
<strong className="me-2" style={{ color: designSettings.group_text_color || designSettings.theme_color }}>
{group.name}
</strong>
<span className="badge bg-secondary rounded-pill">
{group.links.length}
</span>
</div>
<div className="btn-group btn-group-sm">
<button onClick={() => openAddLink(group)} className="btn btn-outline-primary">
<i className="bi bi-link-45deg"></i>
</button>
<button onClick={() => openEditGroup(group)} className="btn btn-outline-secondary">
<i className="bi bi-pencil"></i>
</button>
<button onClick={() => handleDeleteGroup(group)} className="btn btn-outline-danger">
<i className="bi bi-trash"></i>
</button>
</div>
</div>
{expandedGroup === group.id && renderGroupLinks(group)}
</Fragment>
))}
</div>
</div>
)
// Сетка карточек
const renderGridLayout = () => (
<div>
<div className="d-flex justify-content-between align-items-center mb-3">
<h5 className="mb-0">Группы ссылок</h5>
<button className="btn btn-sm btn-success" onClick={openAddGroup}>
<i className="bi bi-plus-lg"></i> Добавить группу
</button>
</div>
<div className="row g-3">
{groups.map(group => (
<div key={group.id} className="col-md-6 col-lg-4">
<div className="card h-100">
<div className="card-header d-flex justify-content-between align-items-center">
<div className="d-flex align-items-center">
{group.icon_url && designSettings.show_group_icons && (
<img
src={group.icon_url}
width={24}
height={24}
className="me-2 rounded"
alt={group.name}
/>
)}
<h6 className="mb-0" style={{ color: designSettings.group_text_color || designSettings.theme_color }}>
{group.name}
</h6>
</div>
<span className="badge bg-secondary rounded-pill">
{group.links.length}
</span>
</div>
<div className="card-body">
{group.links.slice(0, 3).map(link => (
<div key={link.id} className="d-flex align-items-center mb-2">
{link.icon_url && (
<img
src={link.icon_url}
width={16}
height={16}
className="me-2 rounded"
alt={link.title}
/>
)}
<small className="text-truncate">{link.title}</small>
</div>
))}
{group.links.length > 3 && (
<small className="text-muted">+{group.links.length - 3} еще...</small>
)}
</div>
<div className="card-footer">
<div className="btn-group btn-group-sm w-100">
<button onClick={() => openAddLink(group)} className="btn btn-outline-primary">
<i className="bi bi-link-45deg"></i>
</button>
<button onClick={() => openEditGroup(group)} className="btn btn-outline-secondary">
<i className="bi bi-pencil"></i>
</button>
<button onClick={() => handleDeleteGroup(group)} className="btn btn-outline-danger">
<i className="bi bi-trash"></i>
</button>
</div>
</div>
</div>
</div>
))}
</div>
</div>
)
// Компактный макет
const renderCompactLayout = () => (
<div className="compact-layout">
<div className="d-flex justify-content-between align-items-center mb-2">
<h6 className="mb-0">Группы ссылок</h6>
<button className="btn btn-sm btn-success" onClick={openAddGroup}>
<i className="bi bi-plus-lg"></i>
</button>
</div>
{groups.map(group => (
<div key={group.id} className="card mb-2">
<div className="card-body py-2">
<div className="d-flex justify-content-between align-items-center">
<div className="d-flex align-items-center">
{group.icon_url && designSettings.show_group_icons && (
<img
src={group.icon_url}
width={20}
height={20}
className="me-2 rounded"
alt={group.name}
/>
)}
<strong className="me-2" style={{ color: designSettings.group_text_color || designSettings.theme_color }}>
{group.name}
</strong>
<span className="badge bg-secondary badge-sm">
{group.links.length}
</span>
</div>
<div className="btn-group btn-group-sm">
<button onClick={() => openAddLink(group)} className="btn btn-outline-primary btn-sm">
<i className="bi bi-link-45deg"></i>
</button>
<button onClick={() => openEditGroup(group)} className="btn btn-outline-secondary btn-sm">
<i className="bi bi-pencil"></i>
</button>
<button onClick={() => handleDeleteGroup(group)} className="btn btn-outline-danger btn-sm">
<i className="bi bi-trash"></i>
</button>
</div>
</div>
</div>
</div>
))}
</div>
)
// Большие карточки
const renderCardsLayout = () => (
<div>
<div className="d-flex justify-content-between align-items-center mb-4">
<h5 className="mb-0">Группы ссылок</h5>
<button className="btn btn-success" onClick={openAddGroup}>
<i className="bi bi-plus-lg"></i> Добавить группу
</button>
</div>
<div className="row g-4">
{groups.map(group => (
<div key={group.id} className="col-12">
<div className="card shadow-sm">
<div className="card-header bg-light">
<div className="row align-items-center">
<div className="col">
<div className="d-flex align-items-center">
{group.icon_url && designSettings.show_group_icons && (
<img
src={group.icon_url}
width={40}
height={40}
className="me-3 rounded"
alt={group.name}
/>
)}
<div>
<h5 className="mb-1" style={{ color: designSettings.group_text_color || designSettings.theme_color }}>
{group.name}
</h5>
<small className="text-muted">{group.links.length} ссылок</small>
</div>
</div>
</div>
<div className="col-auto">
<div className="btn-group">
<button onClick={() => openAddLink(group)} className="btn btn-outline-primary">
<i className="bi bi-link-45deg"></i> Добавить
</button>
<button onClick={() => openEditGroup(group)} className="btn btn-outline-secondary">
<i className="bi bi-pencil"></i>
</button>
<button onClick={() => handleDeleteGroup(group)} className="btn btn-outline-danger">
<i className="bi bi-trash"></i>
</button>
</div>
</div>
</div>
</div>
<div className="card-body">
<div className="row g-3">
{group.links.map(link => (
<div key={link.id} className="col-md-6 col-lg-4">
<div className="border rounded p-3 h-100">
<div className="d-flex align-items-center mb-2">
{link.icon_url && (
<img
src={link.icon_url}
width={20}
height={20}
className="me-2 rounded"
alt={link.title}
/>
)}
<h6 className="mb-0" style={{ color: designSettings.link_text_color || designSettings.theme_color }}>
{link.title}
</h6>
</div>
<small className="text-muted d-block text-truncate">{link.url}</small>
<div className="mt-2">
<button onClick={() => openEditLink(link)} className="btn btn-sm btn-outline-secondary me-1">
<i className="bi bi-pencil"></i>
</button>
<button onClick={() => handleDeleteLink(link)} className="btn btn-sm btn-outline-danger">
<i className="bi bi-trash"></i>
</button>
</div>
</div>
</div>
))}
</div>
</div>
</div>
</div>
))}
</div>
</div>
)
// Боковая панель
const renderSidebarLayout = () => (
<div className="row">
<div className="col-md-3">
<div className="card">
<div className="card-header">
<h6 className="mb-0">Группы</h6>
<button className="btn btn-sm btn-success mt-2 w-100" onClick={openAddGroup}>
<i className="bi bi-plus-lg"></i> Добавить
</button>
</div>
<div className="list-group list-group-flush">
{groups.map(group => (
<button
key={group.id}
className={`list-group-item list-group-item-action ${expandedGroup === group.id ? 'active' : ''}`}
onClick={() => setExpandedGroup(expandedGroup === group.id ? null : group.id)}
>
<div className="d-flex align-items-center">
{group.icon_url && designSettings.show_group_icons && (
<img
src={group.icon_url}
width={20}
height={20}
className="me-2 rounded"
alt={group.name}
/>
)}
<span className="me-auto">{group.name}</span>
<span className="badge bg-secondary rounded-pill">
{group.links.length}
</span>
</div>
</button>
))}
</div>
</div>
</div>
<div className="col-md-9">
{expandedGroup && groups.find(g => g.id === expandedGroup) && (
<div className="card">
<div className="card-header">
<div className="d-flex justify-content-between align-items-center">
<h5 className="mb-0">{groups.find(g => g.id === expandedGroup)?.name}</h5>
<div className="btn-group">
<button onClick={() => openAddLink(groups.find(g => g.id === expandedGroup)!)} className="btn btn-sm btn-primary">
<i className="bi bi-link-45deg"></i> Добавить ссылку
</button>
<button onClick={() => openEditGroup(groups.find(g => g.id === expandedGroup)!)} className="btn btn-sm btn-secondary">
<i className="bi bi-pencil"></i>
</button>
<button onClick={() => handleDeleteGroup(groups.find(g => g.id === expandedGroup)!)} className="btn btn-sm btn-danger">
<i className="bi bi-trash"></i>
</button>
</div>
</div>
</div>
<div className="card-body">
{groups.find(g => g.id === expandedGroup)?.links.map(link => (
<div key={link.id} className="d-flex align-items-center justify-content-between p-3 border-bottom">
<div className="d-flex align-items-center">
{link.icon_url && (
<img
src={link.icon_url}
width={24}
height={24}
className="me-3 rounded"
alt={link.title}
/>
)}
<div>
<h6 className="mb-1" style={{ color: designSettings.link_text_color || designSettings.theme_color }}>
{link.title}
</h6>
<small className="text-muted">{link.url}</small>
</div>
</div>
<div className="btn-group btn-group-sm">
<button onClick={() => openEditLink(link)} className="btn btn-outline-secondary">
<i className="bi bi-pencil"></i>
</button>
<button onClick={() => handleDeleteLink(link)} className="btn btn-outline-danger">
<i className="bi bi-trash"></i>
</button>
</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
)
// Кладка (Masonry)
const renderMasonryLayout = () => (
<div>
<div className="d-flex justify-content-between align-items-center mb-3">
<h5 className="mb-0">Группы ссылок</h5>
<button className="btn btn-success" onClick={openAddGroup}>
<i className="bi bi-plus-lg"></i> Добавить группу
</button>
</div>
<div className="row g-3">
{groups.map((group, index) => (
<div key={group.id} className={`col-md-6 ${index % 3 === 0 ? 'col-lg-4' : index % 3 === 1 ? 'col-lg-8' : 'col-lg-4'}`}>
<div className="card h-100">
<div className="card-header">
<div className="d-flex align-items-center justify-content-between">
<div className="d-flex align-items-center">
{group.icon_url && designSettings.show_group_icons && (
<img
src={group.icon_url}
width={32}
height={32}
className="me-2 rounded"
alt={group.name}
/>
)}
<h6 className="mb-0" style={{ color: designSettings.group_text_color || designSettings.theme_color }}>
{group.name}
</h6>
</div>
<span className="badge bg-secondary rounded-pill">
{group.links.length}
</span>
</div>
</div>
<div className="card-body">
{group.links.map(link => (
<div key={link.id} className="mb-2 p-2 border rounded">
<div className="d-flex align-items-center">
{link.icon_url && (
<img
src={link.icon_url}
width={16}
height={16}
className="me-2 rounded"
alt={link.title}
/>
)}
<small className="text-truncate" style={{ color: designSettings.link_text_color || designSettings.theme_color }}>
{link.title}
</small>
</div>
</div>
))}
</div>
<div className="card-footer">
<div className="btn-group btn-group-sm w-100">
<button onClick={() => openAddLink(group)} className="btn btn-outline-primary">
<i className="bi bi-link-45deg"></i>
</button>
<button onClick={() => openEditGroup(group)} className="btn btn-outline-secondary">
<i className="bi bi-pencil"></i>
</button>
<button onClick={() => handleDeleteGroup(group)} className="btn btn-outline-danger">
<i className="bi bi-trash"></i>
</button>
</div>
</div>
</div>
</div>
))}
</div>
</div>
)
// Лента времени
const renderTimelineLayout = () => (
<div>
<div className="d-flex justify-content-between align-items-center mb-4">
<h5 className="mb-0">Группы ссылок</h5>
<button className="btn btn-success" onClick={openAddGroup}>
<i className="bi bi-plus-lg"></i> Добавить группу
</button>
</div>
<div className="timeline">
{groups.map((group, index) => (
<div key={group.id} className={`timeline-item ${index % 2 === 0 ? 'left' : 'right'}`}>
<div className="timeline-content">
<div className="card">
<div className="card-header">
<div className="d-flex align-items-center">
{group.icon_url && designSettings.show_group_icons && (
<img
src={group.icon_url}
width={40}
height={40}
className="me-3 rounded"
alt={group.name}
/>
)}
<div>
<h6 className="mb-0" style={{ color: designSettings.group_text_color || designSettings.theme_color }}>
{group.name}
</h6>
<small className="text-muted">{group.links.length} ссылок</small>
</div>
</div>
</div>
<div className="card-body">
{group.links.slice(0, 5).map(link => (
<div key={link.id} className="d-flex align-items-center mb-2">
{link.icon_url && (
<img
src={link.icon_url}
width={16}
height={16}
className="me-2 rounded"
alt={link.title}
/>
)}
<small style={{ color: designSettings.link_text_color || designSettings.theme_color }}>
{link.title}
</small>
</div>
))}
</div>
<div className="card-footer">
<div className="btn-group btn-group-sm">
<button onClick={() => openAddLink(group)} className="btn btn-outline-primary">
<i className="bi bi-link-45deg"></i>
</button>
<button onClick={() => openEditGroup(group)} className="btn btn-outline-secondary">
<i className="bi bi-pencil"></i>
</button>
<button onClick={() => handleDeleteGroup(group)} className="btn btn-outline-danger">
<i className="bi bi-trash"></i>
</button>
</div>
</div>
</div>
</div>
</div>
))}
</div>
</div>
)
// Журнальный макет
const renderMagazineLayout = () => (
<div>
<div className="d-flex justify-content-between align-items-center mb-4">
<h5 className="mb-0">Группы ссылок</h5>
<button className="btn btn-success" onClick={openAddGroup}>
<i className="bi bi-plus-lg"></i> Добавить группу
</button>
</div>
<div className="magazine-layout">
{groups.map((group, index) => (
<div key={group.id} className={`magazine-item ${index === 0 ? 'featured' : ''}`}>
<div className="card mb-4">
<div className="row g-0">
<div className={`${index === 0 ? 'col-md-6' : 'col-md-4'}`}>
<div className="magazine-image-placeholder d-flex align-items-center justify-content-center bg-light">
{group.icon_url && designSettings.show_group_icons ? (
<img
src={group.icon_url}
className="img-fluid rounded-start"
alt={group.name}
style={{ maxHeight: '200px', objectFit: 'cover' }}
/>
) : (
<i className="bi bi-collection fs-1 text-muted"></i>
)}
</div>
</div>
<div className={`${index === 0 ? 'col-md-6' : 'col-md-8'}`}>
<div className="card-body">
<h5 className="card-title" style={{ color: designSettings.group_text_color || designSettings.theme_color }}>
{group.name}
</h5>
<p className="card-text text-muted">
{group.links.length} ссылок в этой группе
</p>
<div className="links-preview">
{group.links.slice(0, 3).map(link => (
<div key={link.id} className="mb-1">
<small style={{ color: designSettings.link_text_color || designSettings.theme_color }}>
{link.title}
</small>
</div>
))}
{group.links.length > 3 && (
<small className="text-muted">и еще {group.links.length - 3}...</small>
)}
</div>
<div className="mt-3">
<div className="btn-group">
<button onClick={() => openAddLink(group)} className="btn btn-outline-primary btn-sm">
<i className="bi bi-link-45deg"></i> Добавить
</button>
<button onClick={() => openEditGroup(group)} className="btn btn-outline-secondary btn-sm">
<i className="bi bi-pencil"></i>
</button>
<button onClick={() => handleDeleteGroup(group)} className="btn btn-outline-danger btn-sm">
<i className="bi bi-trash"></i>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
))}
</div>
</div>
)
// Функция рендеринга ссылок группы
const renderGroupLinks = (group: Group) => (
<div className="list-group-item bg-light">
{group.links.map(link => (
<div key={link.id} className="d-flex align-items-center justify-content-between py-2 border-bottom">
<div className="d-flex align-items-center">
{link.icon_url && (
<img
src={link.icon_url}
width={20}
height={20}
className="me-2 rounded"
alt={link.title}
/>
)}
<a
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="text-decoration-none"
style={{ color: designSettings.link_text_color || designSettings.theme_color }}
>
{link.title}
</a>
</div>
<div className="btn-group btn-group-sm">
<button onClick={() => openEditLink(link)} className="btn btn-outline-secondary">
<i className="bi bi-pencil"></i>
</button>
<button onClick={() => handleDeleteLink(link)} className="btn btn-outline-danger">
<i className="bi bi-trash"></i>
</button>
</div>
</div>
))}
</div>
)
return (
<div
className="pb-8"
style={containerStyle}
suppressHydrationWarning={true}
>
{user && (
<ProfileCard
avatar={user.avatar_url || user.avatar}
full_name={user.full_name}
email={user.email}
bio={user.bio}
last_login={user.last_login}
date_joined={user.date_joined}
totalGroups={totalGroups}
totalLinks={totalLinks}
/>
)}
<div className="container my-4">
<div className="d-flex justify-content-between align-items-center mb-3">
<h2>Ваши ссылки</h2>
<div>
<span className="me-2">Panel state: {showCustomizationPanel ? 'Open' : 'Closed'}</span>
<button
className="btn btn-outline-info me-2"
onClick={openEditProfile}
>
<i className="bi bi-person-gear"></i> Профиль
</button>
<button
className="btn btn-outline-success me-2"
onClick={() => setShowShareModal(true)}
>
<i className="bi bi-share"></i> Поделиться
</button>
<button
className="btn btn-outline-primary"
onClick={() => {
console.log('Settings button clicked')
console.log('Current showCustomizationPanel:', showCustomizationPanel)
setShowCustomizationPanel(true)
console.log('After setting showCustomizationPanel to true')
}}
>
<i className="bi bi-gear"></i> Настройки
</button>
</div>
</div>
</div>
<section className="mt-5 container">
{renderGroupsLayout()}
</section>
{/* Модалка добавления/редактирования группы */}
<div className={`modal ${showGroupModal ? 'd-block' : 'd-none'}`} tabIndex={-1}>
<div className="modal-dialog">
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title">{groupModalMode === 'add' ? 'Добавить группу' : 'Редактировать группу'}</h5>
<button
type="button"
className="btn-close"
onClick={() => setShowGroupModal(false)}
aria-label="Закрыть"
title="Закрыть модальное окно"
/>
</div>
<div className="modal-body">
<div className="mb-3">
<label className="form-label">Название</label>
<input
type="text"
className="form-control"
value={groupForm.name}
onChange={e => setGroupForm(f => ({ ...f, name: e.target.value }))}
/>
</div>
<div className="mb-3">
<label className="form-label">Описание (опционально)</label>
<textarea
className="form-control"
rows={3}
value={groupForm.description}
onChange={e => setGroupForm(f => ({ ...f, description: e.target.value }))}
placeholder="Краткое описание группы ссылок"
/>
</div>
<div className="mb-3">
<label className="form-label">Цвет заголовка</label>
<input
type="color"
className="form-control form-control-color"
value={groupForm.header_color}
onChange={e => setGroupForm(f => ({ ...f, header_color: e.target.value }))}
/>
</div>
<div className="row mb-3">
<div className="col-md-4">
<div className="form-check">
<input
type="checkbox"
className="form-check-input"
id="groupPublic"
checked={groupForm.is_public}
onChange={(e) => setGroupForm(prev => ({ ...prev, is_public: e.target.checked }))}
/>
<label className="form-check-label" htmlFor="groupPublic">
Публичная
</label>
</div>
</div>
<div className="col-md-4">
<div className="form-check">
<input
type="checkbox"
className="form-check-input"
id="groupFavorite"
checked={groupForm.is_favorite}
onChange={(e) => setGroupForm(prev => ({ ...prev, is_favorite: e.target.checked }))}
/>
<label className="form-check-label" htmlFor="groupFavorite">
Избранная
</label>
</div>
</div>
<div className="col-md-4">
<div className="form-check">
<input
type="checkbox"
className="form-check-input"
id="groupExpanded"
checked={groupForm.is_expanded}
onChange={(e) => setGroupForm(prev => ({ ...prev, is_expanded: e.target.checked }))}
/>
<label className="form-check-label" htmlFor="groupExpanded">
Развернутая
</label>
</div>
</div>
</div>
<div className="mb-3">
<label className="form-label">Иконка группы (опционально)</label>
<input
type="file"
className="form-control"
accept="image/*"
onChange={e => setGroupForm(f => ({ ...f, iconFile: e.target.files?.[0] || null }))}
/>
<div className="form-text">Рекомендуемый размер: 32x32 пикселя</div>
</div>
<div className="mb-3">
<label className="form-label">Фоновое изображение (опционально)</label>
<input
type="file"
className="form-control"
accept="image/*"
onChange={e => setGroupForm(f => ({ ...f, backgroundFile: e.target.files?.[0] || null }))}
/>
<div className="alert alert-info mt-2">
<i className="bi bi-info-circle me-2"></i>
<strong>Рекомендуемый размер изображения:</strong>
<br />
{(() => {
const linksCount = editingGroup ? editingGroup.links.length : 3 // по умолчанию для новых групп
const size = calculateOptimalImageSize(linksCount, designSettings.dashboard_layout)
return `${size.width}×${size.height} пикселей (соотношение ${size.aspectRatio.toFixed(2)}:1)`
})()}
<br />
<small className="text-muted">
{editingGroup
? `Размер рассчитан для ${editingGroup.links.length} ссылок в макете "${designSettings.dashboard_layout}"`
: `Размер рассчитан для среднего количества ссылок (3-5) в макете "${designSettings.dashboard_layout}"`
}
</small>
<br />
<small className="text-muted">
💡 <strong>Совет:</strong> Для групп с рамкой используйте изображения с отступами по краям (10-20px)
</small>
</div>
<div className="form-text">Изображение будет использовано как фон для содержимого группы</div>
</div>
</div>
<div className="modal-footer">
<button className="btn btn-secondary" onClick={() => setShowGroupModal(false)}>Отмена</button>
<button className="btn btn-primary" onClick={handleGroupSubmit}>
Сохранить
</button>
</div>
</div>
</div>
</div>
{/* Модалка добавления/редактирования ссылки */}
<div className={`modal ${showLinkModal ? 'd-block' : 'd-none'}`} tabIndex={-1}>
<div className="modal-dialog">
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title">{linkModalMode === 'add' ? 'Добавить ссылку' : 'Редактировать ссылку'}</h5>
<button
type="button"
className="btn-close"
onClick={() => setShowLinkModal(false)}
aria-label="Закрыть"
title="Закрыть модальное окно"
/>
</div>
<div className="modal-body">
<div className="mb-3">
<label className="form-label">Заголовок</label>
<input
type="text"
className="form-control"
value={linkForm.title}
onChange={e => setLinkForm(f => ({ ...f, title: e.target.value }))}
placeholder="Название ссылки"
/>
</div>
<div className="mb-3">
<label className="form-label">URL</label>
<input
type="url"
className="form-control"
value={linkForm.url}
onChange={e => setLinkForm(f => ({ ...f, url: e.target.value }))}
placeholder="https://example.com"
/>
</div>
<div className="mb-3">
<label className="form-label">Описание (опционально)</label>
<textarea
className="form-control"
rows={2}
value={linkForm.description}
onChange={e => setLinkForm(f => ({ ...f, description: e.target.value }))}
placeholder="Краткое описание ссылки"
/>
</div>
<div className="mb-3">
<label className="form-label">Иконка (опционально)</label>
<input
type="file"
className="form-control"
accept="image/*"
onChange={e => setLinkForm(f => ({ ...f, iconFile: e.target.files?.[0] || null }))}
/>
<div className="form-text">Рекомендуемый размер: 24x24 пикселя</div>
</div>
</div>
<div className="modal-footer">
<button className="btn btn-secondary" onClick={() => setShowLinkModal(false)}>Отмена</button>
<button className="btn btn-primary" onClick={handleLinkSubmit}>
Сохранить
</button>
</div>
</div>
</div>
</div>
{/* Модалка "Поделиться" */}
<div className={`modal ${showShareModal ? 'd-block' : 'd-none'}`} tabIndex={-1}>
<div className="modal-dialog">
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title">Поделиться страницей</h5>
<button
type="button"
className="btn-close"
onClick={() => setShowShareModal(false)}
aria-label="Закрыть"
title="Закрыть модальное окно"
/>
</div>
<div className="modal-body">
<p>Ваша публичная страница со ссылками доступна по адресу:</p>
<div className="input-group mb-3">
<input
type="text"
className="form-control"
value={shareUrl || 'Загрузка...'}
readOnly
aria-label="URL публичной страницы"
title="URL публичной страницы"
/>
<button
className="btn btn-outline-primary"
type="button"
onClick={copyShareUrl}
disabled={!shareUrl}
>
<i className="bi bi-clipboard"></i> Копировать
</button>
</div>
<p className="text-muted small">
На этой странице будут видны все ваши группы и ссылки.
Она обновляется автоматически при изменении данных.
</p>
</div>
<div className="modal-footer">
<button className="btn btn-secondary" onClick={() => setShowShareModal(false)}>
Закрыть
</button>
{shareUrl && (
<a
href={shareUrl}
target="_blank"
rel="noopener noreferrer"
className="btn btn-primary"
>
<i className="bi bi-box-arrow-up-right"></i> Открыть страницу
</a>
)}
</div>
</div>
</div>
</div>
{/* Модалка редактирования профиля */}
<div className={`modal ${showProfileModal ? 'd-block' : 'd-none'}`} tabIndex={-1}>
<div className="modal-dialog modal-lg">
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title">Редактировать профиль</h5>
<button
type="button"
className="btn-close"
onClick={() => setShowProfileModal(false)}
aria-label="Закрыть"
title="Закрыть модальное окно"
/>
</div>
<div className="modal-body">
<div className="row">
<div className="col-md-6">
<div className="mb-3">
<label className="form-label">Email</label>
<input
type="email"
className="form-control"
value={profileForm.email}
onChange={e => setProfileForm(f => ({ ...f, email: e.target.value }))}
/>
</div>
<div className="mb-3">
<label className="form-label">Имя</label>
<input
type="text"
className="form-control"
value={profileForm.first_name}
onChange={e => setProfileForm(f => ({ ...f, first_name: e.target.value }))}
/>
</div>
<div className="mb-3">
<label className="form-label">Фамилия</label>
<input
type="text"
className="form-control"
value={profileForm.last_name}
onChange={e => setProfileForm(f => ({ ...f, last_name: e.target.value }))}
/>
</div>
<div className="mb-3">
<label className="form-label">Полное имя</label>
<input
type="text"
className="form-control"
value={profileForm.full_name}
onChange={e => setProfileForm(f => ({ ...f, full_name: e.target.value }))}
/>
</div>
</div>
<div className="col-md-6">
<div className="mb-3">
<label className="form-label">Биография</label>
<textarea
className="form-control"
rows={4}
value={profileForm.bio}
onChange={e => setProfileForm(f => ({ ...f, bio: e.target.value }))}
/>
</div>
<div className="mb-3">
<label className="form-label">Аватар</label>
<input
type="file"
className="form-control"
accept="image/*"
onChange={e => setProfileForm(f => ({ ...f, avatarFile: e.target.files?.[0] || null }))}
/>
{user?.avatar && (
<div className="mt-2">
<img src={user.avatar_url || user.avatar} alt="Текущий аватар" className="img-thumbnail w-25" />
</div>
)}
</div>
<div className="mb-3">
<label className="form-label">Обложка</label>
<input
type="file"
className="form-control"
accept="image/*"
onChange={e => setProfileForm(f => ({ ...f, coverFile: e.target.files?.[0] || null }))}
/>
</div>
</div>
</div>
</div>
<div className="modal-footer">
<button
type="button"
className="btn btn-secondary"
onClick={() => setShowProfileModal(false)}
>
Отмена
</button>
<button
type="button"
className="btn btn-primary"
onClick={handleProfileSubmit}
>
Сохранить
</button>
</div>
</div>
</div>
</div>
{/* Панель кастомизации */}
{showCustomizationPanel && (
<CustomizationPanel
isOpen={showCustomizationPanel}
onClose={() => {
console.log('CustomizationPanel onClose called')
setShowCustomizationPanel(false)
}}
onSettingsUpdate={(newSettings: DesignSettings) => {
console.log('CustomizationPanel onSettingsUpdate called', newSettings)
console.log('Setting new layout:', newSettings.dashboard_layout)
setDesignSettings(newSettings)
setShowCustomizationPanel(false)
}}
/>
)}
</div>
)
}