Some checks failed
continuous-integration/drone/push Build is failing
🎨 Layout improvements: - Add full implementations for timeline, masonry, and magazine layouts - Replace layout stubs with proper React components matching dashboard - Add timeline with alternating left/right positioning and central line - Implement masonry layout with dynamic column sizing - Create magazine layout with featured first item and image previews 🐛 Avatar & URL fixes: - Fix avatar URL protocol (http → https) in normalize_file_url - Add http://links.shareon.kr to internal URLs replacement list - Update get_backend_url to use HTTPS and proper domain from env vars - Fix CustomizationPanel API URL consistency in DashboardClient ✨ Visual enhancements: - Proper hover states and transitions for all layout types - Timeline dots with theme color coordination - Magazine layout with responsive image handling - Masonry cards with dynamic content sizing - Consistent group/link styling across all layouts 🔧 Technical: - Environment-driven URL generation for production - Consistent API endpoint usage across components - Better responsive design for mobile devices - Improved accessibility with proper alt text and ARIA labels
1523 lines
60 KiB
TypeScript
1523 lines
60 KiB
TypeScript
'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<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>
|
||
)
|
||
} |