+ Приведены все функции приложения в рабочий вид
+ Наведен порядок в файлах проекта + Наведен порядок в документации + Настроены скрипты установки, развертки и так далее, расширен MakeFile
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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 />
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,139 +1,746 @@
|
||||
// src/app/[username]/page.tsx
|
||||
'use client'
|
||||
|
||||
import { notFound } from 'next/navigation'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
interface LinkItem {
|
||||
id: number
|
||||
title: string
|
||||
url: string
|
||||
image?: string
|
||||
icon?: string
|
||||
icon_url?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
interface Group {
|
||||
id: number
|
||||
name: string
|
||||
image?: string
|
||||
description?: string
|
||||
icon?: string
|
||||
icon_url?: string
|
||||
header_color?: string
|
||||
background_image?: string
|
||||
is_favorite: boolean
|
||||
links: LinkItem[]
|
||||
}
|
||||
|
||||
interface UserGroupsData {
|
||||
username: string
|
||||
full_name?: string
|
||||
bio?: string
|
||||
avatar?: string
|
||||
cover?: string
|
||||
design_settings: PublicDesignSettings
|
||||
groups: Group[]
|
||||
}
|
||||
|
||||
export default async function UserPage({
|
||||
interface PublicDesignSettings {
|
||||
theme_color: string
|
||||
background_image?: string
|
||||
dashboard_layout: string
|
||||
groups_default_expanded: boolean
|
||||
show_group_icons: boolean
|
||||
show_link_icons: boolean
|
||||
dashboard_background_color: string
|
||||
font_family: 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 UserPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ username: string }>
|
||||
}) {
|
||||
const { username } = await params
|
||||
const API = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'
|
||||
|
||||
const res = await fetch(`${API}/api/users/${username}/public`, {
|
||||
cache: 'no-store',
|
||||
const [username, setUsername] = useState<string>('')
|
||||
const [data, setData] = useState<UserGroupsData | null>(null)
|
||||
const [designSettings, setDesignSettings] = useState<PublicDesignSettings>({
|
||||
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',
|
||||
header_text_color: '#000000',
|
||||
group_text_color: '#333333',
|
||||
link_text_color: '#666666'
|
||||
})
|
||||
if (res.status === 404) return notFound()
|
||||
if (!res.ok) throw new Error('Ошибка загрузки публичных данных')
|
||||
const [expandedGroups, setExpandedGroups] = useState<Set<number>>(new Set())
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const data: UserGroupsData = await res.json()
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
const resolvedParams = await params
|
||||
const usernameValue = resolvedParams.username
|
||||
setUsername(usernameValue)
|
||||
|
||||
return (
|
||||
<main className="pb-8">
|
||||
<div className="container">
|
||||
<h2 className="text-center mb-4">{data.username}</h2>
|
||||
// Определяем API URL в зависимости от окружения
|
||||
const API = typeof window !== 'undefined'
|
||||
? (process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000') // клиент
|
||||
: 'http://web:8000' // сервер в Docker
|
||||
|
||||
<div className="accordion" id="groupsAccordion">
|
||||
{data.groups.map((group) => {
|
||||
const groupId = `group-${group.id}`
|
||||
console.log('Loading data for user:', usernameValue)
|
||||
console.log('API URL:', API)
|
||||
console.log('Is client side:', typeof window !== 'undefined')
|
||||
|
||||
return (
|
||||
<div
|
||||
key={group.id}
|
||||
className="accordion-item mb-3"
|
||||
style={{ border: '1px solid #dee2e6', borderRadius: 4 }}
|
||||
try {
|
||||
// Загружаем только данные пользователя (они уже включают настройки дизайна)
|
||||
const userRes = await fetch(`${API}/api/users/${usernameValue}/public/`, {
|
||||
cache: 'no-store',
|
||||
})
|
||||
|
||||
console.log('User response status:', userRes.status)
|
||||
|
||||
if (userRes.status === 404) {
|
||||
notFound()
|
||||
return
|
||||
}
|
||||
if (!userRes.ok) {
|
||||
throw new Error('Ошибка загрузки публичных данных')
|
||||
}
|
||||
|
||||
const userData: UserGroupsData = await userRes.json()
|
||||
setData(userData)
|
||||
|
||||
// Используем настройки дизайна из ответа пользователя
|
||||
if (userData.design_settings) {
|
||||
setDesignSettings(userData.design_settings)
|
||||
|
||||
// Если группы должны быть развернуты по умолчанию
|
||||
if (userData.design_settings.groups_default_expanded) {
|
||||
setExpandedGroups(new Set(userData.groups.map(g => g.id)))
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading data:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadData()
|
||||
}, [params])
|
||||
|
||||
// Функция для переключения видимости группы (для сайдбар-макета)
|
||||
const toggleGroup = (groupId: number) => {
|
||||
setExpandedGroups(prev => {
|
||||
const newSet = new Set(prev)
|
||||
if (newSet.has(groupId)) {
|
||||
newSet.delete(groupId)
|
||||
} else {
|
||||
newSet.add(groupId)
|
||||
}
|
||||
return newSet
|
||||
})
|
||||
}
|
||||
|
||||
// Базовый список (по умолчанию)
|
||||
const renderListLayout = () => (
|
||||
<div className="row">
|
||||
{data!.groups.map((group) => {
|
||||
const isExpanded = expandedGroups.has(group.id)
|
||||
return (
|
||||
<div key={group.id} className="col-12 mb-4">
|
||||
<div className="card shadow-sm">
|
||||
<div
|
||||
className="card-header d-flex align-items-center justify-content-between"
|
||||
onClick={() => toggleGroup(group.id)}
|
||||
style={{
|
||||
backgroundColor: group.header_color || designSettings.theme_color + '20',
|
||||
borderColor: group.header_color || designSettings.theme_color,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
<h2 className="accordion-header" id={`${groupId}-header`}>
|
||||
<button
|
||||
className="accordion-button collapsed d-flex align-items-center"
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target={`#${groupId}-collapse`}
|
||||
aria-expanded="false"
|
||||
aria-controls={`${groupId}-collapse`}
|
||||
>
|
||||
{group.image && (
|
||||
<Image
|
||||
src={
|
||||
group.image.startsWith('http')
|
||||
? group.image
|
||||
: `${API}${group.image}`
|
||||
}
|
||||
alt={group.name}
|
||||
width={32}
|
||||
height={32}
|
||||
className="me-2 rounded"
|
||||
priority
|
||||
/>
|
||||
<div className="d-flex align-items-center flex-grow-1">
|
||||
{designSettings.show_group_icons && group.icon && (
|
||||
<img
|
||||
src={group.icon}
|
||||
alt=""
|
||||
width={32}
|
||||
height={32}
|
||||
className="me-2 rounded"
|
||||
/>
|
||||
)}
|
||||
<h5 className="mb-0 flex-grow-1" style={{ color: designSettings.group_text_color || designSettings.theme_color }}>
|
||||
{group.name}
|
||||
</h5>
|
||||
<div className="d-flex align-items-center ms-2">
|
||||
{group.is_favorite && (
|
||||
<i className="bi bi-star-fill text-warning me-2" title="Избранная группа"></i>
|
||||
)}
|
||||
<span className="me-2">{group.name}</span>
|
||||
<span className="badge bg-secondary rounded-pill">
|
||||
<span
|
||||
className="badge rounded-pill me-2"
|
||||
style={{
|
||||
backgroundColor: designSettings.theme_color,
|
||||
color: 'white'
|
||||
}}
|
||||
>
|
||||
{group.links.length}
|
||||
</span>
|
||||
</button>
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
<i className={`bi ${isExpanded ? 'bi-chevron-up' : 'bi-chevron-down'} ms-2`}></i>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div
|
||||
id={`${groupId}-collapse`}
|
||||
className="accordion-collapse collapse"
|
||||
aria-labelledby={`${groupId}-header`}
|
||||
data-bs-parent="#groupsAccordion"
|
||||
className="card-body"
|
||||
style={{
|
||||
backgroundImage: group.background_image ? `url(${group.background_image})` : 'none',
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center'
|
||||
}}
|
||||
>
|
||||
<div className="accordion-body">
|
||||
{group.links.length > 0 ? (
|
||||
<ul className="list-unstyled">
|
||||
{group.links.map((link) => (
|
||||
<li
|
||||
key={link.id}
|
||||
className="mb-2 p-2 bg-white rounded shadow-sm"
|
||||
style={{ marginBottom: 5 }}
|
||||
{group.description && (
|
||||
<p className="text-muted mb-3">{group.description}</p>
|
||||
)}
|
||||
{group.links.length > 0 ? (
|
||||
<div className="row">
|
||||
{group.links.map((link) => (
|
||||
<div key={link.id} className="col-12 col-md-6 col-lg-4 mb-3">
|
||||
<Link
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="d-block text-decoration-none"
|
||||
>
|
||||
<div className="d-flex align-items-center">
|
||||
{link.image && (
|
||||
<Image
|
||||
src={
|
||||
link.image.startsWith('http')
|
||||
? link.image
|
||||
: `${API}${link.image}`
|
||||
}
|
||||
alt={link.title}
|
||||
width={24}
|
||||
height={24}
|
||||
className="me-2"
|
||||
priority
|
||||
/>
|
||||
)}
|
||||
<Link
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex-grow-1"
|
||||
>
|
||||
{link.title}
|
||||
</Link>
|
||||
<div className="card h-100 shadow-sm border-start border-3 hover-shadow"
|
||||
style={{
|
||||
borderColor: designSettings.theme_color + '60',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
>
|
||||
<div className="card-body d-flex align-items-center">
|
||||
{designSettings.show_link_icons && link.icon_url && (
|
||||
<Image
|
||||
src={link.icon_url}
|
||||
alt=""
|
||||
width={24}
|
||||
height={24}
|
||||
className="me-2 rounded"
|
||||
priority
|
||||
/>
|
||||
)}
|
||||
<div className="flex-grow-1">
|
||||
<h6 className="card-title mb-1" style={{ color: designSettings.link_text_color || designSettings.theme_color }}>
|
||||
{link.title}
|
||||
</h6>
|
||||
{link.description && (
|
||||
<p className="card-text small text-muted mb-0">{link.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-muted mb-0">
|
||||
В этой группе пока нет ссылок.
|
||||
</p>
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted mb-0">
|
||||
В этой группе пока нет ссылок.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
|
||||
// Сетка групп
|
||||
const renderGridLayout = () => (
|
||||
<div className="row">
|
||||
{data!.groups.map((group) => (
|
||||
<div key={group.id} className="col-12 col-md-6 col-lg-4 mb-4">
|
||||
<div className="card h-100 shadow-sm">
|
||||
<div
|
||||
className="card-header text-center"
|
||||
style={{
|
||||
backgroundColor: group.header_color || designSettings.theme_color + '20',
|
||||
borderColor: group.header_color || designSettings.theme_color
|
||||
}}
|
||||
>
|
||||
{designSettings.show_group_icons && group.icon && (
|
||||
<img
|
||||
src={group.icon}
|
||||
alt=""
|
||||
width={40}
|
||||
height={40}
|
||||
className="rounded-circle mb-2"
|
||||
/>
|
||||
)}
|
||||
<h6 className="mb-0" style={{ color: designSettings.group_text_color || designSettings.theme_color }}>
|
||||
{group.name}
|
||||
</h6>
|
||||
{group.is_favorite && (
|
||||
<i className="bi bi-star-fill text-warning mt-1"></i>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="card-body"
|
||||
style={{
|
||||
backgroundImage: group.background_image ? `url(${group.background_image})` : 'none',
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center'
|
||||
}}
|
||||
>
|
||||
{group.description && (
|
||||
<p className="text-muted small mb-3">{group.description}</p>
|
||||
)}
|
||||
<div className="d-grid gap-2">
|
||||
{group.links.map((link) => (
|
||||
<Link
|
||||
key={link.id}
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn btn-outline-primary btn-sm d-flex align-items-center"
|
||||
style={{
|
||||
borderColor: designSettings.theme_color,
|
||||
color: designSettings.link_text_color || designSettings.theme_color
|
||||
}}
|
||||
>
|
||||
{designSettings.show_link_icons && link.icon && (
|
||||
<img
|
||||
src={link.icon}
|
||||
alt=""
|
||||
width={16}
|
||||
height={16}
|
||||
className="me-2 rounded"
|
||||
/>
|
||||
)}
|
||||
{link.title}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
// Карточки (большие карточки с описанием)
|
||||
const renderCardsLayout = () => (
|
||||
<div className="row">
|
||||
{data!.groups.map((group) => (
|
||||
<div key={group.id} className="col-12 col-lg-6 mb-4">
|
||||
<div className="card h-100 shadow">
|
||||
<div
|
||||
className="card-header d-flex align-items-center"
|
||||
style={{
|
||||
backgroundColor: group.header_color || designSettings.theme_color + '20',
|
||||
borderColor: group.header_color || designSettings.theme_color
|
||||
}}
|
||||
>
|
||||
{designSettings.show_group_icons && group.icon && (
|
||||
<img
|
||||
src={group.icon}
|
||||
alt=""
|
||||
width={48}
|
||||
height={48}
|
||||
className="me-3 rounded"
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<h5 className="mb-1" style={{ color: designSettings.group_text_color || designSettings.theme_color }}>
|
||||
{group.name}
|
||||
</h5>
|
||||
{group.description && (
|
||||
<p className="text-muted mb-0 small">{group.description}</p>
|
||||
)}
|
||||
</div>
|
||||
{group.is_favorite && (
|
||||
<i className="bi bi-star-fill text-warning ms-auto"></i>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="card-body"
|
||||
style={{
|
||||
backgroundImage: group.background_image ? `url(${group.background_image})` : 'none',
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center'
|
||||
}}
|
||||
>
|
||||
<div className="row">
|
||||
{group.links.map((link) => (
|
||||
<div key={link.id} className="col-12 col-md-6 mb-2">
|
||||
<Link
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="d-flex align-items-center p-2 border rounded text-decoration-none hover-shadow"
|
||||
style={{
|
||||
borderColor: designSettings.theme_color + '40',
|
||||
color: designSettings.link_text_color || designSettings.theme_color
|
||||
}}
|
||||
>
|
||||
{designSettings.show_link_icons && link.icon && (
|
||||
<img
|
||||
src={link.icon}
|
||||
alt=""
|
||||
width={24}
|
||||
height={24}
|
||||
className="me-2 rounded"
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<h6 className="mb-0" style={{ color: designSettings.link_text_color || designSettings.theme_color }}>
|
||||
{link.title}
|
||||
</h6>
|
||||
{link.description && (
|
||||
<small className="text-muted">{link.description}</small>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
// Компактный макет
|
||||
const renderCompactLayout = () => (
|
||||
<div className="row">
|
||||
{data!.groups.map((group) => (
|
||||
<div key={group.id} className="col-12 mb-3">
|
||||
<div className="border-start border-4 ps-3" style={{ borderColor: group.header_color || designSettings.theme_color }}>
|
||||
<div className="d-flex align-items-center mb-2">
|
||||
{designSettings.show_group_icons && group.icon_url && (
|
||||
<Image
|
||||
src={group.icon_url}
|
||||
alt=""
|
||||
width={32}
|
||||
height={32}
|
||||
className="me-2 rounded"
|
||||
priority
|
||||
/>
|
||||
)}
|
||||
<h6 className="mb-0" style={{ color: designSettings.group_text_color || designSettings.theme_color }}>
|
||||
{group.name}
|
||||
</h6>
|
||||
{group.is_favorite && (
|
||||
<i className="bi bi-star-fill text-warning ms-2"></i>
|
||||
)}
|
||||
</div>
|
||||
<div className="row">
|
||||
{group.links.map((link) => (
|
||||
<div key={link.id} className="col-auto mb-1">
|
||||
<Link
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn btn-outline-secondary btn-sm d-flex align-items-center"
|
||||
style={{
|
||||
borderColor: designSettings.theme_color + '60',
|
||||
color: designSettings.link_text_color || designSettings.theme_color
|
||||
}}
|
||||
>
|
||||
{designSettings.show_link_icons && link.icon && (
|
||||
<img
|
||||
src={link.icon}
|
||||
alt=""
|
||||
width={16}
|
||||
height={16}
|
||||
className="me-1 rounded"
|
||||
/>
|
||||
)}
|
||||
<small className="text-truncate" style={{ color: designSettings.link_text_color || designSettings.theme_color }}>
|
||||
{link.title}
|
||||
</small>
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
// Боковая панель
|
||||
const renderSidebarLayout = () => (
|
||||
<div className="row">
|
||||
<div className="col-12 col-lg-3 mb-4">
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h6 className="mb-0">Группы</h6>
|
||||
</div>
|
||||
<div className="list-group list-group-flush">
|
||||
{data!.groups.map((group) => (
|
||||
<button
|
||||
key={group.id}
|
||||
className={`list-group-item list-group-item-action d-flex align-items-center ${expandedGroups.has(group.id) ? 'active' : ''}`}
|
||||
onClick={() => toggleGroup(group.id)}
|
||||
style={{
|
||||
borderColor: expandedGroups.has(group.id) ? designSettings.theme_color : undefined
|
||||
}}
|
||||
>
|
||||
{designSettings.show_group_icons && group.icon && (
|
||||
<img
|
||||
src={group.icon}
|
||||
alt=""
|
||||
width={24}
|
||||
height={24}
|
||||
className="me-2 rounded"
|
||||
/>
|
||||
)}
|
||||
<span className="flex-grow-1">{group.name}</span>
|
||||
{group.is_favorite && (
|
||||
<i className="bi bi-star-fill text-warning ms-1"></i>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-12 col-lg-9">
|
||||
{data!.groups.filter(g => expandedGroups.has(g.id)).map((group) => (
|
||||
<div key={group.id} className="card mb-4">
|
||||
<div
|
||||
className="card-header"
|
||||
style={{
|
||||
backgroundColor: group.header_color || designSettings.theme_color + '20',
|
||||
borderColor: group.header_color || designSettings.theme_color
|
||||
}}
|
||||
>
|
||||
<h6 className="mb-0" style={{ color: designSettings.group_text_color || designSettings.theme_color }}>
|
||||
{group.name}
|
||||
</h6>
|
||||
{group.description && (
|
||||
<small className="text-muted">{group.description}</small>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="card-body"
|
||||
style={{
|
||||
backgroundImage: group.background_image ? `url(${group.background_image})` : 'none',
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center'
|
||||
}}
|
||||
>
|
||||
<div className="row">
|
||||
{group.links.map((link) => (
|
||||
<div key={link.id} className="col-12 col-md-6 col-lg-4 mb-3">
|
||||
<Link
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="card h-100 text-decoration-none border"
|
||||
style={{ borderColor: designSettings.theme_color + '40' }}
|
||||
>
|
||||
<div className="card-body d-flex align-items-center">
|
||||
{designSettings.show_link_icons && link.icon && (
|
||||
<img
|
||||
src={link.icon}
|
||||
alt=""
|
||||
width={24}
|
||||
height={24}
|
||||
className="me-2 rounded"
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<h6 className="mb-1" style={{ color: designSettings.link_text_color || designSettings.theme_color }}>
|
||||
{link.title}
|
||||
</h6>
|
||||
{link.description && (
|
||||
<small className="text-muted">{link.description}</small>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
// Заглушки для остальных макетов
|
||||
const renderMasonryLayout = () => renderGridLayout()
|
||||
const renderTimelineLayout = () => renderListLayout()
|
||||
const renderMagazineLayout = () => renderCardsLayout()
|
||||
|
||||
// Основная функция рендеринга групп в зависимости от выбранного макета
|
||||
const renderGroupsLayout = () => {
|
||||
switch (designSettings.dashboard_layout) {
|
||||
case 'list':
|
||||
return renderListLayout()
|
||||
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:
|
||||
return renderListLayout()
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<main className="pb-8">
|
||||
<div className="container">
|
||||
<div className="text-center py-5">
|
||||
<div className="spinner-border" role="status">
|
||||
<span className="visually-hidden">Загрузка...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return notFound()
|
||||
}
|
||||
|
||||
// Стили для контейнера
|
||||
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',
|
||||
paddingTop: '2rem', // отступ сверху для рамки фона
|
||||
paddingBottom: '2rem' // отступ снизу для рамки фона
|
||||
}
|
||||
|
||||
return (
|
||||
<main style={containerStyle}>
|
||||
<div className="container">
|
||||
{/* Профиль пользователя */}
|
||||
<div className="row justify-content-center mb-5">
|
||||
<div className="col-12 col-md-8 col-lg-6">
|
||||
<div className="card shadow-lg border-0" style={{
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.95)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
borderRadius: '20px'
|
||||
}}>
|
||||
<div className="card-body text-center p-4">
|
||||
{/* Аватар пользователя */}
|
||||
{data.avatar && (
|
||||
<div className="mb-3 position-relative d-inline-block">
|
||||
<Image
|
||||
src={data.avatar}
|
||||
alt={data.username}
|
||||
width={120}
|
||||
height={120}
|
||||
className="rounded-circle border border-4 shadow-sm"
|
||||
style={{
|
||||
borderColor: designSettings.theme_color,
|
||||
objectFit: 'cover'
|
||||
}}
|
||||
priority
|
||||
/>
|
||||
<div
|
||||
className="position-absolute bottom-0 end-0 rounded-circle border border-2 border-white"
|
||||
style={{
|
||||
width: '24px',
|
||||
height: '24px',
|
||||
backgroundColor: designSettings.theme_color
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Имя пользователя */}
|
||||
<h1 className="mb-2 fw-bold" style={{ color: designSettings.header_text_color || designSettings.theme_color }}>
|
||||
{data.full_name || data.username}
|
||||
</h1>
|
||||
|
||||
{/* Username если есть полное имя */}
|
||||
{data.full_name && (
|
||||
<p className="text-muted mb-2 fs-5">@{data.username}</p>
|
||||
)}
|
||||
|
||||
{/* Биография пользователя */}
|
||||
{data.bio && (
|
||||
<p className="text-muted mb-3 fs-6 px-3">{data.bio}</p>
|
||||
)}
|
||||
|
||||
{/* Статистика */}
|
||||
<div className="row text-center mt-3">
|
||||
<div className="col-6">
|
||||
<div className="fw-bold fs-4" style={{ color: designSettings.theme_color }}>
|
||||
{data.groups.length}
|
||||
</div>
|
||||
<small className="text-muted">Групп</small>
|
||||
</div>
|
||||
<div className="col-6">
|
||||
<div className="fw-bold fs-4" style={{ color: designSettings.theme_color }}>
|
||||
{data.groups.reduce((total, group) => total + group.links.length, 0)}
|
||||
</div>
|
||||
<small className="text-muted">Ссылок</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Обложка пользователя если есть */}
|
||||
{data.cover && (
|
||||
<div className="row justify-content-center mb-4">
|
||||
<div className="col-12 col-lg-10">
|
||||
<div className="card border-0 shadow position-relative">
|
||||
<Image
|
||||
src={data.cover}
|
||||
alt="Обложка"
|
||||
width={800}
|
||||
height={300}
|
||||
className="card-img rounded"
|
||||
style={{ objectFit: 'cover', maxHeight: '300px' }}
|
||||
/>
|
||||
{/* Cover overlay если включен */}
|
||||
{designSettings.cover_overlay_enabled && (
|
||||
<div
|
||||
className="position-absolute top-0 start-0 w-100 h-100 rounded"
|
||||
style={{
|
||||
backgroundColor: designSettings.cover_overlay_color || '#000000',
|
||||
opacity: designSettings.cover_overlay_opacity || 0.3,
|
||||
pointerEvents: 'none'
|
||||
}}
|
||||
></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Группы ссылок */}
|
||||
<div className="row justify-content-center">
|
||||
<div className="col-12">
|
||||
{renderGroupsLayout()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
131
frontend/linktree-frontend/src/app/auth/login/page.tsx
Normal file
131
frontend/linktree-frontend/src/app/auth/login/page.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
// src/app/auth/login/page.tsx
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
|
||||
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(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/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="min-vh-100 d-flex align-items-center justify-content-center bg-light">
|
||||
<div className="container">
|
||||
<div className="row justify-content-center">
|
||||
<div className="col-md-6 col-lg-5">
|
||||
<div className="card shadow-lg border-0 rounded-4">
|
||||
<div className="card-body p-5">
|
||||
<div className="text-center mb-4">
|
||||
<img
|
||||
src="/assets/img/CAT.png"
|
||||
alt="CatLink"
|
||||
width="80"
|
||||
height="80"
|
||||
className="mb-3"
|
||||
/>
|
||||
<h2 className="fw-bold text-primary">Добро пожаловать!</h2>
|
||||
<p className="text-muted">Войдите в свой аккаунт CatLink</p>
|
||||
</div>
|
||||
|
||||
{apiError && (
|
||||
<div className="alert alert-danger" role="alert">
|
||||
{apiError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="mb-3">
|
||||
<label htmlFor="username" className="form-label">Имя пользователя</label>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
placeholder="Введите имя пользователя"
|
||||
className={`form-control form-control-lg ${errors.username ? 'is-invalid' : ''}`}
|
||||
{...register('username', { required: 'Введите имя пользователя' })}
|
||||
/>
|
||||
{errors.username && (
|
||||
<div className="invalid-feedback">{errors.username.message}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<label htmlFor="password" className="form-label">Пароль</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="Введите пароль"
|
||||
className={`form-control form-control-lg ${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 btn-lg w-100 mb-3"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<span className="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
|
||||
Входим...
|
||||
</>
|
||||
) : (
|
||||
'Войти'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="text-center">
|
||||
<p className="text-muted mb-0">
|
||||
Нет аккаунта?{' '}
|
||||
<Link href="/auth/register" className="text-primary text-decoration-none fw-bold">
|
||||
Зарегистрироваться
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
237
frontend/linktree-frontend/src/app/auth/register/page.tsx
Normal file
237
frontend/linktree-frontend/src/app/auth/register/page.tsx
Normal file
@@ -0,0 +1,237 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Image from 'next/image'
|
||||
|
||||
export default function RegisterPage() {
|
||||
const [formData, setFormData] = useState({
|
||||
username: '',
|
||||
email: '',
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
password: '',
|
||||
password2: ''
|
||||
})
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
// Автозаполнение email из главной страницы
|
||||
const quickStartEmail = localStorage.getItem('quickStartEmail')
|
||||
if (quickStartEmail) {
|
||||
setFormData(prev => ({ ...prev, email: quickStartEmail }))
|
||||
localStorage.removeItem('quickStartEmail')
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
[e.target.name]: e.target.value
|
||||
})
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
if (formData.password !== formData.password2) {
|
||||
setError('Пароли не совпадают')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const API = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'
|
||||
const response = await fetch(`${API}/api/auth/register/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(formData),
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
// Регистрация прошла успешно, перенаправляем на страницу входа
|
||||
router.push('/auth/login?message=registration_success')
|
||||
} else {
|
||||
const errorData = await response.json()
|
||||
setError(errorData.message || 'Ошибка регистрации')
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Ошибка соединения с сервером')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-vh-100 d-flex align-items-center bg-light">
|
||||
<div className="container">
|
||||
<div className="row justify-content-center">
|
||||
<div className="col-md-8 col-lg-6 col-xl-5">
|
||||
<div className="card shadow-sm border-0">
|
||||
<div className="card-body p-5">
|
||||
{/* Логотип */}
|
||||
<div className="text-center mb-4">
|
||||
<Image
|
||||
src="/assets/img/CAT.png"
|
||||
alt="CatLink"
|
||||
width={64}
|
||||
height={64}
|
||||
className="mb-3"
|
||||
/>
|
||||
<h2 className="fw-bold text-primary">Создать аккаунт</h2>
|
||||
<p className="text-muted">Присоединяйтесь к CatLink сегодня</p>
|
||||
</div>
|
||||
|
||||
{/* Форма регистрации */}
|
||||
<form onSubmit={handleSubmit}>
|
||||
{error && (
|
||||
<div className="alert alert-danger" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="row">
|
||||
<div className="col-sm-6 mb-3">
|
||||
<label htmlFor="first_name" className="form-label">
|
||||
Имя
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
id="first_name"
|
||||
name="first_name"
|
||||
value={formData.first_name}
|
||||
onChange={handleChange}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="col-sm-6 mb-3">
|
||||
<label htmlFor="last_name" className="form-label">
|
||||
Фамилия
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
id="last_name"
|
||||
name="last_name"
|
||||
value={formData.last_name}
|
||||
onChange={handleChange}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label htmlFor="username" className="form-label">
|
||||
Имя пользователя
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
id="username"
|
||||
name="username"
|
||||
value={formData.username}
|
||||
onChange={handleChange}
|
||||
placeholder="Только латинские буквы, цифры и _"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label htmlFor="email" className="form-label">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
className="form-control"
|
||||
id="email"
|
||||
name="email"
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label htmlFor="password" className="form-label">
|
||||
Пароль
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
className="form-control"
|
||||
id="password"
|
||||
name="password"
|
||||
value={formData.password}
|
||||
onChange={handleChange}
|
||||
minLength={8}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<label htmlFor="password2" className="form-label">
|
||||
Подтвердите пароль
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
className="form-control"
|
||||
id="password2"
|
||||
name="password2"
|
||||
value={formData.password2}
|
||||
onChange={handleChange}
|
||||
minLength={8}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary w-100 py-2 mb-3"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<span className="spinner-border spinner-border-sm me-2"></span>
|
||||
Создание аккаунта...
|
||||
</>
|
||||
) : (
|
||||
'Создать аккаунт'
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="text-center">
|
||||
<span className="text-muted">Уже есть аккаунт? </span>
|
||||
<Link href="/auth/login" className="text-decoration-none">
|
||||
Войти
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Дополнительная информация */}
|
||||
<div className="text-center mt-4">
|
||||
<p className="text-muted small">
|
||||
Создавая аккаунт, вы соглашаетесь с{' '}
|
||||
<Link href="/terms" className="text-decoration-none">
|
||||
Условиями использования
|
||||
</Link>{' '}
|
||||
и{' '}
|
||||
<Link href="/privacy" className="text-decoration-none">
|
||||
Политикой конфиденциальности
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,672 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
|
||||
interface DesignSettings {
|
||||
id?: number
|
||||
theme_color: string
|
||||
background_image?: string
|
||||
background_image_url?: string
|
||||
dashboard_layout: 'sidebar' | 'grid' | 'list' | 'cards' | 'compact' | 'masonry' | 'timeline' | 'magazine'
|
||||
groups_default_expanded: boolean
|
||||
show_group_icons: boolean
|
||||
show_link_icons: boolean
|
||||
dashboard_background_color: string
|
||||
font_family: string
|
||||
custom_css: string
|
||||
group_text_color?: string
|
||||
link_text_color?: string
|
||||
header_text_color?: string
|
||||
cover_overlay_enabled?: boolean
|
||||
cover_overlay_color?: string
|
||||
cover_overlay_opacity?: number
|
||||
}
|
||||
|
||||
interface CustomizationPanelProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onSettingsUpdate: (settings: DesignSettings) => void
|
||||
}
|
||||
|
||||
export function CustomizationPanel({ isOpen, onClose, onSettingsUpdate }: CustomizationPanelProps) {
|
||||
const [settings, setSettings] = useState<DesignSettings>({
|
||||
theme_color: '#ffffff',
|
||||
dashboard_layout: 'list',
|
||||
groups_default_expanded: true,
|
||||
show_group_icons: true,
|
||||
show_link_icons: true,
|
||||
dashboard_background_color: '#f8f9fa',
|
||||
font_family: 'sans-serif',
|
||||
custom_css: '',
|
||||
group_text_color: '#333333',
|
||||
link_text_color: '#666666',
|
||||
header_text_color: '#000000'
|
||||
})
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [activeTab, setActiveTab] = useState<'layout' | 'colors' | 'groups' | 'advanced'>('layout')
|
||||
const [backgroundImageFile, setBackgroundImageFile] = useState<File | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadSettings()
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
const API = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'
|
||||
const response = await fetch(`${API}/api/customization/settings/`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setSettings(data)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading settings:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const saveSettings = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
const API = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'
|
||||
|
||||
// Если есть новый файл фоновой картинки, отправляем через FormData
|
||||
if (backgroundImageFile) {
|
||||
const formData = new FormData()
|
||||
|
||||
// Добавляем все настройки
|
||||
formData.append('theme_color', settings.theme_color)
|
||||
formData.append('dashboard_layout', settings.dashboard_layout)
|
||||
formData.append('groups_default_expanded', settings.groups_default_expanded.toString())
|
||||
formData.append('show_group_icons', settings.show_group_icons.toString())
|
||||
formData.append('show_link_icons', settings.show_link_icons.toString())
|
||||
formData.append('dashboard_background_color', settings.dashboard_background_color)
|
||||
formData.append('font_family', settings.font_family)
|
||||
formData.append('custom_css', settings.custom_css)
|
||||
formData.append('header_text_color', settings.header_text_color || '#000000')
|
||||
formData.append('group_text_color', settings.group_text_color || '#333333')
|
||||
formData.append('link_text_color', settings.link_text_color || '#666666')
|
||||
formData.append('cover_overlay_enabled', (settings.cover_overlay_enabled || false).toString())
|
||||
formData.append('cover_overlay_color', settings.cover_overlay_color || '#000000')
|
||||
formData.append('cover_overlay_opacity', (settings.cover_overlay_opacity || 0.3).toString())
|
||||
formData.append('background_image', backgroundImageFile)
|
||||
|
||||
const response = await fetch(`${API}/api/customization/settings/`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: formData
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const updatedSettings = await response.json()
|
||||
onSettingsUpdate(updatedSettings)
|
||||
setBackgroundImageFile(null) // Сбрасываем выбранный файл
|
||||
onClose()
|
||||
} else {
|
||||
const errorData = await response.json()
|
||||
console.error('Server error:', errorData)
|
||||
}
|
||||
} else {
|
||||
// Если файл не выбран, отправляем только JSON настройки (картинка остается прежней)
|
||||
const editableSettings = {
|
||||
theme_color: settings.theme_color,
|
||||
dashboard_layout: settings.dashboard_layout,
|
||||
groups_default_expanded: settings.groups_default_expanded,
|
||||
show_group_icons: settings.show_group_icons,
|
||||
show_link_icons: settings.show_link_icons,
|
||||
dashboard_background_color: settings.dashboard_background_color,
|
||||
font_family: settings.font_family,
|
||||
custom_css: settings.custom_css,
|
||||
header_text_color: settings.header_text_color || '#000000',
|
||||
header_text_color: settings.header_text_color || '#000000',
|
||||
group_text_color: settings.group_text_color || '#333333',
|
||||
link_text_color: settings.link_text_color || '#666666',
|
||||
cover_overlay_enabled: settings.cover_overlay_enabled || false,
|
||||
cover_overlay_color: settings.cover_overlay_color || '#000000',
|
||||
cover_overlay_opacity: settings.cover_overlay_opacity || 0.3
|
||||
}
|
||||
|
||||
const response = await fetch(`${API}/api/customization/settings/`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify(editableSettings)
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const updatedSettings = await response.json()
|
||||
onSettingsUpdate(updatedSettings)
|
||||
onClose()
|
||||
} else {
|
||||
const errorData = await response.json()
|
||||
console.error('Server error:', errorData)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving settings:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleChange = (field: keyof DesignSettings, value: any) => {
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
[field]: value
|
||||
}))
|
||||
}
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div className="modal fade show d-block" style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}>
|
||||
<div className="modal-dialog modal-lg">
|
||||
<div className="modal-content">
|
||||
<div className="modal-header">
|
||||
<h5 className="modal-title">
|
||||
<i className="bi bi-palette me-2"></i>
|
||||
Настройки дашборда
|
||||
</h5>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-close"
|
||||
onClick={onClose}
|
||||
></button>
|
||||
</div>
|
||||
|
||||
<div className="modal-body">
|
||||
{/* Вкладки */}
|
||||
<ul className="nav nav-tabs mb-3">
|
||||
<li className="nav-item">
|
||||
<button
|
||||
className={`nav-link ${activeTab === 'layout' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('layout')}
|
||||
>
|
||||
<i className="bi bi-layout-sidebar me-1"></i>
|
||||
Макет
|
||||
</button>
|
||||
</li>
|
||||
<li className="nav-item">
|
||||
<button
|
||||
className={`nav-link ${activeTab === 'colors' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('colors')}
|
||||
>
|
||||
<i className="bi bi-palette-fill me-1"></i>
|
||||
Цвета
|
||||
</button>
|
||||
</li>
|
||||
<li className="nav-item">
|
||||
<button
|
||||
className={`nav-link ${activeTab === 'groups' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('groups')}
|
||||
>
|
||||
<i className="bi bi-collection me-1"></i>
|
||||
Группы
|
||||
</button>
|
||||
</li>
|
||||
<li className="nav-item">
|
||||
<button
|
||||
className={`nav-link ${activeTab === 'advanced' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('advanced')}
|
||||
>
|
||||
<i className="bi bi-gear me-1"></i>
|
||||
Дополнительно
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
{/* Содержимое вкладок */}
|
||||
<div className="tab-content">
|
||||
|
||||
{/* Вкладка: Макет */}
|
||||
{activeTab === 'layout' && (
|
||||
<div className="tab-pane fade show active">
|
||||
<div className="row">
|
||||
<div className="col-12 mb-4">
|
||||
<label className="form-label fs-5 mb-3">
|
||||
<i className="bi bi-layout-text-window-reverse me-2"></i>
|
||||
Стиль отображения групп и ссылок
|
||||
</label>
|
||||
<div className="row g-3">
|
||||
{[
|
||||
{
|
||||
value: 'list',
|
||||
label: 'Список',
|
||||
icon: 'bi-list-ul',
|
||||
description: 'Классический вертикальный список'
|
||||
},
|
||||
{
|
||||
value: 'grid',
|
||||
label: 'Сетка',
|
||||
icon: 'bi-grid-3x3',
|
||||
description: 'Равномерная сетка карточек'
|
||||
},
|
||||
{
|
||||
value: 'cards',
|
||||
label: 'Карточки',
|
||||
icon: 'bi-card-heading',
|
||||
description: 'Большие информативные карточки'
|
||||
},
|
||||
{
|
||||
value: 'compact',
|
||||
label: 'Компактный',
|
||||
icon: 'bi-layout-text-sidebar',
|
||||
description: 'Компактное отображение без отступов'
|
||||
},
|
||||
{
|
||||
value: 'sidebar',
|
||||
label: 'Боковая панель',
|
||||
icon: 'bi-layout-sidebar',
|
||||
description: 'Навигация в боковой панели'
|
||||
},
|
||||
{
|
||||
value: 'masonry',
|
||||
label: 'Кладка',
|
||||
icon: 'bi-bricks',
|
||||
description: 'Динамическая сетка разной высоты'
|
||||
},
|
||||
{
|
||||
value: 'timeline',
|
||||
label: 'Лента времени',
|
||||
icon: 'bi-clock-history',
|
||||
description: 'Хронологическое отображение'
|
||||
},
|
||||
{
|
||||
value: 'magazine',
|
||||
label: 'Журнальный',
|
||||
icon: 'bi-newspaper',
|
||||
description: 'Стиль журнала с крупными изображениями'
|
||||
}
|
||||
].map((layout) => (
|
||||
<div key={layout.value} className="col-md-6 col-lg-4">
|
||||
<div
|
||||
className={`card text-center h-100 layout-option ${settings.dashboard_layout === layout.value ? 'border-primary bg-primary bg-opacity-10 selected' : 'border-secondary'}`}
|
||||
style={{ cursor: 'pointer', transition: 'all 0.2s ease' }}
|
||||
onClick={() => handleChange('dashboard_layout', layout.value)}
|
||||
>
|
||||
<div className="card-body d-flex flex-column">
|
||||
<i className={`${layout.icon} fs-1 mb-3 text-primary`}></i>
|
||||
<h6 className="card-title mb-2">{layout.label}</h6>
|
||||
<p className="card-text small text-muted flex-grow-1">{layout.description}</p>
|
||||
{settings.dashboard_layout === layout.value && (
|
||||
<div className="mt-2">
|
||||
<span className="badge bg-primary">
|
||||
<i className="bi bi-check-lg me-1"></i>
|
||||
Выбрано
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-12 mb-3">
|
||||
<div className="alert alert-info">
|
||||
<i className="bi bi-info-circle me-2"></i>
|
||||
<strong>Совет:</strong> Попробуйте разные макеты, чтобы найти наиболее подходящий для вашего контента.
|
||||
Каждый стиль имеет свои преимущества в зависимости от количества ссылок и их типа.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Вкладка: Цвета */}
|
||||
{activeTab === 'colors' && (
|
||||
<div className="tab-pane fade show active">
|
||||
<div className="row">
|
||||
<div className="col-md-6 mb-3">
|
||||
<label className="form-label">Основной цвет темы</label>
|
||||
<div className="input-group">
|
||||
<input
|
||||
type="color"
|
||||
className="form-control form-control-color"
|
||||
value={settings.theme_color}
|
||||
onChange={(e) => handleChange('theme_color', e.target.value)}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
value={settings.theme_color}
|
||||
onChange={(e) => handleChange('theme_color', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-6 mb-3">
|
||||
<label className="form-label">Цвет фона дашборда</label>
|
||||
<div className="input-group">
|
||||
<input
|
||||
type="color"
|
||||
className="form-control form-control-color"
|
||||
value={settings.dashboard_background_color}
|
||||
onChange={(e) => handleChange('dashboard_background_color', e.target.value)}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
value={settings.dashboard_background_color}
|
||||
onChange={(e) => handleChange('dashboard_background_color', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-12 mb-3">
|
||||
<label className="form-label">Фоновое изображение</label>
|
||||
<div className="mb-2">
|
||||
<input
|
||||
type="file"
|
||||
className="form-control"
|
||||
accept="image/*"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) {
|
||||
setBackgroundImageFile(file)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="form-text">
|
||||
Выберите изображение для фона (JPG, PNG, GIF). Если не выбрано - текущее изображение останется без изменений.
|
||||
</div>
|
||||
</div>
|
||||
{settings.background_image_url && (
|
||||
<div className="mb-2">
|
||||
<label className="form-label small">Текущее изображение:</label>
|
||||
<div>
|
||||
<img
|
||||
src={settings.background_image_url}
|
||||
alt="Текущий фон"
|
||||
className="img-thumbnail"
|
||||
style={{ maxWidth: '200px', maxHeight: '100px', objectFit: 'cover' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{backgroundImageFile && (
|
||||
<div className="mb-2">
|
||||
<label className="form-label small">Новое изображение (будет применено после сохранения):</label>
|
||||
<div className="text-success">
|
||||
<i className="bi bi-file-earmark-image me-1"></i>
|
||||
{backgroundImageFile.name}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-md-4 mb-3">
|
||||
<label className="form-label">Цвет заголовков</label>
|
||||
<div className="input-group">
|
||||
<input
|
||||
type="color"
|
||||
className="form-control form-control-color"
|
||||
value={settings.header_text_color || '#000000'}
|
||||
onChange={(e) => handleChange('header_text_color', e.target.value)}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
value={settings.header_text_color || '#000000'}
|
||||
onChange={(e) => handleChange('header_text_color', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-4 mb-3">
|
||||
<label className="form-label">Цвет названий групп</label>
|
||||
<div className="input-group">
|
||||
<input
|
||||
type="color"
|
||||
className="form-control form-control-color"
|
||||
value={settings.group_text_color || '#333333'}
|
||||
onChange={(e) => handleChange('group_text_color', e.target.value)}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
value={settings.group_text_color || '#333333'}
|
||||
onChange={(e) => handleChange('group_text_color', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-4 mb-3">
|
||||
<label className="form-label">Цвет названий ссылок</label>
|
||||
<div className="input-group">
|
||||
<input
|
||||
type="color"
|
||||
className="form-control form-control-color"
|
||||
value={settings.link_text_color || '#666666'}
|
||||
onChange={(e) => handleChange('link_text_color', e.target.value)}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
value={settings.link_text_color || '#666666'}
|
||||
onChange={(e) => handleChange('link_text_color', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Вкладка: Группы */}
|
||||
{activeTab === 'groups' && (
|
||||
<div className="tab-pane fade show active">
|
||||
<div className="row">
|
||||
<div className="col-12 mb-3">
|
||||
<h6 className="text-muted">Настройки отображения групп</h6>
|
||||
</div>
|
||||
<div className="col-12 mb-3">
|
||||
<div className="form-check form-switch">
|
||||
<input
|
||||
className="form-check-input"
|
||||
type="checkbox"
|
||||
checked={settings.groups_default_expanded}
|
||||
onChange={(e) => handleChange('groups_default_expanded', e.target.checked)}
|
||||
/>
|
||||
<label className="form-check-label">
|
||||
Развернуть группы по умолчанию
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-12 mb-3">
|
||||
<div className="form-check form-switch">
|
||||
<input
|
||||
className="form-check-input"
|
||||
type="checkbox"
|
||||
checked={settings.show_group_icons}
|
||||
onChange={(e) => handleChange('show_group_icons', e.target.checked)}
|
||||
/>
|
||||
<label className="form-check-label">
|
||||
Показывать иконки групп
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-12 mb-3">
|
||||
<div className="form-check form-switch">
|
||||
<input
|
||||
className="form-check-input"
|
||||
type="checkbox"
|
||||
checked={settings.show_link_icons}
|
||||
onChange={(e) => handleChange('show_link_icons', e.target.checked)}
|
||||
/>
|
||||
<label className="form-check-label">
|
||||
Показывать иконки ссылок
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-12">
|
||||
<div className="alert alert-info">
|
||||
<i className="bi bi-info-circle me-2"></i>
|
||||
<strong>Настройки отдельных групп</strong><br/>
|
||||
Чтобы настроить конкретную группу (публичность, избранное, разворачивание), используйте кнопку редактирования рядом с названием группы в основном списке.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Вкладка: Дополнительно */}
|
||||
{activeTab === 'advanced' && (
|
||||
<div className="tab-pane fade show active">
|
||||
<div className="row">
|
||||
<div className="col-12 mb-3">
|
||||
<label className="form-label">Шрифт</label>
|
||||
<select
|
||||
className="form-select"
|
||||
value={settings.font_family}
|
||||
onChange={(e) => handleChange('font_family', e.target.value)}
|
||||
>
|
||||
<option value="sans-serif">Sans Serif</option>
|
||||
<option value="serif">Serif</option>
|
||||
<option value="monospace">Monospace</option>
|
||||
<option value="Inter, sans-serif">Inter</option>
|
||||
<option value="Roboto, sans-serif">Roboto</option>
|
||||
<option value="Open Sans, sans-serif">Open Sans</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-12 mb-3">
|
||||
<label className="form-label">Дополнительный CSS</label>
|
||||
<textarea
|
||||
className="form-control font-monospace"
|
||||
rows={6}
|
||||
value={settings.custom_css}
|
||||
onChange={(e) => handleChange('custom_css', e.target.value)}
|
||||
placeholder="/* Ваш дополнительный CSS */ .my-custom-class { color: #333; }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Cover Overlay Section */}
|
||||
<div className="col-12 mb-3">
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h6 className="mb-0">Перекрытие обложки</h6>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<div className="form-check mb-3">
|
||||
<input
|
||||
className="form-check-input"
|
||||
type="checkbox"
|
||||
id="coverOverlayEnabled"
|
||||
checked={settings.cover_overlay_enabled || false}
|
||||
onChange={(e) => handleChange('cover_overlay_enabled', e.target.checked)}
|
||||
/>
|
||||
<label className="form-check-label" htmlFor="coverOverlayEnabled">
|
||||
Включить цветовое перекрытие обложки
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{settings.cover_overlay_enabled && (
|
||||
<>
|
||||
<div className="row">
|
||||
<div className="col-6 mb-3">
|
||||
<label className="form-label">Цвет перекрытия</label>
|
||||
<div className="d-flex gap-2">
|
||||
<input
|
||||
type="color"
|
||||
className="form-control form-control-color"
|
||||
value={settings.cover_overlay_color || '#000000'}
|
||||
onChange={(e) => handleChange('cover_overlay_color', e.target.value)}
|
||||
title="Выберите цвет перекрытия"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
value={settings.cover_overlay_color || '#000000'}
|
||||
onChange={(e) => handleChange('cover_overlay_color', e.target.value)}
|
||||
placeholder="#000000"
|
||||
title="Hex код цвета"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-6 mb-3">
|
||||
<label className="form-label">
|
||||
Прозрачность ({Math.round((settings.cover_overlay_opacity || 0.3) * 100)}%)
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
className="form-range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.1"
|
||||
value={settings.cover_overlay_opacity || 0.3}
|
||||
onChange={(e) => handleChange('cover_overlay_opacity', parseFloat(e.target.value))}
|
||||
title="Настройка прозрачности перекрытия"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview */}
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Предварительный просмотр</label>
|
||||
<div className="position-relative" style={{ height: '100px', border: '1px solid #dee2e6', borderRadius: '0.375rem', overflow: 'hidden' }}>
|
||||
<div
|
||||
className="w-100 h-100"
|
||||
style={{
|
||||
background: 'linear-gradient(45deg, #ccc 25%, transparent 25%), linear-gradient(-45deg, #ccc 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #ccc 75%), linear-gradient(-45deg, transparent 75%, #ccc 75%)',
|
||||
backgroundSize: '20px 20px',
|
||||
backgroundPosition: '0 0, 0 10px, 10px -10px, -10px 0px'
|
||||
}}
|
||||
></div>
|
||||
<div
|
||||
className="position-absolute top-0 start-0 w-100 h-100"
|
||||
style={{
|
||||
backgroundColor: settings.cover_overlay_color || '#000000',
|
||||
opacity: settings.cover_overlay_opacity || 0.3
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="modal-footer">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={onClose}
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={saveSettings}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<span className="spinner-border spinner-border-sm me-2"></span>
|
||||
Сохранение...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<i className="bi bi-check-lg me-2"></i>
|
||||
Сохранить
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
309
frontend/linktree-frontend/src/app/components/GroupEditModal.tsx
Normal file
309
frontend/linktree-frontend/src/app/components/GroupEditModal.tsx
Normal file
@@ -0,0 +1,309 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useRef } from 'react'
|
||||
|
||||
interface GroupCustomization {
|
||||
id?: number
|
||||
name: string
|
||||
description?: string
|
||||
header_color: string
|
||||
background_image?: string | null
|
||||
is_expanded: boolean
|
||||
display_style: 'grid' | 'list' | 'cards'
|
||||
icon?: File | string | null
|
||||
}
|
||||
|
||||
interface GroupEditModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onSave: (group: GroupCustomization, iconFile?: File) => void
|
||||
group?: GroupCustomization | null
|
||||
mode: 'add' | 'edit'
|
||||
}
|
||||
|
||||
export function GroupEditModal({ isOpen, onClose, onSave, group, mode }: GroupEditModalProps) {
|
||||
const [formData, setFormData] = useState<GroupCustomization>({
|
||||
name: '',
|
||||
description: '',
|
||||
header_color: '#ffffff',
|
||||
is_expanded: true,
|
||||
display_style: 'list'
|
||||
})
|
||||
const [iconFile, setIconFile] = useState<File | null>(null)
|
||||
const [iconPreview, setIconPreview] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isOpen) {
|
||||
if (mode === 'edit' && group) {
|
||||
setFormData({
|
||||
...group
|
||||
})
|
||||
if (typeof group.icon === 'string' && group.icon) {
|
||||
setIconPreview(group.icon)
|
||||
}
|
||||
} else {
|
||||
setFormData({
|
||||
name: '',
|
||||
description: '',
|
||||
header_color: '#ffffff',
|
||||
is_expanded: true,
|
||||
display_style: 'list'
|
||||
})
|
||||
setIconFile(null)
|
||||
setIconPreview(null)
|
||||
}
|
||||
}
|
||||
}, [isOpen, mode, group])
|
||||
|
||||
const handleChange = (field: keyof GroupCustomization, value: any) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[field]: value
|
||||
}))
|
||||
}
|
||||
|
||||
const handleIconChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) {
|
||||
setIconFile(file)
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
setIconPreview(reader.result as string)
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
}
|
||||
|
||||
const removeIcon = () => {
|
||||
setIconFile(null)
|
||||
setIconPreview(null)
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!formData.name.trim()) {
|
||||
alert('Название группы обязательно')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
await onSave(formData, iconFile || undefined)
|
||||
onClose()
|
||||
} catch (error) {
|
||||
console.error('Error saving group:', error)
|
||||
alert('Ошибка при сохранении группы')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div className="modal fade show d-block" style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}>
|
||||
<div className="modal-dialog modal-lg">
|
||||
<div className="modal-content">
|
||||
<div className="modal-header">
|
||||
<h5 className="modal-title">
|
||||
{mode === 'add' ? (
|
||||
<>
|
||||
<i className="bi bi-plus-circle me-2"></i>
|
||||
Создать группу
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<i className="bi bi-pencil me-2"></i>
|
||||
Редактировать группу
|
||||
</>
|
||||
)}
|
||||
</h5>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-close"
|
||||
onClick={onClose}
|
||||
aria-label="Закрыть"
|
||||
></button>
|
||||
</div>
|
||||
|
||||
<div className="modal-body">
|
||||
<div className="row">
|
||||
{/* Основные настройки */}
|
||||
<div className="col-md-8">
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Название группы *</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
value={formData.name}
|
||||
onChange={(e) => handleChange('name', e.target.value)}
|
||||
placeholder="Введите название группы"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Описание</label>
|
||||
<textarea
|
||||
className="form-control"
|
||||
rows={3}
|
||||
value={formData.description || ''}
|
||||
onChange={(e) => handleChange('description', e.target.value)}
|
||||
placeholder="Краткое описание группы"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Цвет заголовка</label>
|
||||
<div className="input-group">
|
||||
<input
|
||||
type="color"
|
||||
className="form-control form-control-color"
|
||||
value={formData.header_color}
|
||||
onChange={(e) => handleChange('header_color', e.target.value)}
|
||||
title="Выберите цвет заголовка"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
value={formData.header_color}
|
||||
onChange={(e) => handleChange('header_color', e.target.value)}
|
||||
placeholder="#ffffff"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Стиль отображения</label>
|
||||
<div className="row">
|
||||
{[
|
||||
{ value: 'list', label: 'Список', icon: 'bi-list-ul' },
|
||||
{ value: 'grid', label: 'Сетка', icon: 'bi-grid-3x3' },
|
||||
{ value: 'cards', label: 'Карточки', icon: 'bi-card-text' }
|
||||
].map((style) => (
|
||||
<div key={style.value} className="col-4">
|
||||
<div
|
||||
className={`card text-center h-100 ${formData.display_style === style.value ? 'border-primary bg-primary bg-opacity-10' : ''}`}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => handleChange('display_style', style.value)}
|
||||
>
|
||||
<div className="card-body p-2">
|
||||
<i className={`${style.icon} fs-4 mb-1`}></i>
|
||||
<p className="card-text small mb-0">{style.label}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<div className="form-check form-switch">
|
||||
<input
|
||||
className="form-check-input"
|
||||
type="checkbox"
|
||||
checked={formData.is_expanded}
|
||||
onChange={(e) => handleChange('is_expanded', e.target.checked)}
|
||||
id="groupExpanded"
|
||||
/>
|
||||
<label className="form-check-label" htmlFor="groupExpanded">
|
||||
Развернуть группу по умолчанию
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Иконка группы */}
|
||||
<div className="col-md-4">
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Иконка группы</label>
|
||||
|
||||
{iconPreview && (
|
||||
<div className="text-center mb-3">
|
||||
<img
|
||||
src={iconPreview}
|
||||
alt="Preview"
|
||||
className="img-thumbnail"
|
||||
style={{ maxWidth: '100px', maxHeight: '100px' }}
|
||||
/>
|
||||
<div className="mt-2">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-outline-danger"
|
||||
onClick={removeIcon}
|
||||
>
|
||||
<i className="bi bi-trash"></i> Удалить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
className="form-control"
|
||||
accept="image/*"
|
||||
onChange={handleIconChange}
|
||||
/>
|
||||
<div className="form-text">
|
||||
Поддерживаются форматы: PNG, JPG, SVG
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Фоновое изображение</label>
|
||||
<input
|
||||
type="file"
|
||||
className="form-control"
|
||||
accept="image/*"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) {
|
||||
// TODO: Implement background image upload
|
||||
console.log('Background image selected:', file)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="form-text">
|
||||
Фоновое изображение для заголовка группы
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="modal-footer">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={onClose}
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={handleSave}
|
||||
disabled={loading || !formData.name.trim()}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<span className="spinner-border spinner-border-sm me-2"></span>
|
||||
Сохранение...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<i className="bi bi-check-lg me-2"></i>
|
||||
{mode === 'add' ? 'Создать' : 'Сохранить'}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import Script from 'next/script'
|
||||
|
||||
interface User {
|
||||
username: string
|
||||
avatar: string
|
||||
avatar: string | null
|
||||
}
|
||||
|
||||
export function LayoutWrapper({ children }: { children: ReactNode }) {
|
||||
@@ -44,7 +44,10 @@ export function LayoutWrapper({ children }: { children: ReactNode }) {
|
||||
}, [])
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('token')
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem('token')
|
||||
}
|
||||
setUser(null)
|
||||
router.push('/')
|
||||
}
|
||||
|
||||
@@ -80,9 +83,11 @@ export function LayoutWrapper({ children }: { children: ReactNode }) {
|
||||
<div className="ms-auto d-flex align-items-center gap-3">
|
||||
<Image
|
||||
src={
|
||||
user.avatar.startsWith('http')
|
||||
user.avatar && user.avatar.startsWith('http')
|
||||
? user.avatar
|
||||
: `http://localhost:8000${user.avatar}`
|
||||
: user.avatar
|
||||
? `http://localhost:8000${user.avatar}`
|
||||
: '/assets/img/avatar-dhg.png'
|
||||
}
|
||||
alt="Avatar"
|
||||
width={32}
|
||||
@@ -115,8 +120,8 @@ export function LayoutWrapper({ children }: { children: ReactNode }) {
|
||||
|
||||
{/* Подвал не выводим на публичных страницах */}
|
||||
{!isPublicUserPage && (
|
||||
<footer className="bg-light footer fixed-bottom border-top">
|
||||
<div className="container py-2">
|
||||
<footer className="bg-light footer border-top mt-5">
|
||||
<div className="container py-4">
|
||||
<div className="row">
|
||||
<div className="col-lg-6 text-center text-lg-start mb-2 mb-lg-0">
|
||||
<ul className="list-inline mb-1">
|
||||
@@ -143,15 +148,12 @@ export function LayoutWrapper({ children }: { children: ReactNode }) {
|
||||
)}
|
||||
|
||||
{/* Bootstrap JS */}
|
||||
<Script
|
||||
src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.0/jquery.min.js"
|
||||
strategy="beforeInteractive"
|
||||
/>
|
||||
{/* Bootstrap JS: load after React hydrates to avoid DOM mutations during hydration */}
|
||||
<Script
|
||||
src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
|
||||
strategy="beforeInteractive"
|
||||
strategy="afterInteractive"
|
||||
/>
|
||||
<Script src="/assets/js/bs-init.js" strategy="lazyOnload" />
|
||||
<Script src="/assets/js/bs-init.js" strategy="afterInteractive" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
@import "tailwindcss";
|
||||
@import "./styles/layouts.css";
|
||||
@import "./test-styles.css";
|
||||
/* src/app/globals.css */
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
|
||||
@@ -1,75 +1,258 @@
|
||||
// src/app/page.tsx
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Image from 'next/image'
|
||||
|
||||
export default function HomePage() {
|
||||
const [email, setEmail] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const router = useRouter()
|
||||
|
||||
const handleQuickStart = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!email) return
|
||||
|
||||
setLoading(true)
|
||||
|
||||
// Сохраняем email в локальном хранилище для автозаполнения формы регистрации
|
||||
localStorage.setItem('quickStartEmail', email)
|
||||
|
||||
// Перенаправляем на страницу регистрации
|
||||
router.push('/auth/register')
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<header
|
||||
className="text-center text-white masthead"
|
||||
className="text-center text-white masthead position-relative"
|
||||
style={{
|
||||
background: "url('/assets/img/bg-masthead.jpg') no-repeat center center",
|
||||
backgroundSize: 'cover',
|
||||
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center'
|
||||
}}
|
||||
>
|
||||
<div className="overlay"></div>
|
||||
<div className="container py-5">
|
||||
<div className="row">
|
||||
<div className="col-xl-9 mx-auto">
|
||||
<h1 className="mb-5">Ваши ссылки. Ваш стиль. Ваш CatLink.</h1>
|
||||
</div>
|
||||
<div className="col-md-10 col-lg-8 col-xl-7 mx-auto">
|
||||
<form className="d-flex">
|
||||
<input
|
||||
className="form-control form-control-lg me-2"
|
||||
type="email"
|
||||
placeholder="Введите электронную почту"
|
||||
<div className="col-xl-10 mx-auto">
|
||||
<div className="mb-4">
|
||||
<Image
|
||||
src="/assets/img/CAT.png"
|
||||
alt="CatLink"
|
||||
width={120}
|
||||
height={120}
|
||||
className="mb-4"
|
||||
/>
|
||||
<button className="btn btn-primary btn-lg" type="submit">
|
||||
Начать
|
||||
</div>
|
||||
<h1 className="display-4 fw-bold mb-4">
|
||||
Ваши ссылки. Ваш стиль. Ваш CatLink.
|
||||
</h1>
|
||||
<p className="lead mb-5 fs-4">
|
||||
Создайте красивую персональную страницу со всеми важными ссылками в одном месте.
|
||||
Делитесь профессионально и стильно!
|
||||
</p>
|
||||
</div>
|
||||
<div className="col-md-10 col-lg-8 col-xl-6 mx-auto">
|
||||
<form onSubmit={handleQuickStart} className="d-flex flex-column flex-sm-row gap-3">
|
||||
<input
|
||||
className="form-control form-control-lg"
|
||||
type="email"
|
||||
placeholder="Введите ваш email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<button
|
||||
className="btn btn-warning btn-lg px-4 fw-bold"
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
style={{ minWidth: '140px' }}
|
||||
>
|
||||
{loading ? (
|
||||
<span className="spinner-border spinner-border-sm"></span>
|
||||
) : (
|
||||
'Начать бесплатно'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
<div className="mt-3">
|
||||
<small className="text-white-50">
|
||||
Уже есть аккаунт? <Link href="/auth/login" className="text-warning text-decoration-none fw-bold">Войти</Link>
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Анимированная стрелка вниз */}
|
||||
<div className="position-absolute bottom-0 start-50 translate-middle-x mb-4">
|
||||
<div className="animate-bounce">
|
||||
<i className="bi bi-chevron-down text-white fs-3"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="text-center bg-light features-icons py-5">
|
||||
{/* Преимущества */}
|
||||
<section className="py-5 bg-light">
|
||||
<div className="container">
|
||||
<div className="row text-center mb-5">
|
||||
<div className="col-lg-8 mx-auto">
|
||||
<h2 className="display-5 fw-bold mb-3">Почему выбирают CatLink?</h2>
|
||||
<p className="lead text-muted">
|
||||
Простой и мощный инструмент для создания вашего цифрового присутствия
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="row">
|
||||
<div className="col-lg-4 mb-4">
|
||||
<div className="features-icons-item">
|
||||
<div className="features-icons-icon mb-3">
|
||||
<img src="/assets/img/CAT.png" alt="CatLink" width={89} height={89} />
|
||||
<div className="text-center h-100">
|
||||
<div className="bg-primary rounded-circle d-inline-flex align-items-center justify-content-center mb-4" style={{ width: '80px', height: '80px' }}>
|
||||
<i className="bi bi-link-45deg text-white fs-1"></i>
|
||||
</div>
|
||||
<h3>Публикация</h3>
|
||||
<p className="lead mb-0">
|
||||
Делитесь единой ссылкой catlinks.kr/ваше-имя в био, мессенджерах и письмах.
|
||||
<h4 className="fw-bold">Один URL — все ссылки</h4>
|
||||
<p className="text-muted">
|
||||
Соберите все важные ссылки в одном месте. Социальные сети, портфолио,
|
||||
контакты — всё под одним адресом.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-lg-4 mb-4">
|
||||
<div className="features-icons-item">
|
||||
<div className="features-icons-icon mb-3">
|
||||
<img src="/assets/img/CAT.png" alt="CatLink" width={89} height={89} />
|
||||
<div className="text-center h-100">
|
||||
<div className="bg-success rounded-circle d-inline-flex align-items-center justify-content-center mb-4" style={{ width: '80px', height: '80px' }}>
|
||||
<i className="bi bi-palette text-white fs-1"></i>
|
||||
</div>
|
||||
<h3>Почему CatLink?</h3>
|
||||
<p className="lead mb-0">
|
||||
Повяжите свои миры одной «хвостовой» ссылкой.
|
||||
<h4 className="fw-bold">Персонализация</h4>
|
||||
<p className="text-muted">
|
||||
Настройте цвета, шрифты, макеты. Создайте уникальный стиль,
|
||||
который отражает вашу личность или бренд.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-lg-4 mb-4">
|
||||
<div className="features-icons-item">
|
||||
<div className="features-icons-icon mb-3">
|
||||
<img src="/assets/img/CAT.png" alt="CatLink" width={89} height={89} />
|
||||
<div className="text-center h-100">
|
||||
<div className="bg-warning rounded-circle d-inline-flex align-items-center justify-content-center mb-4" style={{ width: '80px', height: '80px' }}>
|
||||
<i className="bi bi-graph-up text-white fs-1"></i>
|
||||
</div>
|
||||
<h3>Разместите всё важное на одной ссылке</h3>
|
||||
<p className="lead mb-0">
|
||||
Идите дальше, как кошка: легко и грациозно.
|
||||
<h4 className="fw-bold">Аналитика</h4>
|
||||
<p className="text-muted">
|
||||
Отслеживайте клики, популярность ссылок и активность посетителей.
|
||||
Понимайте свою аудиторию лучше.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Примеры использования */}
|
||||
<section className="py-5">
|
||||
<div className="container">
|
||||
<div className="row align-items-center">
|
||||
<div className="col-lg-6 mb-4">
|
||||
<h2 className="display-6 fw-bold mb-4">Для всех и каждого</h2>
|
||||
<div className="row">
|
||||
<div className="col-sm-6 mb-3">
|
||||
<div className="d-flex align-items-start">
|
||||
<div className="bg-primary rounded-circle me-3 d-flex align-items-center justify-content-center" style={{ width: '40px', height: '40px', minWidth: '40px' }}>
|
||||
<i className="bi bi-person text-white"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h6 className="fw-bold mb-1">Блогеры</h6>
|
||||
<small className="text-muted">Соберите все социальные сети</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-sm-6 mb-3">
|
||||
<div className="d-flex align-items-start">
|
||||
<div className="bg-success rounded-circle me-3 d-flex align-items-center justify-content-center" style={{ width: '40px', height: '40px', minWidth: '40px' }}>
|
||||
<i className="bi bi-briefcase text-white"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h6 className="fw-bold mb-1">Бизнес</h6>
|
||||
<small className="text-muted">Покажите услуги и контакты</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-sm-6 mb-3">
|
||||
<div className="d-flex align-items-start">
|
||||
<div className="bg-warning rounded-circle me-3 d-flex align-items-center justify-content-center" style={{ width: '40px', height: '40px', minWidth: '40px' }}>
|
||||
<i className="bi bi-music-note text-white"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h6 className="fw-bold mb-1">Музыканты</h6>
|
||||
<small className="text-muted">Поделитесь творчеством</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-sm-6 mb-3">
|
||||
<div className="d-flex align-items-start">
|
||||
<div className="bg-info rounded-circle me-3 d-flex align-items-center justify-content-center" style={{ width: '40px', height: '40px', minWidth: '40px' }}>
|
||||
<i className="bi bi-camera text-white"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h6 className="fw-bold mb-1">Фотографы</h6>
|
||||
<small className="text-muted">Покажите портфолио</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-lg-6 text-center">
|
||||
<div className="bg-light rounded-3 p-4 shadow-sm">
|
||||
<div className="bg-white rounded-3 p-4 mb-3 border">
|
||||
<div className="d-flex align-items-center mb-3">
|
||||
<div className="bg-primary rounded-circle me-3" style={{ width: '40px', height: '40px' }}></div>
|
||||
<div>
|
||||
<h6 className="mb-0">@your_username</h6>
|
||||
<small className="text-muted">Ваша персональная страница</small>
|
||||
</div>
|
||||
</div>
|
||||
<div className="d-grid gap-2">
|
||||
<div className="bg-light rounded-2 p-2 text-start">
|
||||
<small><i className="bi bi-instagram text-danger me-2"></i>Instagram</small>
|
||||
</div>
|
||||
<div className="bg-light rounded-2 p-2 text-start">
|
||||
<small><i className="bi bi-youtube text-danger me-2"></i>YouTube</small>
|
||||
</div>
|
||||
<div className="bg-light rounded-2 p-2 text-start">
|
||||
<small><i className="bi bi-globe text-primary me-2"></i>Личный сайт</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<small className="text-muted">Пример вашей страницы</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA секция */}
|
||||
<section className="py-5 bg-primary text-white">
|
||||
<div className="container text-center">
|
||||
<div className="row">
|
||||
<div className="col-lg-8 mx-auto">
|
||||
<h2 className="display-6 fw-bold mb-4">Готовы начать?</h2>
|
||||
<p className="lead mb-4">
|
||||
Присоединяйтесь к тысячам пользователей, которые уже создали свою идеальную страницу ссылок
|
||||
</p>
|
||||
<div className="d-flex flex-column flex-sm-row gap-3 justify-content-center">
|
||||
<Link href="/auth/register" className="btn btn-warning btn-lg px-4 fw-bold">
|
||||
Создать аккаунт бесплатно
|
||||
</Link>
|
||||
<Link href="/auth/login" className="btn btn-outline-light btn-lg px-4">
|
||||
У меня есть аккаунт
|
||||
</Link>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<small className="text-white-75">Бесплатно навсегда • Без ограничений • Быстрая настройка</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
396
frontend/linktree-frontend/src/app/styles/layouts.css
Normal file
396
frontend/linktree-frontend/src/app/styles/layouts.css
Normal file
@@ -0,0 +1,396 @@
|
||||
/* Стили для различных макетов дашборда */
|
||||
|
||||
/* Общие стили */
|
||||
.layout-option {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.layout-option:hover {
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.layout-option.selected {
|
||||
transform: scale(1.02);
|
||||
box-shadow: 0 6px 12px rgba(0, 123, 255, 0.3);
|
||||
}
|
||||
|
||||
/* Компактный макет */
|
||||
.compact-layout .card {
|
||||
margin-bottom: 0.5rem !important;
|
||||
}
|
||||
|
||||
.compact-layout .card-body {
|
||||
padding: 0.75rem !important;
|
||||
}
|
||||
|
||||
.compact-layout .badge-sm {
|
||||
font-size: 0.7rem;
|
||||
padding: 0.2em 0.4em;
|
||||
}
|
||||
|
||||
/* Временная шкала */
|
||||
.timeline {
|
||||
position: relative;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.timeline::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 4px;
|
||||
background: linear-gradient(to bottom, #007bff, #6c757d);
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
margin-left: -2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.timeline-item {
|
||||
padding: 10px 40px;
|
||||
position: relative;
|
||||
background-color: inherit;
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
.timeline-item::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
right: -10px;
|
||||
background: #007bff;
|
||||
border: 4px solid #fff;
|
||||
top: 20px;
|
||||
border-radius: 50%;
|
||||
z-index: 1;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.timeline-item.left {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.timeline-item.right {
|
||||
left: 50%;
|
||||
}
|
||||
|
||||
.timeline-item.right::after {
|
||||
left: -10px;
|
||||
}
|
||||
|
||||
.timeline-content {
|
||||
padding: 20px 30px;
|
||||
background: white;
|
||||
position: relative;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.timeline-content:hover {
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.timeline-item.left .timeline-content {
|
||||
margin-right: 30px;
|
||||
}
|
||||
|
||||
.timeline-item.right .timeline-content {
|
||||
margin-left: 30px;
|
||||
}
|
||||
|
||||
/* Адаптивность для временной шкалы */
|
||||
@media screen and (max-width: 768px) {
|
||||
.timeline::after {
|
||||
left: 31px;
|
||||
}
|
||||
|
||||
.timeline-item {
|
||||
width: 100%;
|
||||
padding-left: 70px;
|
||||
padding-right: 25px;
|
||||
}
|
||||
|
||||
.timeline-item::before {
|
||||
left: 60px;
|
||||
border: medium solid white;
|
||||
border-width: 10px 10px 10px 0;
|
||||
border-color: transparent white transparent transparent;
|
||||
}
|
||||
|
||||
.timeline-item.left::after,
|
||||
.timeline-item.right::after {
|
||||
left: 21px;
|
||||
}
|
||||
|
||||
.timeline-item.right {
|
||||
left: 0%;
|
||||
}
|
||||
|
||||
.timeline-item.left .timeline-content,
|
||||
.timeline-item.right .timeline-content {
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Журнальный макет */
|
||||
.magazine-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.magazine-item.featured .card {
|
||||
border: 2px solid #007bff;
|
||||
box-shadow: 0 8px 24px rgba(0, 123, 255, 0.15);
|
||||
}
|
||||
|
||||
.magazine-item.featured .card-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.magazine-image-placeholder {
|
||||
min-height: 200px;
|
||||
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
|
||||
border-radius: 8px 0 0 8px;
|
||||
}
|
||||
|
||||
.magazine-item:nth-child(even) .row {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.magazine-item:nth-child(even) .magazine-image-placeholder {
|
||||
border-radius: 0 8px 8px 0;
|
||||
}
|
||||
|
||||
.links-preview {
|
||||
max-height: 120px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Кладка (Masonry) адаптивная сетка */
|
||||
.masonry-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
grid-gap: 1rem;
|
||||
grid-auto-rows: min-content;
|
||||
}
|
||||
|
||||
.masonry-item {
|
||||
break-inside: avoid;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* Анимации появления */
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideInLeft {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideInRight {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-in-up {
|
||||
animation: fadeInUp 0.6s ease-out;
|
||||
}
|
||||
|
||||
.animate-slide-in-left {
|
||||
animation: slideInLeft 0.6s ease-out;
|
||||
}
|
||||
|
||||
.animate-slide-in-right {
|
||||
animation: slideInRight 0.6s ease-out;
|
||||
}
|
||||
|
||||
/* Стили для разных размеров карточек в сетке */
|
||||
.grid-layout .col-md-6:nth-child(3n+1) {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.grid-layout .col-lg-4:nth-child(4n+1) {
|
||||
grid-row: span 2;
|
||||
}
|
||||
|
||||
/* Улучшенные стили для боковой панели */
|
||||
.sidebar-layout .list-group-item-action.active {
|
||||
background: linear-gradient(135deg, #007bff 0%, #0056b3 100%);
|
||||
border-color: #007bff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.sidebar-layout .list-group-item-action:hover {
|
||||
background-color: #f8f9fa;
|
||||
border-color: #dee2e6;
|
||||
}
|
||||
|
||||
/* Hover эффекты для карточек */
|
||||
.card-hover {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.card-hover:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
/* Цветовые акценты для разных типов контента */
|
||||
.group-card-primary {
|
||||
border-left: 4px solid #007bff;
|
||||
}
|
||||
|
||||
.group-card-secondary {
|
||||
border-left: 4px solid #6c757d;
|
||||
}
|
||||
|
||||
.group-card-success {
|
||||
border-left: 4px solid #28a745;
|
||||
}
|
||||
|
||||
.group-card-warning {
|
||||
border-left: 4px solid #ffc107;
|
||||
}
|
||||
|
||||
.group-card-info {
|
||||
border-left: 4px solid #17a2b8;
|
||||
}
|
||||
|
||||
/* Стили для пустого состояния */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem 1rem;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.empty-state i {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 1rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.empty-state h4 {
|
||||
margin-bottom: 0.5rem;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
margin-bottom: 1.5rem;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
/* Адаптивность для мобильных устройств */
|
||||
@media (max-width: 768px) {
|
||||
.magazine-item .row {
|
||||
flex-direction: column !important;
|
||||
}
|
||||
|
||||
.magazine-image-placeholder {
|
||||
border-radius: 8px 8px 0 0 !important;
|
||||
min-height: 150px;
|
||||
}
|
||||
|
||||
.sidebar-layout .row {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar-layout .col-md-3 {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.compact-layout {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.timeline-item {
|
||||
padding-left: 50px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Улучшенные состояния загрузки */
|
||||
.loading-skeleton {
|
||||
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: loading 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes loading {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Микроанимации для интерактивных элементов */
|
||||
.btn-enhanced {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.btn-enhanced::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 0;
|
||||
height: 0;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border-radius: 50%;
|
||||
transition: width 0.3s ease, height 0.3s ease;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.btn-enhanced:hover::before {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Кастомные скроллбары */
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: #c1c1c1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: #a8a8a8;
|
||||
}
|
||||
Reference in New Issue
Block a user