+ Приведены все функции приложения в рабочий вид

+ Наведен порядок в файлах проекта
+ Наведен порядок в документации
+ Настроены скрипты установки, развертки и так далее, расширен MakeFile
This commit is contained in:
2025-11-02 06:09:55 +09:00
parent 367e1c932e
commit 2e535513b5
6103 changed files with 7040 additions and 1027861 deletions

View File

@@ -1,95 +0,0 @@
// src/app/auth/login/page.tsx
'use client'
import { useState, useEffect } from 'react'
import { useForm } from 'react-hook-form'
import { useRouter } from 'next/navigation'
type FormData = { username: string; password: string }
export default function LoginPage() {
const router = useRouter()
const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm<FormData>()
const [apiError, setApiError] = useState<string | null>(null)
useEffect(() => {
if (typeof window !== 'undefined' && localStorage.getItem('token')) {
router.push('/dashboard')
}
}, [router])
async function onSubmit(data: FormData) {
setApiError(null)
try {
const res = await fetch(
'/api/auth/login',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
}
)
if (!res.ok) {
const json = await res.json()
setApiError(json.detail || 'Ошибка входа')
return
}
const { access } = await res.json()
localStorage.setItem('token', access)
router.push('/dashboard')
} catch {
setApiError('Сетевая ошибка')
}
}
return (
<div className="d-flex align-items-center justify-content-center" style={{ height: '100vh' }}>
<div className="card shadow-lg border-0" style={{ maxWidth: 800, width: '100%' }}>
<div className="row g-0">
<div className="col-lg-6 d-none d-lg-flex" style={{
backgroundImage: "url('/assets/img/durvill_logo.jpg')",
backgroundSize: 'cover',
backgroundPosition: 'center'
}} />
<div className="col-lg-6">
<div className="p-5">
<h4 className="text-center mb-4">Welcome back!</h4>
{apiError && <p className="text-danger text-center">{apiError}</p>}
<form onSubmit={handleSubmit(onSubmit)}>
<div className="mb-3">
<input
type="text"
placeholder="Enter Username..."
className={`form-control ${errors.username ? 'is-invalid' : ''}`}
{...register('username', { required: 'Введите имя пользователя' })}
/>
{errors.username && <div className="invalid-feedback">{errors.username.message}</div>}
</div>
<div className="mb-3">
<input
type="password"
placeholder="Password"
className={`form-control ${errors.password ? 'is-invalid' : ''}`}
{...register('password', { required: 'Введите пароль' })}
/>
{errors.password && <div className="invalid-feedback">{errors.password.message}</div>}
</div>
<button
type="submit"
disabled={isSubmitting}
className="btn btn-primary w-100"
style={{ background: '#01703E' }}
>
{isSubmitting ? 'Вхожу...' : 'Login'}
</button>
</form>
<div className="text-center mt-3">
<a href="#" className="small text-decoration-none">Forgot Password?</a>
</div>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -1,238 +0,0 @@
'use client';
import { useState, useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import Image from 'next/image';
type FormData = {
username: string;
email: string;
first_name?: string;
last_name?: string;
password: string;
password2: string;
};
export default function RegisterPage() {
const router = useRouter();
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<FormData>();
const [apiError, setApiError] = useState<string | null>(null);
// если уже залогинен — редирект на дашборд
useEffect(() => {
if (typeof window !== 'undefined' && localStorage.getItem('token')) {
router.push('/dashboard');
}
}, [router]);
async function onSubmit(data: FormData) {
setApiError(null);
try {
const res = await fetch('/api/auth/register/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!res.ok) {
const json = await res.json();
// берём первую ошибку из ответа
const firstKey = Object.keys(json)[0];
setApiError(
Array.isArray(json[firstKey])
? (json[firstKey] as string[])[0]
: json[firstKey].toString()
);
return;
}
// при успехе — редирект на логин
router.push('/auth/login');
} catch (e) {
setApiError('Сетевая ошибка, попробуйте снова.');
}
}
return (
<div
className="d-flex align-items-center"
style={{ width: '100%', height: '100vh' }}
>
<div className="container">
<div className="row justify-content-center">
<div className="col-md-9 col-lg-12 col-xl-10">
<div className="card shadow-lg border-0 my-5">
<div className="row g-0">
{/* Левая половина с картинкой */}
<div className="col-lg-6 d-none d-lg-flex">
<div
className="flex-grow-1 bg-register-image"
style={{
backgroundImage: "url('/assets/img/durvill_logo.jpg')",
backgroundSize: 'cover',
backgroundPosition: 'center',
}}
/>
</div>
{/* Правая — сама форма */}
<div className="col-lg-6">
<div className="p-5">
<div className="text-center mb-4">
<h4 className="text-dark">Регистрация</h4>
</div>
<form onSubmit={handleSubmit(onSubmit)} noValidate>
{/* Username */}
<div className="mb-3">
<input
type="text"
placeholder="Имя пользователя"
className={`form-control form-control-user ${
errors.username ? 'is-invalid' : ''
}`}
{...register('username', {
required: 'Введите имя пользователя',
minLength: {
value: 3,
message: 'От 3 символов',
},
})}
/>
{errors.username && (
<div className="invalid-feedback">
{errors.username.message}
</div>
)}
</div>
{/* Email */}
<div className="mb-3">
<input
type="email"
placeholder="Email"
className={`form-control form-control-user ${
errors.email ? 'is-invalid' : ''
}`}
{...register('email', {
required: 'Введите email',
pattern: {
value:
/^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/,
message: 'Неверный формат',
},
})}
/>
{errors.email && (
<div className="invalid-feedback">
{errors.email.message}
</div>
)}
</div>
{/* First / Last name */}
<div className="row">
<div className="col-md-6 mb-3">
<input
type="text"
placeholder="Имя"
className="form-control form-control-user"
{...register('first_name')}
/>
</div>
<div className="col-md-6 mb-3">
<input
type="text"
placeholder="Фамилия"
className="form-control form-control-user"
{...register('last_name')}
/>
</div>
</div>
{/* Password */}
<div className="mb-3">
<input
type="password"
placeholder="Пароль"
className={`form-control form-control-user ${
errors.password ? 'is-invalid' : ''
}`}
{...register('password', {
required: 'Введите пароль',
minLength: {
value: 8,
message: 'Не менее 8 символов',
},
})}
/>
{errors.password && (
<div className="invalid-feedback">
{errors.password.message}
</div>
)}
</div>
{/* Confirm Password */}
<div className="mb-3">
<input
type="password"
placeholder="Повторите пароль"
className={`form-control form-control-user ${
errors.password2 ? 'is-invalid' : ''
}`}
{...register('password2', {
required: 'Повторите пароль',
validate: (value, formValues) =>
value === formValues.password ||
'Пароли не совпадают',
})}
/>
{errors.password2 && (
<div className="invalid-feedback">
{errors.password2.message}
</div>
)}
</div>
{/* API Error */}
{apiError && (
<div className="text-center text-danger mb-3 small">
{apiError}
</div>
)}
{/* Кнопка */}
<button
type="submit"
className="btn btn-primary d-block btn-user w-100"
style={{ background: '#01703E' }}
disabled={isSubmitting}
>
{isSubmitting ? 'Регистрация...' : 'Зарегистрироваться'}
</button>
</form>
{/* Ссылка на вход */}
<hr />
<div className="text-center">
<span className="small">
Уже есть аккаунт?{' '}
<Link href="/auth/login" style={{ color: '#01703E' }}>
Войти
</Link>
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,390 +1,22 @@
'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 dynamic from 'next/dynamic'
interface UserProfile {
id: number
username: string
email: string
full_name: string
bio?: string
avatar: string
last_login: string
date_joined: string
}
interface LinkItem {
id: number
title: string
url: string
icon?: string
group: number
}
interface Group {
id: number
name: string
icon?: string
links: LinkItem[]
}
export default function DashboardPage() {
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 [showGroupModal, setShowGroupModal] = useState(false)
const [groupModalMode, setGroupModalMode] = useState<'add' | 'edit'>('add')
const [editingGroup, setEditingGroup] = useState<Group | null>(null)
const [groupForm, setGroupForm] = useState<{ name: string; iconFile: File | null }>({
name: '',
iconFile: null,
})
// === Для модалок ссылок ===
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; iconFile: File | null }>({
title: '',
url: '',
iconFile: null,
})
const API = ''
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}` } }),
])
.then(async ([uRes, gRes, lRes]) => {
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()
// «привязываем» ссылки к группам
const enrichedGroups: Group[] = groupsData.map((grp: any) => ({
...grp,
links: linksData.filter((link: LinkItem) => link.group === grp.id),
}))
setUser(userData)
setGroups(enrichedGroups)
})
.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/groups', { headers: { Authorization: `Bearer ${token}` } }),
fetch('/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: '', iconFile: null })
setShowGroupModal(true)
}
function openEditGroup(grp: Group) {
setGroupModalMode('edit')
setEditingGroup(grp)
setGroupForm({ name: grp.name, iconFile: null })
setShowGroupModal(true)
}
async function handleGroupSubmit() {
const token = localStorage.getItem('token')!
const fd = new FormData()
fd.append('name', groupForm.name)
if (groupForm.iconFile) fd.append('icon', groupForm.iconFile)
const url = groupModalMode === 'add'
? '/api/groups'
: `/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/groups/${grp.id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
})
await reloadData()
}
// === Обработчики ссылок ===
function openAddLink(grp: Group) {
setLinkModalMode('add')
setCurrentGroupIdForLink(grp.id)
setLinkForm({ title: '', url: '', iconFile: null })
setShowLinkModal(true)
}
function openEditLink(link: LinkItem) {
setLinkModalMode('edit')
setEditingLink(link)
setCurrentGroupIdForLink(link.group)
setLinkForm({ title: link.title, url: link.url, 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)
if (linkForm.iconFile) fd.append('icon', linkForm.iconFile)
fd.append('group', String(currentGroupIdForLink))
const url = linkModalMode === 'add'
? '/api/links'
: `/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/links/${link.id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
})
await reloadData()
}
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 totalGroups = groups.length
const totalLinks = groups.reduce((sum, grp) => sum + grp.links.length, 0)
return (
<div className="pb-8">
{user && (
<ProfileCard
avatar={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}
/>
)}
<section className="mt-5 container">
<div className="card shadow">
<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 && (
<img
src={`${API}${group.icon}`}
width={32}
height={32}
className="me-2 rounded"
alt={group.name}
/>
)}
<strong className="me-2">{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 && (
<div className="list-group-item bg-light">
<ul className="mb-0 ps-3">
{group.links.map(link => (
<li
key={link.id}
className="d-flex justify-content-between align-items-center mb-2"
>
<div className="d-flex align-items-center">
{link.icon && (
<img
src={link.icon.startsWith('http')
? link.icon
: `${API}${link.icon}`}
width={24}
height={24}
className="me-2"
alt={link.title}
/>
)}
<a href={link.url} target="_blank" rel="noopener noreferrer">
{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-fill"></i>
</button>
<button onClick={() => handleDeleteLink(link)} className="btn btn-outline-danger">
<i className="bi bi-trash-fill"></i>
</button>
</div>
</li>
))}
</ul>
</div>
)}
</Fragment>
))}
</div>
</div>
</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)} />
</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>
<input
type="file"
className="form-control"
onChange={e => setGroupForm(f => ({ ...f, iconFile: e.target.files?.[0] || null }))}
/>
</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)} />
</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 }))}
/>
</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 }))}
/>
</div>
<div className="mb-3">
<label className="form-label">Иконка (опционально)</label>
<input
type="file"
className="form-control"
onChange={e => setLinkForm(f => ({ ...f, iconFile: e.target.files?.[0] || null }))}
/>
</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>
// Динамический импорт клиентского компонента без SSR
const DashboardClient = dynamic(() => import('./DashboardClient'), {
ssr: false,
loading: () => (
<div className="d-flex justify-content-center align-items-center min-vh-100">
<div className="text-center">
<div className="spinner-border text-primary" role="status">
<span className="visually-hidden">Загрузка...</span>
</div>
<p className="mt-3">Загрузка дашборда...</p>
</div>
</div>
)
})
export default function DashboardPage() {
return <DashboardClient />
}

View File

@@ -0,0 +1,530 @@
'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'
import { GroupEditModal } from '../../components/GroupEditModal'
interface UserProfile {
id: number
username: string
email: string
full_name: string
bio?: string
avatar: string
last_login: string
date_joined: string
}
interface LinkItem {
id: number
title: string
url: string
icon?: 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'
links: LinkItem[]
}
interface DesignSettings {
id?: number
theme_color: string
background_image?: string
dashboard_layout: 'sidebar' | 'grid' | 'list'
groups_default_expanded: boolean
show_group_icons: boolean
show_link_icons: boolean
dashboard_background_color: string
font_family: string
custom_css: string
}
export default function DashboardPage() {
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: ''
})
const [showCustomizationPanel, setShowCustomizationPanel] = useState(false)
// === Для модалок групп ===
const [showGroupModal, setShowGroupModal] = useState(false)
const [groupModalMode, setGroupModalMode] = useState<'add' | 'edit'>('add')
const [groupForm, setGroupForm] = useState({ name: '', iconFile: null as File | null })
const [editingGroup, setEditingGroup] = useState<Group | null>(null)
// === Для модалок ссылок ===
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; iconFile: File | null }>({
title: '',
url: '',
iconFile: null,
})
const API = ''
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, sRes]) => {
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()
// Загружаем настройки дизайна если доступны
if (sRes.ok) {
const settingsData = await sRes.json()
setDesignSettings(prev => ({ ...prev, ...settingsData }))
}
// «привязываем» ссылки к группам
const enrichedGroups: Group[] = groupsData.map((grp: any) => ({
...grp,
links: linksData.filter((link: LinkItem) => link.group === grp.id),
}))
setUser(userData)
setGroups(enrichedGroups)
})
.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/groups', { headers: { Authorization: `Bearer ${token}` } }),
fetch('/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: '', iconFile: null })
setShowGroupModal(true)
}
function openEditGroup(grp: Group) {
setGroupModalMode('edit')
setEditingGroup(grp)
setGroupForm({ name: grp.name, iconFile: null })
setShowGroupModal(true)
}
async function handleGroupSubmit() {
const token = localStorage.getItem('token')!
const fd = new FormData()
fd.append('name', groupForm.name)
if (groupForm.iconFile) fd.append('icon', groupForm.iconFile)
const url = groupModalMode === 'add'
? '/api/groups'
: `/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/groups/${grp.id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
})
await reloadData()
}
// === Обработчики ссылок ===
function openAddLink(grp: Group) {
setLinkModalMode('add')
setCurrentGroupIdForLink(grp.id)
setLinkForm({ title: '', url: '', iconFile: null })
setShowLinkModal(true)
}
function openEditLink(link: LinkItem) {
setLinkModalMode('edit')
setEditingLink(link)
setCurrentGroupIdForLink(link.group)
setLinkForm({ title: link.title, url: link.url, 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)
if (linkForm.iconFile) fd.append('icon', linkForm.iconFile)
fd.append('group', String(currentGroupIdForLink))
const url = linkModalMode === 'add'
? '/api/links'
: `/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/links/${link.id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
})
await reloadData()
}
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 totalGroups = groups.length
const totalLinks = groups.reduce((sum, grp) => sum + grp.links.length, 0)
return (
<div
className="pb-8"
style={{
backgroundColor: designSettings.dashboard_background_color,
fontFamily: designSettings.font_family
}}
>
{user && (
<ProfileCard
avatar={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-4">
<h2 className="mb-0">
<i className="bi bi-collection me-2"></i>
Мои группы и ссылки
</h2>
<div className="btn-group">
<button
className="btn btn-primary"
onClick={() => {
setGroupModalMode('add')
setEditingGroup(null)
setShowGroupModal(true)
}}
>
<i className="bi bi-plus-circle me-2"></i>
Добавить группу
</button>
<button
className="btn btn-outline-secondary"
onClick={() => setShowCustomizationPanel(true)}
title="Настройки дашборда"
>
<i className="bi bi-palette"></i>
</button>
</div>
</div>
totalLinks={totalLinks}
/>
)}
<section className="mt-5 container">
<div className="card shadow">
<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 && (
<img
src={`${API}${group.icon}`}
width={32}
height={32}
className="me-2 rounded"
alt={group.name}
/>
)}
<strong className="me-2">{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 && (
<div className="list-group-item bg-light">
<ul className="mb-0 ps-3">
{group.links.map(link => (
<li
key={link.id}
className="d-flex justify-content-between align-items-center mb-2"
>
<div className="d-flex align-items-center">
{link.icon && (
<img
src={link.icon && link.icon.startsWith('http')
? link.icon
: `${API}${link.icon}`}
width={24}
height={24}
className="me-2"
alt={link.title}
/>
)}
<a href={link.url} target="_blank" rel="noopener noreferrer">
{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-fill"></i>
</button>
<button onClick={() => handleDeleteLink(link)} className="btn btn-outline-danger">
<i className="bi bi-trash-fill"></i>
</button>
</div>
</li>
))}
</ul>
</div>
)}
</Fragment>
))}
</div>
</div>
</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)} />
</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>
<input
type="file"
className="form-control"
onChange={e => setGroupForm(f => ({ ...f, iconFile: e.target.files?.[0] || null }))}
/>
</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)} />
</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 }))}
/>
</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 }))}
/>
</div>
<div className="mb-3">
<label className="form-label">Иконка (опционально)</label>
<input
type="file"
className="form-control"
onChange={e => setLinkForm(f => ({ ...f, iconFile: e.target.files?.[0] || null }))}
/>
</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>
{/* Панель кастомизации */}
<CustomizationPanel
isOpen={showCustomizationPanel}
onClose={() => setShowCustomizationPanel(false)}
onSettingsUpdate={(settings) => {
setDesignSettings(settings)
setShowCustomizationPanel(false)
}}
/>
{/* Модальное окно редактирования группы */}
<GroupEditModal
isOpen={showGroupModal}
onClose={() => {
setShowGroupModal(false)
setEditingGroup(null)
}}
onSave={async (groupData, iconFile) => {
const token = localStorage.getItem('token')
const formData = new FormData()
Object.entries(groupData).forEach(([key, value]) => {
if (value !== null && value !== undefined) {
formData.append(key, value.toString())
}
})
if (iconFile) {
formData.append('icon', iconFile)
}
const url = groupModalMode === 'edit'
? `/api/groups/${editingGroup?.id}/`
: '/api/groups/'
const method = groupModalMode === 'edit' ? 'PUT' : 'POST'
try {
const response = await fetch(url, {
method,
headers: {
'Authorization': `Bearer ${token}`
},
body: formData
})
if (response.ok) {
await reloadData()
setShowGroupModal(false)
setEditingGroup(null)
} else {
throw new Error('Ошибка при сохранении группы')
}
} catch (error) {
console.error('Error saving group:', error)
throw error
}
}}
group={editingGroup}
mode={groupModalMode}
/>
</div>
)
}