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

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

View File

@@ -1,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>