'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 } export default function DashboardClient() { const router = useRouter() const [user, setUser] = useState(null) const [groups, setGroups] = useState([]) const [expandedGroup, setExpandedGroup] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) // Состояния для кастомизации const [designSettings, setDesignSettings] = useState({ 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(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(null) const [currentGroupIdForLink, setCurrentGroupIdForLink] = useState(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
Загрузка...
if (error) return
{error}
// Функция расчета оптимального размера изображения для группы 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 = () => (
Группы ссылок
{groups.map(group => (
setExpandedGroup(expandedGroup === group.id ? null : group.id)} > {group.icon_url && designSettings.show_group_icons && ( {group.name} )} {group.name} {group.links.length}
{expandedGroup === group.id && renderGroupLinks(group)}
))}
) // Сетка карточек const renderGridLayout = () => (
Группы ссылок
{groups.map(group => (
{group.icon_url && designSettings.show_group_icons && ( {group.name} )}
{group.name}
{group.links.length}
{group.links.slice(0, 3).map(link => (
{link.icon_url && ( {link.title} )} {link.title}
))} {group.links.length > 3 && ( +{group.links.length - 3} еще... )}
))}
) // Компактный макет const renderCompactLayout = () => (
Группы ссылок
{groups.map(group => (
{group.icon_url && designSettings.show_group_icons && ( {group.name} )} {group.name} {group.links.length}
))}
) // Большие карточки const renderCardsLayout = () => (
Группы ссылок
{groups.map(group => (
{group.icon_url && designSettings.show_group_icons && ( {group.name} )}
{group.name}
{group.links.length} ссылок
{group.links.map(link => (
{link.icon_url && ( {link.title} )}
{link.title}
{link.url}
))}
))}
) // Боковая панель const renderSidebarLayout = () => (
Группы
{groups.map(group => ( ))}
{expandedGroup && groups.find(g => g.id === expandedGroup) && (
{groups.find(g => g.id === expandedGroup)?.name}
{groups.find(g => g.id === expandedGroup)?.links.map(link => (
{link.icon_url && ( {link.title} )}
{link.title}
{link.url}
))}
)}
) // Кладка (Masonry) const renderMasonryLayout = () => (
Группы ссылок
{groups.map((group, index) => (
{group.icon_url && designSettings.show_group_icons && ( {group.name} )}
{group.name}
{group.links.length}
{group.links.map(link => (
{link.icon_url && ( {link.title} )} {link.title}
))}
))}
) // Лента времени const renderTimelineLayout = () => (
Группы ссылок
{groups.map((group, index) => (
{group.icon_url && designSettings.show_group_icons && ( {group.name} )}
{group.name}
{group.links.length} ссылок
{group.links.slice(0, 5).map(link => (
{link.icon_url && ( {link.title} )} {link.title}
))}
))}
) // Журнальный макет const renderMagazineLayout = () => (
Группы ссылок
{groups.map((group, index) => (
{group.icon_url && designSettings.show_group_icons ? ( {group.name} ) : ( )}
{group.name}

{group.links.length} ссылок в этой группе

{group.links.slice(0, 3).map(link => (
• {link.title}
))} {group.links.length > 3 && ( и еще {group.links.length - 3}... )}
))}
) // Функция рендеринга ссылок группы const renderGroupLinks = (group: Group) => (
{group.links.map(link => (
{link.icon_url && ( {link.title} )} {link.title}
))}
) return (
{user && ( )}

Ваши ссылки

Panel state: {showCustomizationPanel ? 'Open' : 'Closed'}
{renderGroupsLayout()}
{/* Модалка добавления/редактирования группы */}
{groupModalMode === 'add' ? 'Добавить группу' : 'Редактировать группу'}
setGroupForm(f => ({ ...f, name: e.target.value }))} />