new models, frontend functions, public pages

This commit is contained in:
2025-05-07 15:41:03 +09:00
parent 91f0d54563
commit 18497d4343
784 changed files with 124024 additions and 289 deletions

View File

@@ -0,0 +1,84 @@
// File: /home/trevor/links/frontend/linktree-frontend/src/app/dashboard/page.tsx
import * as entry from '../../../../src/app/dashboard/page.js'
import type { ResolvingMetadata, ResolvingViewport } from 'next/dist/lib/metadata/types/metadata-interface.js'
type TEntry = typeof import('../../../../src/app/dashboard/page.js')
type SegmentParams<T extends Object = any> = T extends Record<string, any>
? { [K in keyof T]: T[K] extends string ? string | string[] | undefined : never }
: T
// Check that the entry is a valid entry
checkFields<Diff<{
default: Function
config?: {}
generateStaticParams?: Function
revalidate?: RevalidateRange<TEntry> | false
dynamic?: 'auto' | 'force-dynamic' | 'error' | 'force-static'
dynamicParams?: boolean
fetchCache?: 'auto' | 'force-no-store' | 'only-no-store' | 'default-no-store' | 'default-cache' | 'only-cache' | 'force-cache'
preferredRegion?: 'auto' | 'global' | 'home' | string | string[]
runtime?: 'nodejs' | 'experimental-edge' | 'edge'
maxDuration?: number
metadata?: any
generateMetadata?: Function
viewport?: any
generateViewport?: Function
experimental_ppr?: boolean
}, TEntry, ''>>()
// Check the prop type of the entry function
checkFields<Diff<PageProps, FirstArg<TEntry['default']>, 'default'>>()
// Check the arguments and return type of the generateMetadata function
if ('generateMetadata' in entry) {
checkFields<Diff<PageProps, FirstArg<MaybeField<TEntry, 'generateMetadata'>>, 'generateMetadata'>>()
checkFields<Diff<ResolvingMetadata, SecondArg<MaybeField<TEntry, 'generateMetadata'>>, 'generateMetadata'>>()
}
// Check the arguments and return type of the generateViewport function
if ('generateViewport' in entry) {
checkFields<Diff<PageProps, FirstArg<MaybeField<TEntry, 'generateViewport'>>, 'generateViewport'>>()
checkFields<Diff<ResolvingViewport, SecondArg<MaybeField<TEntry, 'generateViewport'>>, 'generateViewport'>>()
}
// Check the arguments and return type of the generateStaticParams function
if ('generateStaticParams' in entry) {
checkFields<Diff<{ params: SegmentParams }, FirstArg<MaybeField<TEntry, 'generateStaticParams'>>, 'generateStaticParams'>>()
checkFields<Diff<{ __tag__: 'generateStaticParams', __return_type__: any[] | Promise<any[]> }, { __tag__: 'generateStaticParams', __return_type__: ReturnType<MaybeField<TEntry, 'generateStaticParams'>> }>>()
}
export interface PageProps {
params?: Promise<SegmentParams>
searchParams?: Promise<any>
}
export interface LayoutProps {
children?: React.ReactNode
params?: Promise<SegmentParams>
}
// =============
// Utility types
type RevalidateRange<T> = T extends { revalidate: any } ? NonNegative<T['revalidate']> : never
// If T is unknown or any, it will be an empty {} type. Otherwise, it will be the same as Omit<T, keyof Base>.
type OmitWithTag<T, K extends keyof any, _M> = Omit<T, K>
type Diff<Base, T extends Base, Message extends string = ''> = 0 extends (1 & T) ? {} : OmitWithTag<T, keyof Base, Message>
type FirstArg<T extends Function> = T extends (...args: [infer T, any]) => any ? unknown extends T ? any : T : never
type SecondArg<T extends Function> = T extends (...args: [any, infer T]) => any ? unknown extends T ? any : T : never
type MaybeField<T, K extends string> = T extends { [k in K]: infer G } ? G extends Function ? G : never : never
function checkFields<_ extends { [k in keyof any]: never }>() {}
// https://github.com/sindresorhus/type-fest
type Numeric = number | bigint
type Zero = 0 | 0n
type Negative<T extends Numeric> = T extends Zero ? never : `${T}` extends `-${string}` ? T : never
type NonNegative<T extends Numeric> = T extends Zero ? T : Negative<T> extends never ? T : '__invalid_negative_number__'

View File

@@ -0,0 +1,84 @@
// File: /home/trevor/links/frontend/linktree-frontend/src/app/dashboard/page.tsx
import * as entry from '../../../../src/app/(protected)/dashboard/page.jsx'
import type { ResolvingMetadata, ResolvingViewport } from 'next/dist/lib/metadata/types/metadata-interface.js'
type TEntry = typeof import('../../../../src/app/(protected)/dashboard/page.jsx')
type SegmentParams<T extends Object = any> = T extends Record<string, any>
? { [K in keyof T]: T[K] extends string ? string | string[] | undefined : never }
: T
// Check that the entry is a valid entry
checkFields<Diff<{
default: Function
config?: {}
generateStaticParams?: Function
revalidate?: RevalidateRange<TEntry> | false
dynamic?: 'auto' | 'force-dynamic' | 'error' | 'force-static'
dynamicParams?: boolean
fetchCache?: 'auto' | 'force-no-store' | 'only-no-store' | 'default-no-store' | 'default-cache' | 'only-cache' | 'force-cache'
preferredRegion?: 'auto' | 'global' | 'home' | string | string[]
runtime?: 'nodejs' | 'experimental-edge' | 'edge'
maxDuration?: number
metadata?: any
generateMetadata?: Function
viewport?: any
generateViewport?: Function
experimental_ppr?: boolean
}, TEntry, ''>>()
// Check the prop type of the entry function
checkFields<Diff<PageProps, FirstArg<TEntry['default']>, 'default'>>()
// Check the arguments and return type of the generateMetadata function
if ('generateMetadata' in entry) {
checkFields<Diff<PageProps, FirstArg<MaybeField<TEntry, 'generateMetadata'>>, 'generateMetadata'>>()
checkFields<Diff<ResolvingMetadata, SecondArg<MaybeField<TEntry, 'generateMetadata'>>, 'generateMetadata'>>()
}
// Check the arguments and return type of the generateViewport function
if ('generateViewport' in entry) {
checkFields<Diff<PageProps, FirstArg<MaybeField<TEntry, 'generateViewport'>>, 'generateViewport'>>()
checkFields<Diff<ResolvingViewport, SecondArg<MaybeField<TEntry, 'generateViewport'>>, 'generateViewport'>>()
}
// Check the arguments and return type of the generateStaticParams function
if ('generateStaticParams' in entry) {
checkFields<Diff<{ params: SegmentParams }, FirstArg<MaybeField<TEntry, 'generateStaticParams'>>, 'generateStaticParams'>>()
checkFields<Diff<{ __tag__: 'generateStaticParams', __return_type__: any[] | Promise<any[]> }, { __tag__: 'generateStaticParams', __return_type__: ReturnType<MaybeField<TEntry, 'generateStaticParams'>> }>>()
}
export interface PageProps {
params?: Promise<SegmentParams>
searchParams?: Promise<any>
}
export interface LayoutProps {
children?: React.ReactNode
params?: Promise<SegmentParams>
}
// =============
// Utility types
type RevalidateRange<T> = T extends { revalidate: any } ? NonNegative<T['revalidate']> : never
// If T is unknown or any, it will be an empty {} type. Otherwise, it will be the same as Omit<T, keyof Base>.
type OmitWithTag<T, K extends keyof any, _M> = Omit<T, K>
type Diff<Base, T extends Base, Message extends string = ''> = 0 extends (1 & T) ? {} : OmitWithTag<T, keyof Base, Message>
type FirstArg<T extends Function> = T extends (...args: [infer T, any]) => any ? unknown extends T ? any : T : never
type SecondArg<T extends Function> = T extends (...args: [any, infer T]) => any ? unknown extends T ? any : T : never
type MaybeField<T, K extends string> = T extends { [k in K]: infer G } ? G extends Function ? G : never : never
function checkFields<_ extends { [k in keyof any]: never }>() {}
// https://github.com/sindresorhus/type-fest
type Numeric = number | bigint
type Zero = 0 | 0n
type Negative<T extends Numeric> = T extends Zero ? never : `${T}` extends `-${string}` ? T : never
type NonNegative<T extends Numeric> = T extends Zero ? T : Negative<T> extends never ? T : '__invalid_negative_number__'

View File

@@ -0,0 +1,35 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
remotePatterns: [
{
protocol: 'http',
hostname: '127.0.0.1',
port: '8000', // <-- обязательно 8000, где Django отдаёт медиа
pathname: '/storage/**', // <-- подпапкиstorage/avatars, images/link_groups и т.д.
},
],
},
// Разрешаем в деве обращения с вашего адреса
allowedDevOrigins: [
'http://localhost:3001',
'http://192.168.219.114:3001',
'http://0.0.0.0:3001',
'http://localhost:3000',
'http://192.168.219.114:3000',
'http://127.0.0.1:3001',
],
// Проксируем все запросы /api/* на Django
async rewrites() {
return [
{
source: '/api/:path*', // локальный путь на фронте
destination: 'http://127.0.0.1:8000/api/:path*/' // куда реально уходит запрос
}
]
},
};
module.exports = nextConfig;

View File

@@ -0,0 +1,37 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
remotePatterns: [
{
protocol: 'http',
hostname: '127.0.0.1',
port: '8000', // where Django serves media
pathname: '/storage/**', // storage/avatars, images/link_groups, etc.
},
],
},
// proxy all /api/* calls to Django
async rewrites() {
return [
{
source: '/api/:path*',
destination: 'http://127.0.0.1:8000/api/:path*/',
},
];
},
experimental: {
// whitelist origins youll browse from in dev
allowedDevOrigins: [
'http://localhost:3001',
'http://127.0.0.1:3001',
'http://192.168.219.114:3001',
'http://0.0.0.0:3001',
'http://localhost:3000',
'http://192.168.219.114:3000',
],
},
};
module.exports = nextConfig;

View File

@@ -0,0 +1,37 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
remotePatterns: [
{
protocol: 'http',
hostname: '127.0.0.1',
port: '8000', // where Django serves media
pathname: '/storage/**', // storage/avatars, images/link_groups, etc.
},
],
},
// proxy all /api/* calls to Django
async rewrites() {
return [
{
source: '/api/:path*',
destination: 'http://127.0.0.1:8000/api/:path*',
},
];
},
experimental: {
// whitelist origins youll browse from in dev
allowedDevOrigins: [
'http://localhost:3001',
'http://127.0.0.1:3001',
'http://192.168.219.114:3001',
'http://0.0.0.0:3001',
'http://localhost:3000',
'http://192.168.219.114:3000',
],
},
};
module.exports = nextConfig;

View File

@@ -0,0 +1,31 @@
{
"name": "linktree-frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"axios": "^1.9.0",
"next": "15.3.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.56.2"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4.1.5",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"autoprefixer": "^10.4.21",
"eslint": "^9",
"eslint-config-next": "15.3.1",
"postcss": "^8.5.3",
"tailwindcss": "^4.1.5",
"typescript": "^5"
}
}

View File

@@ -0,0 +1,21 @@
{
"name": "CatLink",
"short_name": "CatLink",
"icons": [
{
"src": "/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}

View File

@@ -0,0 +1,45 @@
{
"name": "CatLink",
"short_name": "CatLink",
"icons": [
{
"src": "/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
"name": "CatLink",
"short_name": "CatLink",
"description": "Все ваши ссылки в одном месте. Публикуйте CatLink в био, мессенджерах и письмах.",
"start_url": "/",
"scope": "/",
"display": "standalone",
"theme_color": "#ffffff",
"background_color": "#ffffff",
"icons": [
{
"src": "/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}

View File

@@ -0,0 +1,25 @@
{
"name": "CatLink",
"short_name": "CatLink",
"description": "Все ваши ссылки в одном месте. Публикуйте CatLink в био, мессенджерах и письмах.",
"start_url": "/",
"scope": "/",
"display": "standalone",
"theme_color": "#ffffff",
"background_color": "#ffffff",
"icons": [
{
"src": "/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}

View File

@@ -0,0 +1,238 @@
'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>
);
}

View File

@@ -0,0 +1,199 @@
// src/app/dashboard/page.tsx
'use client'
import React, { useEffect, useState, Fragment } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { ProfileCard } from '../components/ProfileCard'
interface UserProfile {
avatar: string
full_name: string
email: string
bio?: string
last_login: string
date_joined: string
}
interface LinkItem {
id: number
title: string
url: string
}
interface Group {
id: number
name: 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)
useEffect(() => {
const token = localStorage.getItem('token')
if (!token) {
router.push('/auth/login')
return
}
const fetchData = async () => {
try {
// 1) Профиль
const uRes = await fetch(`/api/auth/user/`, {
headers: { Authorization: `Bearer ${token}` },
})
if (uRes.status === 401) {
localStorage.removeItem('token')
router.push('/auth/login')
return
}
if (!uRes.ok) throw new Error('Не удалось получить профиль')
const userData: UserProfile = await uRes.json()
// 2) Группы
const gRes = await fetch(`/api/groups/`, {
headers: { Authorization: `Bearer ${token}` },
})
if (gRes.status === 401) {
localStorage.removeItem('token')
router.push('/auth/login')
return
}
if (!gRes.ok) throw new Error('Не удалось загрузить группы')
const groupsData: Group[] = await gRes.json()
setUser(userData)
setGroups(groupsData)
} catch (err) {
// на любую ошибку — редирект на логин
console.error(err)
localStorage.removeItem('token')
router.push('/auth/login')
} finally {
setLoading(false)
}
}
fetchData()
}, [router])
if (loading) {
return <div className="flex items-center justify-center h-screen">Загрузка...</div>
}
return (
<div className="pb-8">
<div className="container">
<div className="row justify-content-center">
<div className="col-xl-10 col-xxl-9">
{/* Профиль пользователя */}
{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}
/>
)}
{/* Секция групп ссылок */}
<div className="card shadow mt-5">
<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={() => {
/* TODO: открыть модальное окно добавления группы */
}}
>
<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">
<span
style={{ cursor: 'pointer' }}
onClick={() =>
setExpandedGroup(
expandedGroup === group.id ? null : group.id
)
}
>
{group.name}{' '}
<span className="badge bg-secondary rounded-pill">
{group.links?.length ?? 0}
</span>
</span>
<div className="btn-group btn-group-sm">
<button className="btn btn-outline-primary">
<i className="bi bi-link-45deg"></i>
</button>
<button className="btn btn-outline-secondary">
<i className="bi bi-pencil"></i>
</button>
<button 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"
>
<a
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="flex-grow-1"
>
{link.title}
</a>
<div className="btn-group btn-group-sm">
<button className="btn btn-outline-secondary">
<i className="bi bi-pencil-fill"></i>
</button>
<button className="btn btn-outline-danger">
<i className="bi bi-trash-fill"></i>
</button>
</div>
</li>
))}
</ul>
</div>
)}
</Fragment>
))}
</div>
<div className="card-footer">
<nav>
<ul className="pagination pagination-sm justify-content-center mb-0">
{[1, 2, 3].map((p) => (
<li key={p} className="page-item">
<Link href={`?page=${p}`} className="page-link">
{p}
</Link>
</li>
))}
</ul>
</nav>
</div>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,199 @@
// src/app/dashboard/page.tsx
'use client'
import React, { useEffect, useState, Fragment } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { ProfileCard } from '../../components/ProfileCard'
interface UserProfile {
avatar: string
full_name: string
email: string
bio?: string
last_login: string
date_joined: string
}
interface LinkItem {
id: number
title: string
url: string
}
interface Group {
id: number
name: 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)
useEffect(() => {
const token = localStorage.getItem('token')
if (!token) {
router.push('/auth/login')
return
}
const fetchData = async () => {
try {
// 1) Профиль
const uRes = await fetch(`/api/auth/user/`, {
headers: { Authorization: `Bearer ${token}` },
})
if (uRes.status === 401) {
localStorage.removeItem('token')
router.push('/auth/login')
return
}
if (!uRes.ok) throw new Error('Не удалось получить профиль')
const userData: UserProfile = await uRes.json()
// 2) Группы
const gRes = await fetch(`/api/groups/`, {
headers: { Authorization: `Bearer ${token}` },
})
if (gRes.status === 401) {
localStorage.removeItem('token')
router.push('/auth/login')
return
}
if (!gRes.ok) throw new Error('Не удалось загрузить группы')
const groupsData: Group[] = await gRes.json()
setUser(userData)
setGroups(groupsData)
} catch (err) {
// на любую ошибку — редирект на логин
console.error(err)
localStorage.removeItem('token')
router.push('/auth/login')
} finally {
setLoading(false)
}
}
fetchData()
}, [router])
if (loading) {
return <div className="flex items-center justify-center h-screen">Загрузка...</div>
}
return (
<div className="pb-8">
<div className="container">
<div className="row justify-content-center">
<div className="col-xl-10 col-xxl-9">
{/* Профиль пользователя */}
{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}
/>
)}
{/* Секция групп ссылок */}
<div className="card shadow mt-5">
<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={() => {
/* TODO: открыть модальное окно добавления группы */
}}
>
<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">
<span
style={{ cursor: 'pointer' }}
onClick={() =>
setExpandedGroup(
expandedGroup === group.id ? null : group.id
)
}
>
{group.name}{' '}
<span className="badge bg-secondary rounded-pill">
{group.links?.length ?? 0}
</span>
</span>
<div className="btn-group btn-group-sm">
<button className="btn btn-outline-primary">
<i className="bi bi-link-45deg"></i>
</button>
<button className="btn btn-outline-secondary">
<i className="bi bi-pencil"></i>
</button>
<button 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"
>
<a
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="flex-grow-1"
>
{link.title}
</a>
<div className="btn-group btn-group-sm">
<button className="btn btn-outline-secondary">
<i className="bi bi-pencil-fill"></i>
</button>
<button className="btn btn-outline-danger">
<i className="bi bi-trash-fill"></i>
</button>
</div>
</li>
))}
</ul>
</div>
)}
</Fragment>
))}
</div>
<div className="card-footer">
<nav>
<ul className="pagination pagination-sm justify-content-center mb-0">
{[1, 2, 3].map((p) => (
<li key={p} className="page-item">
<Link href={`?page=${p}`} className="page-link">
{p}
</Link>
</li>
))}
</ul>
</nav>
</div>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,423 @@
'use client'
import React, { useEffect, useState, Fragment } from 'react'
import { useRouter } from 'next/navigation'
import Image from 'next/image'
import { Modal } from '../components/Modal' // Модалка из примера выше
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
}
interface Group {
id: number
name: string
image?: string
links: LinkItem[]
_open?: boolean // флаг аккордеона
}
type ModalType =
| 'addGroup'
| 'editGroup'
| 'deleteGroup'
| 'addLink'
| 'editLink'
| 'deleteLink'
| null
export default function DashboardPage() {
const router = useRouter()
const API = process.env.NEXT_PUBLIC_API_URL
const token = typeof window !== 'undefined' ? localStorage.getItem('token') : null
const [user, setUser] = useState<UserProfile | null>(null)
const [groups, setGroups] = useState<Group[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// для модалки
const [modalType, setModalType] = useState<ModalType>(null)
const [currentGroup, setCurrentGroup] = useState<Group | null>(null)
const [currentLink, setCurrentLink] = useState<LinkItem | null>(null)
const [formValue, setFormValue] = useState<{ name?: string; url?: string }>({})
// загрузка пользователя и групп
useEffect(() => {
if (!token) {
router.replace('/auth/login')
return
}
Promise.all([
fetch(`${API}/api/auth/user/`, {
headers: { Authorization: `Bearer ${token}` },
}),
fetch(`${API}/api/groups/`, {
headers: { Authorization: `Bearer ${token}` },
}),
])
.then(async ([uRes, gRes]) => {
if (!uRes.ok) throw new Error('Не удалось получить профиль')
if (!gRes.ok) throw new Error('Не удалось загрузить группы')
const [u, g] = await Promise.all([uRes.json(), gRes.json()])
setUser(u)
setGroups(g)
})
.catch((err) => setError((err as Error).message))
.finally(() => setLoading(false))
}, [router, API, token])
if (loading) return <div className="text-center mt-5">Загрузка...</div>
if (error) return <div className="alert alert-danger m-5">{error}</div>
// закрыть модалку
const closeModal = () => {
setModalType(null)
setCurrentGroup(null)
setCurrentLink(null)
setFormValue({})
}
// === Группы ===
async function handleGroupSubmit() {
if (!modalType) return
const isNew = modalType === 'addGroup'
const url = isNew
? `${API}/api/groups/`
: `${API}/api/groups/${currentGroup!.id}/`
const method = isNew ? 'POST' : 'PUT'
const res = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ name: formValue.name }),
})
if (res.ok) {
const grp = await res.json()
setGroups((prev) =>
isNew
? [...prev, { ...grp, links: [] }]
: prev.map((g) => (g.id === grp.id ? { ...g, name: grp.name } : g))
)
closeModal()
} else {
alert('Ошибка при сохранении группы')
}
}
async function handleGroupDelete() {
if (!currentGroup) return
const res = await fetch(`${API}/api/groups/${currentGroup.id}/`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
})
if (res.ok) {
setGroups((prev) => prev.filter((g) => g.id !== currentGroup.id))
closeModal()
} else {
alert('Ошибка при удалении группы')
}
}
// === Ссылки ===
async function handleLinkSubmit() {
if (!currentGroup) return
const isEdit = modalType === 'editLink'
const url = isEdit
? `${API}/api/links/${currentLink!.id}/`
: `${API}/api/links/`
const method = isEdit ? 'PUT' : 'POST'
const payload = {
title: formValue.name,
url: formValue.url,
group: currentGroup.id,
}
const res = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(payload),
})
if (res.ok) {
const linkData = await res.json()
setGroups((prev) =>
prev.map((g) =>
g.id === currentGroup.id
? {
...g,
links: isEdit
? g.links.map((l) => (l.id === linkData.id ? linkData : l))
: [...g.links, linkData],
}
: g
)
)
closeModal()
} else {
alert('Ошибка при сохранении ссылки')
}
}
async function handleLinkDelete() {
if (!currentLink || !currentGroup) return
const res = await fetch(`${API}/api/links/${currentLink.id}/`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
})
if (res.ok) {
setGroups((prev) =>
prev.map((g) =>
g.id === currentGroup.id
? { ...g, links: g.links.filter((l) => l.id !== currentLink.id) }
: g
)
)
closeModal()
} else {
alert('Ошибка при удалении ссылки')
}
}
return (
<div className="container py-5">
{/* Профиль */}
{user && (
<div className="card mb-4 shadow-sm">
<div className="card-body d-flex align-items-center">
<Image
src={user.avatar}
alt="Avatar"
width={60}
height={60}
className="rounded-circle me-3"
/>
<div>
<h5 className="card-title mb-1">
{user.full_name || user.username}
</h5>
<p className="mb-0 text-muted small">{user.email}</p>
</div>
</div>
</div>
)}
{/* Заголовок с кнопкой «Добавить группу» */}
<div className="mb-3 d-flex justify-content-between align-items-center">
<h4>Группы ссылок</h4>
<button
className="btn btn-sm btn-success"
onClick={() => setModalType('addGroup')}
>
<i className="bi bi-plus-lg"></i> Добавить
</button>
</div>
{/* Список групп (аккордеон) */}
{groups.map((group) => (
<Fragment key={group.id}>
<div className="card mb-2">
<div className="card-header d-flex justify-content-between">
<div>
{/* Редактировать имя группы */}
<a
href="#!"
className="me-2"
onClick={(e) => {
e.preventDefault()
setCurrentGroup(group)
setFormValue({ name: group.name })
setModalType('editGroup')
}}
>
<i className="bi bi-pencil"></i>
</a>
{/* Открыть/закрыть аккордеон */}
<span
className="fw-semibold"
style={{ cursor: 'pointer' }}
onClick={() =>
setGroups((pg) =>
pg.map((g) =>
g.id === group.id
? { ...g, _open: !g._open }
: g
)
)
}
>
{group.name}{' '}
<span className="badge bg-secondary">
{group.links.length}
</span>
</span>
</div>
<div>
{/* Добавить ссылку */}
<button
className="btn btn-sm btn-outline-primary me-2"
onClick={() => {
setCurrentGroup(group)
setFormValue({ name: '', url: '' })
setModalType('addLink')
}}
>
<i className="bi bi-link-45deg"></i>
</button>
{/* Удалить группу */}
<button
className="btn btn-sm btn-outline-danger"
onClick={() => {
setCurrentGroup(group)
setModalType('deleteGroup')
}}
>
<i className="bi bi-trash"></i>
</button>
</div>
</div>
{/* Ссылки в раскрытом состоянии */}
{group._open && (
<ul className="list-group list-group-flush">
{group.links.map((link) => (
<li
key={link.id}
className="list-group-item d-flex justify-content-between"
>
<a
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="flex-grow-1"
>
{link.title}
</a>
<div className="btn-group btn-group-sm">
<button
className="btn btn-outline-secondary"
onClick={() => {
setCurrentGroup(group)
setCurrentLink(link)
setFormValue({ name: link.title, url: link.url })
setModalType('editLink')
}}
>
<i className="bi bi-pencil-fill"></i>
</button>
<button
className="btn btn-outline-danger"
onClick={() => {
setCurrentGroup(group)
setCurrentLink(link)
setModalType('deleteLink')
}}
>
<i className="bi bi-trash-fill"></i>
</button>
</div>
</li>
))}
</ul>
)}
</div>
</Fragment>
))}
{/* Универсальная модалка */}
{modalType && (
<Modal onClose={closeModal}>
{/* Группа: добавить/редактировать */}
{(modalType === 'addGroup' || modalType === 'editGroup') && (
<>
<h5>
{modalType === 'addGroup' ? 'Новая группа' : 'Редактировать группу'}
</h5>
<input
type="text"
className="form-control mb-3"
placeholder="Название группы"
value={formValue.name || ''}
onChange={(e) =>
setFormValue({ ...formValue, name: e.target.value })
}
/>
<button className="btn btn-primary" onClick={handleGroupSubmit}>
Сохранить
</button>
</>
)}
{/* Подтверждение удаления группы */}
{modalType === 'deleteGroup' && (
<>
<h5>Удалить группу?</h5>
<p>Группа «{currentGroup?.name}» будет удалена навсегда.</p>
<button className="btn btn-danger me-2" onClick={handleGroupDelete}>
Удалить
</button>
<button className="btn btn-secondary" onClick={closeModal}>
Отмена
</button>
</>
)}
{/* Ссылка: добавить/редактировать */}
{(modalType === 'addLink' || modalType === 'editLink') && (
<>
<h5>{modalType === 'addLink' ? 'Новая ссылка' : 'Редактировать ссылку'}</h5>
<input
type="text"
className="form-control mb-2"
placeholder="Название"
value={formValue.name || ''}
onChange={(e) =>
setFormValue({ ...formValue, name: e.target.value })
}
/>
<input
type="url"
className="form-control mb-3"
placeholder="URL"
value={formValue.url || ''}
onChange={(e) =>
setFormValue({ ...formValue, url: e.target.value })
}
/>
<button className="btn btn-primary" onClick={handleLinkSubmit}>
Сохранить
</button>
</>
)}
{/* Подтверждение удаления ссылки */}
{modalType === 'deleteLink' && (
<>
<h5>Удалить ссылку?</h5>
<p>Ссылка «{currentLink?.title}» будет удалена.</p>
<button className="btn btn-danger me-2" onClick={handleLinkDelete}>
Удалить
</button>
<button className="btn btn-secondary" onClick={closeModal}>
Отмена
</button>
</>
)}
</Modal>
)}
</div>
)
}

View File

@@ -0,0 +1,423 @@
'use client'
import React, { useEffect, useState, Fragment } from 'react'
import { useRouter } from 'next/navigation'
import Image from 'next/image'
import { Modal } from '../../components/modal' // Модалка из примера выше
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
}
interface Group {
id: number
name: string
image?: string
links: LinkItem[]
_open?: boolean // флаг аккордеона
}
type ModalType =
| 'addGroup'
| 'editGroup'
| 'deleteGroup'
| 'addLink'
| 'editLink'
| 'deleteLink'
| null
export default function DashboardPage() {
const router = useRouter()
const API = process.env.NEXT_PUBLIC_API_URL
const token = typeof window !== 'undefined' ? localStorage.getItem('token') : null
const [user, setUser] = useState<UserProfile | null>(null)
const [groups, setGroups] = useState<Group[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// для модалки
const [modalType, setModalType] = useState<ModalType>(null)
const [currentGroup, setCurrentGroup] = useState<Group | null>(null)
const [currentLink, setCurrentLink] = useState<LinkItem | null>(null)
const [formValue, setFormValue] = useState<{ name?: string; url?: string }>({})
// загрузка пользователя и групп
useEffect(() => {
if (!token) {
router.replace('/auth/login')
return
}
Promise.all([
fetch(`${API}/api/auth/user/`, {
headers: { Authorization: `Bearer ${token}` },
}),
fetch(`${API}/api/groups/`, {
headers: { Authorization: `Bearer ${token}` },
}),
])
.then(async ([uRes, gRes]) => {
if (!uRes.ok) throw new Error('Не удалось получить профиль')
if (!gRes.ok) throw new Error('Не удалось загрузить группы')
const [u, g] = await Promise.all([uRes.json(), gRes.json()])
setUser(u)
setGroups(g)
})
.catch((err) => setError((err as Error).message))
.finally(() => setLoading(false))
}, [router, API, token])
if (loading) return <div className="text-center mt-5">Загрузка...</div>
if (error) return <div className="alert alert-danger m-5">{error}</div>
// закрыть модалку
const closeModal = () => {
setModalType(null)
setCurrentGroup(null)
setCurrentLink(null)
setFormValue({})
}
// === Группы ===
async function handleGroupSubmit() {
if (!modalType) return
const isNew = modalType === 'addGroup'
const url = isNew
? `${API}/api/groups/`
: `${API}/api/groups/${currentGroup!.id}/`
const method = isNew ? 'POST' : 'PUT'
const res = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ name: formValue.name }),
})
if (res.ok) {
const grp = await res.json()
setGroups((prev) =>
isNew
? [...prev, { ...grp, links: [] }]
: prev.map((g) => (g.id === grp.id ? { ...g, name: grp.name } : g))
)
closeModal()
} else {
alert('Ошибка при сохранении группы')
}
}
async function handleGroupDelete() {
if (!currentGroup) return
const res = await fetch(`${API}/api/groups/${currentGroup.id}/`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
})
if (res.ok) {
setGroups((prev) => prev.filter((g) => g.id !== currentGroup.id))
closeModal()
} else {
alert('Ошибка при удалении группы')
}
}
// === Ссылки ===
async function handleLinkSubmit() {
if (!currentGroup) return
const isEdit = modalType === 'editLink'
const url = isEdit
? `${API}/api/links/${currentLink!.id}/`
: `${API}/api/links/`
const method = isEdit ? 'PUT' : 'POST'
const payload = {
title: formValue.name,
url: formValue.url,
group: currentGroup.id,
}
const res = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(payload),
})
if (res.ok) {
const linkData = await res.json()
setGroups((prev) =>
prev.map((g) =>
g.id === currentGroup.id
? {
...g,
links: isEdit
? g.links.map((l) => (l.id === linkData.id ? linkData : l))
: [...g.links, linkData],
}
: g
)
)
closeModal()
} else {
alert('Ошибка при сохранении ссылки')
}
}
async function handleLinkDelete() {
if (!currentLink || !currentGroup) return
const res = await fetch(`${API}/api/links/${currentLink.id}/`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
})
if (res.ok) {
setGroups((prev) =>
prev.map((g) =>
g.id === currentGroup.id
? { ...g, links: g.links.filter((l) => l.id !== currentLink.id) }
: g
)
)
closeModal()
} else {
alert('Ошибка при удалении ссылки')
}
}
return (
<div className="container py-5">
{/* Профиль */}
{user && (
<div className="card mb-4 shadow-sm">
<div className="card-body d-flex align-items-center">
<Image
src={user.avatar}
alt="Avatar"
width={60}
height={60}
className="rounded-circle me-3"
/>
<div>
<h5 className="card-title mb-1">
{user.full_name || user.username}
</h5>
<p className="mb-0 text-muted small">{user.email}</p>
</div>
</div>
</div>
)}
{/* Заголовок с кнопкой «Добавить группу» */}
<div className="mb-3 d-flex justify-content-between align-items-center">
<h4>Группы ссылок</h4>
<button
className="btn btn-sm btn-success"
onClick={() => setModalType('addGroup')}
>
<i className="bi bi-plus-lg"></i> Добавить
</button>
</div>
{/* Список групп (аккордеон) */}
{groups.map((group) => (
<Fragment key={group.id}>
<div className="card mb-2">
<div className="card-header d-flex justify-content-between">
<div>
{/* Редактировать имя группы */}
<a
href="#!"
className="me-2"
onClick={(e) => {
e.preventDefault()
setCurrentGroup(group)
setFormValue({ name: group.name })
setModalType('editGroup')
}}
>
<i className="bi bi-pencil"></i>
</a>
{/* Открыть/закрыть аккордеон */}
<span
className="fw-semibold"
style={{ cursor: 'pointer' }}
onClick={() =>
setGroups((pg) =>
pg.map((g) =>
g.id === group.id
? { ...g, _open: !g._open }
: g
)
)
}
>
{group.name}{' '}
<span className="badge bg-secondary">
{group.links.length}
</span>
</span>
</div>
<div>
{/* Добавить ссылку */}
<button
className="btn btn-sm btn-outline-primary me-2"
onClick={() => {
setCurrentGroup(group)
setFormValue({ name: '', url: '' })
setModalType('addLink')
}}
>
<i className="bi bi-link-45deg"></i>
</button>
{/* Удалить группу */}
<button
className="btn btn-sm btn-outline-danger"
onClick={() => {
setCurrentGroup(group)
setModalType('deleteGroup')
}}
>
<i className="bi bi-trash"></i>
</button>
</div>
</div>
{/* Ссылки в раскрытом состоянии */}
{group._open && (
<ul className="list-group list-group-flush">
{group.links.map((link) => (
<li
key={link.id}
className="list-group-item d-flex justify-content-between"
>
<a
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="flex-grow-1"
>
{link.title}
</a>
<div className="btn-group btn-group-sm">
<button
className="btn btn-outline-secondary"
onClick={() => {
setCurrentGroup(group)
setCurrentLink(link)
setFormValue({ name: link.title, url: link.url })
setModalType('editLink')
}}
>
<i className="bi bi-pencil-fill"></i>
</button>
<button
className="btn btn-outline-danger"
onClick={() => {
setCurrentGroup(group)
setCurrentLink(link)
setModalType('deleteLink')
}}
>
<i className="bi bi-trash-fill"></i>
</button>
</div>
</li>
))}
</ul>
)}
</div>
</Fragment>
))}
{/* Универсальная модалка */}
{modalType && (
<Modal onClose={closeModal}>
{/* Группа: добавить/редактировать */}
{(modalType === 'addGroup' || modalType === 'editGroup') && (
<>
<h5>
{modalType === 'addGroup' ? 'Новая группа' : 'Редактировать группу'}
</h5>
<input
type="text"
className="form-control mb-3"
placeholder="Название группы"
value={formValue.name || ''}
onChange={(e) =>
setFormValue({ ...formValue, name: e.target.value })
}
/>
<button className="btn btn-primary" onClick={handleGroupSubmit}>
Сохранить
</button>
</>
)}
{/* Подтверждение удаления группы */}
{modalType === 'deleteGroup' && (
<>
<h5>Удалить группу?</h5>
<p>Группа «{currentGroup?.name}» будет удалена навсегда.</p>
<button className="btn btn-danger me-2" onClick={handleGroupDelete}>
Удалить
</button>
<button className="btn btn-secondary" onClick={closeModal}>
Отмена
</button>
</>
)}
{/* Ссылка: добавить/редактировать */}
{(modalType === 'addLink' || modalType === 'editLink') && (
<>
<h5>{modalType === 'addLink' ? 'Новая ссылка' : 'Редактировать ссылку'}</h5>
<input
type="text"
className="form-control mb-2"
placeholder="Название"
value={formValue.name || ''}
onChange={(e) =>
setFormValue({ ...formValue, name: e.target.value })
}
/>
<input
type="url"
className="form-control mb-3"
placeholder="URL"
value={formValue.url || ''}
onChange={(e) =>
setFormValue({ ...formValue, url: e.target.value })
}
/>
<button className="btn btn-primary" onClick={handleLinkSubmit}>
Сохранить
</button>
</>
)}
{/* Подтверждение удаления ссылки */}
{modalType === 'deleteLink' && (
<>
<h5>Удалить ссылку?</h5>
<p>Ссылка «{currentLink?.title}» будет удалена.</p>
<button className="btn btn-danger me-2" onClick={handleLinkDelete}>
Удалить
</button>
<button className="btn btn-secondary" onClick={closeModal}>
Отмена
</button>
</>
)}
</Modal>
)}
</div>
)
}

View File

@@ -0,0 +1,638 @@
// 'use client'
// import React, { useEffect, useState, Fragment } from 'react'
// import { useRouter } from 'next/navigation'
// import Image from 'next/image'
// import { Modal } from '../../components/modal' // Модалка из примера выше
// 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
// }
// interface Group {
// id: number
// name: string
// image?: string
// links: LinkItem[]
// _open?: boolean // флаг аккордеона
// }
// type ModalType =
// | 'addGroup'
// | 'editGroup'
// | 'deleteGroup'
// | 'addLink'
// | 'editLink'
// | 'deleteLink'
// | null
// export default function DashboardPage() {
// const router = useRouter()
// const API = process.env.NEXT_PUBLIC_API_URL
// const token = typeof window !== 'undefined' ? localStorage.getItem('token') : null
// const [user, setUser] = useState<UserProfile | null>(null)
// const [groups, setGroups] = useState<Group[]>([])
// const [loading, setLoading] = useState(true)
// const [error, setError] = useState<string | null>(null)
// // для модалки
// const [modalType, setModalType] = useState<ModalType>(null)
// const [currentGroup, setCurrentGroup] = useState<Group | null>(null)
// const [currentLink, setCurrentLink] = useState<LinkItem | null>(null)
// const [formValue, setFormValue] = useState<{ name?: string; url?: string }>({})
// // загрузка пользователя и групп
// useEffect(() => {
// if (!token) {
// router.replace('/auth/login')
// return
// }
// Promise.all([
// fetch(`${API}/api/auth/user/`, {
// headers: { Authorization: `Bearer ${token}` },
// }),
// fetch(`${API}/api/groups/`, {
// headers: { Authorization: `Bearer ${token}` },
// }),
// ])
// .then(async ([uRes, gRes]) => {
// if (!uRes.ok) throw new Error('Не удалось получить профиль')
// if (!gRes.ok) throw new Error('Не удалось загрузить группы')
// const [u, g] = await Promise.all([uRes.json(), gRes.json()])
// setUser(u)
// setGroups(g)
// })
// .catch((err) => setError((err as Error).message))
// .finally(() => setLoading(false))
// }, [router, API, token])
// if (loading) return <div className="text-center mt-5">Загрузка...</div>
// if (error) return <div className="alert alert-danger m-5">{error}</div>
// // закрыть модалку
// const closeModal = () => {
// setModalType(null)
// setCurrentGroup(null)
// setCurrentLink(null)
// setFormValue({})
// }
// // === Группы ===
// async function handleGroupSubmit() {
// if (!modalType) return
// const isNew = modalType === 'addGroup'
// const url = isNew
// ? `${API}/api/groups/`
// : `${API}/api/groups/${currentGroup!.id}/`
// const method = isNew ? 'POST' : 'PUT'
// const res = await fetch(url, {
// method,
// headers: {
// 'Content-Type': 'application/json',
// Authorization: `Bearer ${token}`,
// },
// body: JSON.stringify({ name: formValue.name }),
// })
// if (res.ok) {
// const grp = await res.json()
// setGroups((prev) =>
// isNew
// ? [...prev, { ...grp, links: [] }]
// : prev.map((g) => (g.id === grp.id ? { ...g, name: grp.name } : g))
// )
// closeModal()
// } else {
// alert('Ошибка при сохранении группы')
// }
// }
// async function handleGroupDelete() {
// if (!currentGroup) return
// const res = await fetch(`${API}/api/groups/${currentGroup.id}/`, {
// method: 'DELETE',
// headers: { Authorization: `Bearer ${token}` },
// })
// if (res.ok) {
// setGroups((prev) => prev.filter((g) => g.id !== currentGroup.id))
// closeModal()
// } else {
// alert('Ошибка при удалении группы')
// }
// }
// // === Ссылки ===
// async function handleLinkSubmit() {
// if (!currentGroup) return
// const isEdit = modalType === 'editLink'
// const url = isEdit
// ? `${API}/api/links/${currentLink!.id}/`
// : `${API}/api/links/`
// const method = isEdit ? 'PUT' : 'POST'
// const payload = {
// title: formValue.name,
// url: formValue.url,
// group: currentGroup.id,
// }
// const res = await fetch(url, {
// method,
// headers: {
// 'Content-Type': 'application/json',
// Authorization: `Bearer ${token}`,
// },
// body: JSON.stringify(payload),
// })
// if (res.ok) {
// const linkData = await res.json()
// setGroups((prev) =>
// prev.map((g) =>
// g.id === currentGroup.id
// ? {
// ...g,
// links: isEdit
// ? g.links.map((l) => (l.id === linkData.id ? linkData : l))
// : [...g.links, linkData],
// }
// : g
// )
// )
// closeModal()
// } else {
// alert('Ошибка при сохранении ссылки')
// }
// }
// async function handleLinkDelete() {
// if (!currentLink || !currentGroup) return
// const res = await fetch(`${API}/api/links/${currentLink.id}/`, {
// method: 'DELETE',
// headers: { Authorization: `Bearer ${token}` },
// })
// if (res.ok) {
// setGroups((prev) =>
// prev.map((g) =>
// g.id === currentGroup.id
// ? { ...g, links: g.links.filter((l) => l.id !== currentLink.id) }
// : g
// )
// )
// closeModal()
// } else {
// alert('Ошибка при удалении ссылки')
// }
// }
// return (
// <div className="container py-5">
// {/* Профиль */}
// {user && (
// <div className="card mb-4 shadow-sm">
// <div className="card-body d-flex align-items-center">
// <Image
// src={user.avatar}
// alt="Avatar"
// width={60}
// height={60}
// className="rounded-circle me-3"
// />
// <div>
// <h5 className="card-title mb-1">
// {user.full_name || user.username}
// </h5>
// <p className="mb-0 text-muted small">{user.email}</p>
// </div>
// </div>
// </div>
// )}
// {/* Заголовок с кнопкой «Добавить группу» */}
// <div className="mb-3 d-flex justify-content-between align-items-center">
// <h4>Группы ссылок</h4>
// <button
// className="btn btn-sm btn-success"
// onClick={() => setModalType('addGroup')}
// >
// <i className="bi bi-plus-lg"></i> Добавить
// </button>
// </div>
// {/* Список групп (аккордеон) */}
// {groups.map((group) => (
// <Fragment key={group.id}>
// <div className="card mb-2">
// <div className="card-header d-flex justify-content-between">
// <div>
// {/* Редактировать имя группы */}
// <a
// href="#!"
// className="me-2"
// onClick={(e) => {
// e.preventDefault()
// setCurrentGroup(group)
// setFormValue({ name: group.name })
// setModalType('editGroup')
// }}
// >
// <i className="bi bi-pencil"></i>
// </a>
// {/* Открыть/закрыть аккордеон */}
// <span
// className="fw-semibold"
// style={{ cursor: 'pointer' }}
// onClick={() =>
// setGroups((pg) =>
// pg.map((g) =>
// g.id === group.id
// ? { ...g, _open: !g._open }
// : g
// )
// )
// }
// >
// {group.name}{' '}
// <span className="badge bg-secondary">
// {group.links.length}
// </span>
// </span>
// </div>
// <div>
// {/* Добавить ссылку */}
// <button
// className="btn btn-sm btn-outline-primary me-2"
// onClick={() => {
// setCurrentGroup(group)
// setFormValue({ name: '', url: '' })
// setModalType('addLink')
// }}
// >
// <i className="bi bi-link-45deg"></i>
// </button>
// {/* Удалить группу */}
// <button
// className="btn btn-sm btn-outline-danger"
// onClick={() => {
// setCurrentGroup(group)
// setModalType('deleteGroup')
// }}
// >
// <i className="bi bi-trash"></i>
// </button>
// </div>
// </div>
// {/* Ссылки в раскрытом состоянии */}
// {group._open && (
// <ul className="list-group list-group-flush">
// {group.links.map((link) => (
// <li
// key={link.id}
// className="list-group-item d-flex justify-content-between"
// >
// <a
// href={link.url}
// target="_blank"
// rel="noopener noreferrer"
// className="flex-grow-1"
// >
// {link.title}
// </a>
// <div className="btn-group btn-group-sm">
// <button
// className="btn btn-outline-secondary"
// onClick={() => {
// setCurrentGroup(group)
// setCurrentLink(link)
// setFormValue({ name: link.title, url: link.url })
// setModalType('editLink')
// }}
// >
// <i className="bi bi-pencil-fill"></i>
// </button>
// <button
// className="btn btn-outline-danger"
// onClick={() => {
// setCurrentGroup(group)
// setCurrentLink(link)
// setModalType('deleteLink')
// }}
// >
// <i className="bi bi-trash-fill"></i>
// </button>
// </div>
// </li>
// ))}
// </ul>
// )}
// </div>
// </Fragment>
// ))}
// {/* Универсальная модалка */}
// {modalType && (
// <Modal onClose={closeModal}>
// {/* Группа: добавить/редактировать */}
// {(modalType === 'addGroup' || modalType === 'editGroup') && (
// <>
// <h5>
// {modalType === 'addGroup' ? 'Новая группа' : 'Редактировать группу'}
// </h5>
// <input
// type="text"
// className="form-control mb-3"
// placeholder="Название группы"
// value={formValue.name || ''}
// onChange={(e) =>
// setFormValue({ ...formValue, name: e.target.value })
// }
// />
// <button className="btn btn-primary" onClick={handleGroupSubmit}>
// Сохранить
// </button>
// </>
// )}
// {/* Подтверждение удаления группы */}
// {modalType === 'deleteGroup' && (
// <>
// <h5>Удалить группу?</h5>
// <p>Группа «{currentGroup?.name}» будет удалена навсегда.</p>
// <button className="btn btn-danger me-2" onClick={handleGroupDelete}>
// Удалить
// </button>
// <button className="btn btn-secondary" onClick={closeModal}>
// Отмена
// </button>
// </>
// )}
// {/* Ссылка: добавить/редактировать */}
// {(modalType === 'addLink' || modalType === 'editLink') && (
// <>
// <h5>{modalType === 'addLink' ? 'Новая ссылка' : 'Редактировать ссылку'}</h5>
// <input
// type="text"
// className="form-control mb-2"
// placeholder="Название"
// value={formValue.name || ''}
// onChange={(e) =>
// setFormValue({ ...formValue, name: e.target.value })
// }
// />
// <input
// type="url"
// className="form-control mb-3"
// placeholder="URL"
// value={formValue.url || ''}
// onChange={(e) =>
// setFormValue({ ...formValue, url: e.target.value })
// }
// />
// <button className="btn btn-primary" onClick={handleLinkSubmit}>
// Сохранить
// </button>
// </>
// )}
// {/* Подтверждение удаления ссылки */}
// {modalType === 'deleteLink' && (
// <>
// <h5>Удалить ссылку?</h5>
// <p>Ссылка «{currentLink?.title}» будет удалена.</p>
// <button className="btn btn-danger me-2" onClick={handleLinkDelete}>
// Удалить
// </button>
// <button className="btn btn-secondary" onClick={closeModal}>
// Отмена
// </button>
// </>
// )}
// </Modal>
// )}
// </div>
// )
// }
// src/app/(protected)/dashboard/page.tsx
'use client'
import React, { useEffect, useState, Fragment } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { ProfileCard } from '../components/ProfileCard'
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
// we'll always fill this in as an array:
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)
useEffect(() => {
const token = localStorage.getItem('token')
if (!token) {
router.push('/auth/login')
return
}
const API = process.env.NEXT_PUBLIC_API_URL ?? ''
// 1) fetch profile
// 2) fetch all groups
// 3) fetch all links
Promise.all([
fetch(`${API}/api/auth/user/`, {
headers: { Authorization: `Bearer ${token}` },
}),
fetch(`${API}/api/groups/`, {
headers: { Authorization: `Bearer ${token}` },
}),
fetch(`${API}/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()
// group.links might not exist on the payload, so we build it:
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])
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={() => {
/* open “add group” modal */
}}
>
<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={`${process.env.NEXT_PUBLIC_API_URL}${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 className="btn btn-outline-primary">
<i className="bi bi-link-45deg"></i>
</button>
<button className="btn btn-outline-secondary">
<i className="bi bi-pencil"></i>
</button>
<button 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
: `${process.env.NEXT_PUBLIC_API_URL}${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 className="btn btn-outline-secondary">
<i className="bi bi-pencil-fill"></i>
</button>
<button className="btn btn-outline-danger">
<i className="bi bi-trash-fill"></i>
</button>
</div>
</li>
))}
</ul>
</div>
)}
</Fragment>
))}
</div>
</div>
</section>
</div>
)
}

View File

@@ -0,0 +1,638 @@
// 'use client'
// import React, { useEffect, useState, Fragment } from 'react'
// import { useRouter } from 'next/navigation'
// import Image from 'next/image'
// import { Modal } from '../../components/modal' // Модалка из примера выше
// 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
// }
// interface Group {
// id: number
// name: string
// image?: string
// links: LinkItem[]
// _open?: boolean // флаг аккордеона
// }
// type ModalType =
// | 'addGroup'
// | 'editGroup'
// | 'deleteGroup'
// | 'addLink'
// | 'editLink'
// | 'deleteLink'
// | null
// export default function DashboardPage() {
// const router = useRouter()
// const API = process.env.NEXT_PUBLIC_API_URL
// const token = typeof window !== 'undefined' ? localStorage.getItem('token') : null
// const [user, setUser] = useState<UserProfile | null>(null)
// const [groups, setGroups] = useState<Group[]>([])
// const [loading, setLoading] = useState(true)
// const [error, setError] = useState<string | null>(null)
// // для модалки
// const [modalType, setModalType] = useState<ModalType>(null)
// const [currentGroup, setCurrentGroup] = useState<Group | null>(null)
// const [currentLink, setCurrentLink] = useState<LinkItem | null>(null)
// const [formValue, setFormValue] = useState<{ name?: string; url?: string }>({})
// // загрузка пользователя и групп
// useEffect(() => {
// if (!token) {
// router.replace('/auth/login')
// return
// }
// Promise.all([
// fetch(`${API}/api/auth/user/`, {
// headers: { Authorization: `Bearer ${token}` },
// }),
// fetch(`${API}/api/groups/`, {
// headers: { Authorization: `Bearer ${token}` },
// }),
// ])
// .then(async ([uRes, gRes]) => {
// if (!uRes.ok) throw new Error('Не удалось получить профиль')
// if (!gRes.ok) throw new Error('Не удалось загрузить группы')
// const [u, g] = await Promise.all([uRes.json(), gRes.json()])
// setUser(u)
// setGroups(g)
// })
// .catch((err) => setError((err as Error).message))
// .finally(() => setLoading(false))
// }, [router, API, token])
// if (loading) return <div className="text-center mt-5">Загрузка...</div>
// if (error) return <div className="alert alert-danger m-5">{error}</div>
// // закрыть модалку
// const closeModal = () => {
// setModalType(null)
// setCurrentGroup(null)
// setCurrentLink(null)
// setFormValue({})
// }
// // === Группы ===
// async function handleGroupSubmit() {
// if (!modalType) return
// const isNew = modalType === 'addGroup'
// const url = isNew
// ? `${API}/api/groups/`
// : `${API}/api/groups/${currentGroup!.id}/`
// const method = isNew ? 'POST' : 'PUT'
// const res = await fetch(url, {
// method,
// headers: {
// 'Content-Type': 'application/json',
// Authorization: `Bearer ${token}`,
// },
// body: JSON.stringify({ name: formValue.name }),
// })
// if (res.ok) {
// const grp = await res.json()
// setGroups((prev) =>
// isNew
// ? [...prev, { ...grp, links: [] }]
// : prev.map((g) => (g.id === grp.id ? { ...g, name: grp.name } : g))
// )
// closeModal()
// } else {
// alert('Ошибка при сохранении группы')
// }
// }
// async function handleGroupDelete() {
// if (!currentGroup) return
// const res = await fetch(`${API}/api/groups/${currentGroup.id}/`, {
// method: 'DELETE',
// headers: { Authorization: `Bearer ${token}` },
// })
// if (res.ok) {
// setGroups((prev) => prev.filter((g) => g.id !== currentGroup.id))
// closeModal()
// } else {
// alert('Ошибка при удалении группы')
// }
// }
// // === Ссылки ===
// async function handleLinkSubmit() {
// if (!currentGroup) return
// const isEdit = modalType === 'editLink'
// const url = isEdit
// ? `${API}/api/links/${currentLink!.id}/`
// : `${API}/api/links/`
// const method = isEdit ? 'PUT' : 'POST'
// const payload = {
// title: formValue.name,
// url: formValue.url,
// group: currentGroup.id,
// }
// const res = await fetch(url, {
// method,
// headers: {
// 'Content-Type': 'application/json',
// Authorization: `Bearer ${token}`,
// },
// body: JSON.stringify(payload),
// })
// if (res.ok) {
// const linkData = await res.json()
// setGroups((prev) =>
// prev.map((g) =>
// g.id === currentGroup.id
// ? {
// ...g,
// links: isEdit
// ? g.links.map((l) => (l.id === linkData.id ? linkData : l))
// : [...g.links, linkData],
// }
// : g
// )
// )
// closeModal()
// } else {
// alert('Ошибка при сохранении ссылки')
// }
// }
// async function handleLinkDelete() {
// if (!currentLink || !currentGroup) return
// const res = await fetch(`${API}/api/links/${currentLink.id}/`, {
// method: 'DELETE',
// headers: { Authorization: `Bearer ${token}` },
// })
// if (res.ok) {
// setGroups((prev) =>
// prev.map((g) =>
// g.id === currentGroup.id
// ? { ...g, links: g.links.filter((l) => l.id !== currentLink.id) }
// : g
// )
// )
// closeModal()
// } else {
// alert('Ошибка при удалении ссылки')
// }
// }
// return (
// <div className="container py-5">
// {/* Профиль */}
// {user && (
// <div className="card mb-4 shadow-sm">
// <div className="card-body d-flex align-items-center">
// <Image
// src={user.avatar}
// alt="Avatar"
// width={60}
// height={60}
// className="rounded-circle me-3"
// />
// <div>
// <h5 className="card-title mb-1">
// {user.full_name || user.username}
// </h5>
// <p className="mb-0 text-muted small">{user.email}</p>
// </div>
// </div>
// </div>
// )}
// {/* Заголовок с кнопкой «Добавить группу» */}
// <div className="mb-3 d-flex justify-content-between align-items-center">
// <h4>Группы ссылок</h4>
// <button
// className="btn btn-sm btn-success"
// onClick={() => setModalType('addGroup')}
// >
// <i className="bi bi-plus-lg"></i> Добавить
// </button>
// </div>
// {/* Список групп (аккордеон) */}
// {groups.map((group) => (
// <Fragment key={group.id}>
// <div className="card mb-2">
// <div className="card-header d-flex justify-content-between">
// <div>
// {/* Редактировать имя группы */}
// <a
// href="#!"
// className="me-2"
// onClick={(e) => {
// e.preventDefault()
// setCurrentGroup(group)
// setFormValue({ name: group.name })
// setModalType('editGroup')
// }}
// >
// <i className="bi bi-pencil"></i>
// </a>
// {/* Открыть/закрыть аккордеон */}
// <span
// className="fw-semibold"
// style={{ cursor: 'pointer' }}
// onClick={() =>
// setGroups((pg) =>
// pg.map((g) =>
// g.id === group.id
// ? { ...g, _open: !g._open }
// : g
// )
// )
// }
// >
// {group.name}{' '}
// <span className="badge bg-secondary">
// {group.links.length}
// </span>
// </span>
// </div>
// <div>
// {/* Добавить ссылку */}
// <button
// className="btn btn-sm btn-outline-primary me-2"
// onClick={() => {
// setCurrentGroup(group)
// setFormValue({ name: '', url: '' })
// setModalType('addLink')
// }}
// >
// <i className="bi bi-link-45deg"></i>
// </button>
// {/* Удалить группу */}
// <button
// className="btn btn-sm btn-outline-danger"
// onClick={() => {
// setCurrentGroup(group)
// setModalType('deleteGroup')
// }}
// >
// <i className="bi bi-trash"></i>
// </button>
// </div>
// </div>
// {/* Ссылки в раскрытом состоянии */}
// {group._open && (
// <ul className="list-group list-group-flush">
// {group.links.map((link) => (
// <li
// key={link.id}
// className="list-group-item d-flex justify-content-between"
// >
// <a
// href={link.url}
// target="_blank"
// rel="noopener noreferrer"
// className="flex-grow-1"
// >
// {link.title}
// </a>
// <div className="btn-group btn-group-sm">
// <button
// className="btn btn-outline-secondary"
// onClick={() => {
// setCurrentGroup(group)
// setCurrentLink(link)
// setFormValue({ name: link.title, url: link.url })
// setModalType('editLink')
// }}
// >
// <i className="bi bi-pencil-fill"></i>
// </button>
// <button
// className="btn btn-outline-danger"
// onClick={() => {
// setCurrentGroup(group)
// setCurrentLink(link)
// setModalType('deleteLink')
// }}
// >
// <i className="bi bi-trash-fill"></i>
// </button>
// </div>
// </li>
// ))}
// </ul>
// )}
// </div>
// </Fragment>
// ))}
// {/* Универсальная модалка */}
// {modalType && (
// <Modal onClose={closeModal}>
// {/* Группа: добавить/редактировать */}
// {(modalType === 'addGroup' || modalType === 'editGroup') && (
// <>
// <h5>
// {modalType === 'addGroup' ? 'Новая группа' : 'Редактировать группу'}
// </h5>
// <input
// type="text"
// className="form-control mb-3"
// placeholder="Название группы"
// value={formValue.name || ''}
// onChange={(e) =>
// setFormValue({ ...formValue, name: e.target.value })
// }
// />
// <button className="btn btn-primary" onClick={handleGroupSubmit}>
// Сохранить
// </button>
// </>
// )}
// {/* Подтверждение удаления группы */}
// {modalType === 'deleteGroup' && (
// <>
// <h5>Удалить группу?</h5>
// <p>Группа «{currentGroup?.name}» будет удалена навсегда.</p>
// <button className="btn btn-danger me-2" onClick={handleGroupDelete}>
// Удалить
// </button>
// <button className="btn btn-secondary" onClick={closeModal}>
// Отмена
// </button>
// </>
// )}
// {/* Ссылка: добавить/редактировать */}
// {(modalType === 'addLink' || modalType === 'editLink') && (
// <>
// <h5>{modalType === 'addLink' ? 'Новая ссылка' : 'Редактировать ссылку'}</h5>
// <input
// type="text"
// className="form-control mb-2"
// placeholder="Название"
// value={formValue.name || ''}
// onChange={(e) =>
// setFormValue({ ...formValue, name: e.target.value })
// }
// />
// <input
// type="url"
// className="form-control mb-3"
// placeholder="URL"
// value={formValue.url || ''}
// onChange={(e) =>
// setFormValue({ ...formValue, url: e.target.value })
// }
// />
// <button className="btn btn-primary" onClick={handleLinkSubmit}>
// Сохранить
// </button>
// </>
// )}
// {/* Подтверждение удаления ссылки */}
// {modalType === 'deleteLink' && (
// <>
// <h5>Удалить ссылку?</h5>
// <p>Ссылка «{currentLink?.title}» будет удалена.</p>
// <button className="btn btn-danger me-2" onClick={handleLinkDelete}>
// Удалить
// </button>
// <button className="btn btn-secondary" onClick={closeModal}>
// Отмена
// </button>
// </>
// )}
// </Modal>
// )}
// </div>
// )
// }
// src/app/(protected)/dashboard/page.tsx
'use client'
import React, { useEffect, useState, Fragment } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { ProfileCard } from '../../components/ProfileCard'
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
// we'll always fill this in as an array:
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)
useEffect(() => {
const token = localStorage.getItem('token')
if (!token) {
router.push('/auth/login')
return
}
const API = process.env.NEXT_PUBLIC_API_URL ?? ''
// 1) fetch profile
// 2) fetch all groups
// 3) fetch all links
Promise.all([
fetch(`${API}/api/auth/user/`, {
headers: { Authorization: `Bearer ${token}` },
}),
fetch(`${API}/api/groups/`, {
headers: { Authorization: `Bearer ${token}` },
}),
fetch(`${API}/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()
// group.links might not exist on the payload, so we build it:
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])
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={() => {
/* open “add group” modal */
}}
>
<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={`${process.env.NEXT_PUBLIC_API_URL}${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 className="btn btn-outline-primary">
<i className="bi bi-link-45deg"></i>
</button>
<button className="btn btn-outline-secondary">
<i className="bi bi-pencil"></i>
</button>
<button 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
: `${process.env.NEXT_PUBLIC_API_URL}${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 className="btn btn-outline-secondary">
<i className="bi bi-pencil-fill"></i>
</button>
<button className="btn btn-outline-danger">
<i className="bi bi-trash-fill"></i>
</button>
</div>
</li>
))}
</ul>
</div>
)}
</Fragment>
))}
</div>
</div>
</section>
</div>
)
}

View File

@@ -0,0 +1,213 @@
'use client'
import React, { useEffect, useState, Fragment } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { ProfileCard } from '../../components/ProfileCard'
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
// we'll always fill this in as an array:
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)
useEffect(() => {
const token = localStorage.getItem('token')
if (!token) {
router.push('/auth/login')
return
}
const API = process.env.NEXT_PUBLIC_API_URL ?? ''
// 1) fetch profile
// 2) fetch all groups
// 3) fetch all links
Promise.all([
fetch(`${API}/api/auth/user/`, {
headers: { Authorization: `Bearer ${token}` },
}),
fetch(`${API}/api/groups/`, {
headers: { Authorization: `Bearer ${token}` },
}),
fetch(`${API}/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()
// group.links might not exist on the payload, so we build it:
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])
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={() => {
/* open “add group” modal */
}}
>
<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={`${process.env.NEXT_PUBLIC_API_URL}${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 className="btn btn-outline-primary">
<i className="bi bi-link-45deg"></i>
</button>
<button className="btn btn-outline-secondary">
<i className="bi bi-pencil"></i>
</button>
<button 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
: `${process.env.NEXT_PUBLIC_API_URL}${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 className="btn btn-outline-secondary">
<i className="bi bi-pencil-fill"></i>
</button>
<button className="btn btn-outline-danger">
<i className="bi bi-trash-fill"></i>
</button>
</div>
</li>
))}
</ul>
</div>
)}
</Fragment>
))}
</div>
</div>
</section>
</div>
)
}

View File

@@ -0,0 +1,390 @@
'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'
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 = process.env.NEXT_PUBLIC_API_URL ?? ''
useEffect(() => {
const token = localStorage.getItem('token')
if (!token) {
router.push('/auth/login')
return
}
// загружаем профиль, группы и ссылки
Promise.all([
fetch(`${API}/api/auth/user/`, { headers: { Authorization: `Bearer ${token}` } }),
fetch(`${API}/api/groups/`, { headers: { Authorization: `Bearer ${token}` } }),
fetch(`${API}/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}/api/groups/`, { headers: { Authorization: `Bearer ${token}` } }),
fetch(`${API}/api/links/`, { headers: { Authorization: `Bearer ${token}` } }),
])
const groupsData = await gRes.json()
const linksData = await lRes.json()
const enrichedGroups: Group[] = groupsData.map((grp: any) => ({
...grp,
links: linksData.filter((link: LinkItem) => link.group === grp.id),
}))
setGroups(enrichedGroups)
}
// === Обработчики групп ===
function openAddGroup() {
setGroupModalMode('add')
setGroupForm({ name: '', 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}/api/groups/`
: `${API}/api/groups/${editingGroup?.id}/`
const method = groupModalMode === 'add' ? 'POST' : 'PATCH'
await fetch(url, {
method,
headers: { Authorization: `Bearer ${token}` },
body: fd,
})
setShowGroupModal(false)
await reloadData()
}
async function handleDeleteGroup(grp: Group) {
if (!confirm(`Удалить группу "${grp.name}"?`)) return
const token = localStorage.getItem('token')!
await fetch(`${API}/api/groups/${grp.id}/`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
})
await reloadData()
}
// === Обработчики ссылок ===
function openAddLink(grp: Group) {
setLinkModalMode('add')
setCurrentGroupIdForLink(grp.id)
setLinkForm({ title: '', url: '', 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}/api/links/`
: `${API}/api/links/${editingLink?.id}/`
const method = linkModalMode === 'add' ? 'POST' : 'PATCH'
await fetch(url, {
method,
headers: { Authorization: `Bearer ${token}` },
body: fd,
})
setShowLinkModal(false)
await reloadData()
}
async function handleDeleteLink(link: LinkItem) {
if (!confirm(`Удалить ссылку "${link.title}"?`)) return
const token = localStorage.getItem('token')!
await fetch(`${API}/api/links/${link.id}/`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
})
await reloadData()
}
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>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,15 @@
// src/app/(protected)/layout.tsx
"use client"
import { ReactNode } from "react"
import { Header } from "../components/Header"
import { Footer } from "../components/Footer"
export default function ProtectedLayout({ children }: { children: ReactNode }) {
return (
<>
<Header />
<main className="mt-4">{children}</main>
<Footer />
</>
)
}

View File

@@ -0,0 +1,176 @@
// src/app/layout.tsx
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { ReactNode } from "react";
import Script from "next/script";
import Link from "next/link";
import Image from "next/image";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "CatLink",
description: "Ваши ссылки в одном месте",
};
export default function RootLayout({
children,
}: {
children: ReactNode;
}) {
return (
<html lang="ru">
<head>
<meta charSet="utf-8" />
<link
rel="apple-touch-icon"
type="image/png"
sizes="180x180"
href="/assets/img/apple-touch-icon.png?h=0f5e29c1169e75a7003e818478b67caa"
/>
<link
rel="icon"
type="image/png"
sizes="96x96"
href="/assets/img/favicon-96x96.png?h=c8792ce927e01a494c4af48bed5dc5e3"
/>
<link
rel="icon"
type="image/png"
sizes="192x192"
href="/assets/img/web-app-manifest-192x192.png?h=c8792ce927e01a494c4af48bed5dc5e3"
/>
<link
rel="icon"
type="image/png"
sizes="512x512"
href="/assets/img/web-app-manifest-512x512.png?h=c8792ce927e01a494c4af48bed5dc5e3"
/>
<link
rel="stylesheet"
href="/assets/bootstrap/css/bootstrap.min.css?h=608a9825a1f76f674715160908e57785"
/>
<link
rel="stylesheet"
href="/assets/css/Lato.css?h=8253736d3a23b522f64b7e7d96d1d8ff"
/>
<link
rel="manifest"
href="/manifest.json?h=457941ffad3c027c946331c09a4d7d2f"
/>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css"
/>
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#ffffff" />
</head>
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
{/* Header (fixed to top) */}
<nav className="navbar navbar-expand bg-light fixed-top shadow-sm">
<div className="container">
<Link href="/" className="navbar-brand d-flex align-items-center">
<Image
src="/assets/img/CAT.png"
alt="CatLink"
width={89}
height={89}
/>
<span className="ms-2">CatLink</span>
</Link>
<button
className="navbar-toggler"
type="button"
data-bs-toggle="collapse"
data-bs-target="#navcol-1"
/>
<div className="collapse navbar-collapse" id="navcol-1">
<Link href="/auth/login" className="btn btn-primary ms-auto">
<i className="fa fa-user"></i>
<span className="d-none d-sm-inline"> Вход</span>
</Link>
</div>
</div>
</nav>
{/* Main content: добавляем дополнительный отступ сверху */}
<main
style={{
paddingTop: "100px", // <-- тут увеличиваем интервал
paddingBottom: "200px"
}}
// или через класс bootstrap: className="pt-5 pb-5"
>
{children}
</main>
{/* Footer (fixed to bottom) */}
<footer className="bg-light footer fixed-bottom border-top">
<div className="container py-2">
<div className="row">
<div className="col-lg-6 text-center text-lg-start mb-2 mb-lg-0">
<ul className="list-inline mb-1">
<li className="list-inline-item">
<Link href="#">About</Link>
</li>
<li className="list-inline-item"></li>
<li className="list-inline-item">
<Link href="#">Contact</Link>
</li>
<li className="list-inline-item"></li>
<li className="list-inline-item">
<Link href="#">Terms of Use</Link>
</li>
<li className="list-inline-item"></li>
<li className="list-inline-item">
<Link href="#">Privacy Policy</Link>
</li>
</ul>
<p className="text-muted small mb-0">© CatLink 2025</p>
</div>
<div className="col-lg-6 text-center text-lg-end">
<ul className="list-inline mb-0">
<li className="list-inline-item">
<Link href="#">
<i className="fa fa-facebook fa-lg fa-fw"></i>
</Link>
</li>
<li className="list-inline-item">
<Link href="#">
<i className="fa fa-twitter fa-lg fa-fw"></i>
</Link>
</li>
<li className="list-inline-item">
<Link href="#">
<i className="fa fa-instagram fa-lg fa-fw"></i>
</Link>
</li>
</ul>
</div>
</div>
</div>
</footer>
{/* Scripts */}
<Script
src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.0/jquery.min.js"
strategy="beforeInteractive"
/>
<Script
src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
strategy="beforeInteractive"
/>
<Script src="/assets/js/bs-init.js" strategy="lazyOnload" />
</body>
</html>
);
}

View File

@@ -0,0 +1,176 @@
// src/app/layout.tsx
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "../globals.css";
import { ReactNode } from "react";
import Script from "next/script";
import Link from "next/link";
import Image from "next/image";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "CatLink",
description: "Ваши ссылки в одном месте",
};
export default function RootLayout({
children,
}: {
children: ReactNode;
}) {
return (
<html lang="ru">
<head>
<meta charSet="utf-8" />
<link
rel="apple-touch-icon"
type="image/png"
sizes="180x180"
href="/assets/img/apple-touch-icon.png?h=0f5e29c1169e75a7003e818478b67caa"
/>
<link
rel="icon"
type="image/png"
sizes="96x96"
href="/assets/img/favicon-96x96.png?h=c8792ce927e01a494c4af48bed5dc5e3"
/>
<link
rel="icon"
type="image/png"
sizes="192x192"
href="/assets/img/web-app-manifest-192x192.png?h=c8792ce927e01a494c4af48bed5dc5e3"
/>
<link
rel="icon"
type="image/png"
sizes="512x512"
href="/assets/img/web-app-manifest-512x512.png?h=c8792ce927e01a494c4af48bed5dc5e3"
/>
<link
rel="stylesheet"
href="/assets/bootstrap/css/bootstrap.min.css?h=608a9825a1f76f674715160908e57785"
/>
<link
rel="stylesheet"
href="/assets/css/Lato.css?h=8253736d3a23b522f64b7e7d96d1d8ff"
/>
<link
rel="manifest"
href="/manifest.json?h=457941ffad3c027c946331c09a4d7d2f"
/>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css"
/>
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#ffffff" />
</head>
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
{/* Header (fixed to top) */}
<nav className="navbar navbar-expand bg-light fixed-top shadow-sm">
<div className="container">
<Link href="/" className="navbar-brand d-flex align-items-center">
<Image
src="/assets/img/CAT.png"
alt="CatLink"
width={89}
height={89}
/>
<span className="ms-2">CatLink</span>
</Link>
<button
className="navbar-toggler"
type="button"
data-bs-toggle="collapse"
data-bs-target="#navcol-1"
/>
<div className="collapse navbar-collapse" id="navcol-1">
<Link href="/auth/login" className="btn btn-primary ms-auto">
<i className="fa fa-user"></i>
<span className="d-none d-sm-inline"> Вход</span>
</Link>
</div>
</div>
</nav>
{/* Main content: добавляем дополнительный отступ сверху */}
<main
style={{
paddingTop: "100px", // <-- тут увеличиваем интервал
paddingBottom: "200px"
}}
// или через класс bootstrap: className="pt-5 pb-5"
>
{children}
</main>
{/* Footer (fixed to bottom) */}
<footer className="bg-light footer fixed-bottom border-top">
<div className="container py-2">
<div className="row">
<div className="col-lg-6 text-center text-lg-start mb-2 mb-lg-0">
<ul className="list-inline mb-1">
<li className="list-inline-item">
<Link href="#">About</Link>
</li>
<li className="list-inline-item"></li>
<li className="list-inline-item">
<Link href="#">Contact</Link>
</li>
<li className="list-inline-item"></li>
<li className="list-inline-item">
<Link href="#">Terms of Use</Link>
</li>
<li className="list-inline-item"></li>
<li className="list-inline-item">
<Link href="#">Privacy Policy</Link>
</li>
</ul>
<p className="text-muted small mb-0">© CatLink 2025</p>
</div>
<div className="col-lg-6 text-center text-lg-end">
<ul className="list-inline mb-0">
<li className="list-inline-item">
<Link href="#">
<i className="fa fa-facebook fa-lg fa-fw"></i>
</Link>
</li>
<li className="list-inline-item">
<Link href="#">
<i className="fa fa-twitter fa-lg fa-fw"></i>
</Link>
</li>
<li className="list-inline-item">
<Link href="#">
<i className="fa fa-instagram fa-lg fa-fw"></i>
</Link>
</li>
</ul>
</div>
</div>
</div>
</footer>
{/* Scripts */}
<Script
src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.0/jquery.min.js"
strategy="beforeInteractive"
/>
<Script
src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
strategy="beforeInteractive"
/>
<Script src="/assets/js/bs-init.js" strategy="lazyOnload" />
</body>
</html>
);
}

View File

@@ -0,0 +1,177 @@
// src/app/layout.tsx
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "../globals.css";
import { ReactNode } from "react";
import Script from "next/script";
import Link from "next/link";
import Image from "next/image";
import { LayoutWrapper } from '../components/LayoutWrapper'
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "CatLink",
description: "Ваши ссылки в одном месте",
};
export default function RootLayout({
children,
}: {
children: ReactNode;
}) {
return (
<html lang="ru">
<head>
<meta charSet="utf-8" />
<link
rel="apple-touch-icon"
type="image/png"
sizes="180x180"
href="/assets/img/apple-touch-icon.png?h=0f5e29c1169e75a7003e818478b67caa"
/>
<link
rel="icon"
type="image/png"
sizes="96x96"
href="/assets/img/favicon-96x96.png?h=c8792ce927e01a494c4af48bed5dc5e3"
/>
<link
rel="icon"
type="image/png"
sizes="192x192"
href="/assets/img/web-app-manifest-192x192.png?h=c8792ce927e01a494c4af48bed5dc5e3"
/>
<link
rel="icon"
type="image/png"
sizes="512x512"
href="/assets/img/web-app-manifest-512x512.png?h=c8792ce927e01a494c4af48bed5dc5e3"
/>
<link
rel="stylesheet"
href="/assets/bootstrap/css/bootstrap.min.css?h=608a9825a1f76f674715160908e57785"
/>
<link
rel="stylesheet"
href="/assets/css/Lato.css?h=8253736d3a23b522f64b7e7d96d1d8ff"
/>
<link
rel="manifest"
href="/manifest.json?h=457941ffad3c027c946331c09a4d7d2f"
/>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css"
/>
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#ffffff" />
</head>
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
{/* Header (fixed to top) */}
<nav className="navbar navbar-expand bg-light fixed-top shadow-sm">
<div className="container">
<Link href="/" className="navbar-brand d-flex align-items-center">
<Image
src="/assets/img/CAT.png"
alt="CatLink"
width={89}
height={89}
/>
<span className="ms-2">CatLink</span>
</Link>
<button
className="navbar-toggler"
type="button"
data-bs-toggle="collapse"
data-bs-target="#navcol-1"
/>
<div className="collapse navbar-collapse" id="navcol-1">
<Link href="/auth/login" className="btn btn-primary ms-auto">
<i className="fa fa-user"></i>
<span className="d-none d-sm-inline"> Вход</span>
</Link>
</div>
</div>
</nav>
{/* Main content: добавляем дополнительный отступ сверху */}
<main
style={{
paddingTop: "100px", // <-- тут увеличиваем интервал
paddingBottom: "200px"
}}
// или через класс bootstrap: className="pt-5 pb-5"
>
{children}
</main>
{/* Footer (fixed to bottom) */}
<footer className="bg-light footer fixed-bottom border-top">
<div className="container py-2">
<div className="row">
<div className="col-lg-6 text-center text-lg-start mb-2 mb-lg-0">
<ul className="list-inline mb-1">
<li className="list-inline-item">
<Link href="#">About</Link>
</li>
<li className="list-inline-item"></li>
<li className="list-inline-item">
<Link href="#">Contact</Link>
</li>
<li className="list-inline-item"></li>
<li className="list-inline-item">
<Link href="#">Terms of Use</Link>
</li>
<li className="list-inline-item"></li>
<li className="list-inline-item">
<Link href="#">Privacy Policy</Link>
</li>
</ul>
<p className="text-muted small mb-0">© CatLink 2025</p>
</div>
<div className="col-lg-6 text-center text-lg-end">
<ul className="list-inline mb-0">
<li className="list-inline-item">
<Link href="#">
<i className="fa fa-facebook fa-lg fa-fw"></i>
</Link>
</li>
<li className="list-inline-item">
<Link href="#">
<i className="fa fa-twitter fa-lg fa-fw"></i>
</Link>
</li>
<li className="list-inline-item">
<Link href="#">
<i className="fa fa-instagram fa-lg fa-fw"></i>
</Link>
</li>
</ul>
</div>
</div>
</div>
</footer>
{/* Scripts */}
<Script
src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.0/jquery.min.js"
strategy="beforeInteractive"
/>
<Script
src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
strategy="beforeInteractive"
/>
<Script src="/assets/js/bs-init.js" strategy="lazyOnload" />
</body>
</html>
);
}

View File

@@ -0,0 +1,173 @@
// src/app/layout.tsx
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "../globals.css";
import { ReactNode } from "react";
import Script from "next/script";
import Link from "next/link";
import Image from "next/image";
import { LayoutWrapper } from '../components/LayoutWrapper'
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "CatLink",
description: "Ваши ссылки в одном месте",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ru">
<head>
<meta charSet="utf-8" />
<link
rel="apple-touch-icon"
type="image/png"
sizes="180x180"
href="/assets/img/apple-touch-icon.png?h=0f5e29c1169e75a7003e818478b67caa"
/>
<link
rel="icon"
type="image/png"
sizes="96x96"
href="/assets/img/favicon-96x96.png?h=c8792ce927e01a494c4af48bed5dc5e3"
/>
<link
rel="icon"
type="image/png"
sizes="192x192"
href="/assets/img/web-app-manifest-192x192.png?h=c8792ce927e01a494c4af48bed5dc5e3"
/>
<link
rel="icon"
type="image/png"
sizes="512x512"
href="/assets/img/web-app-manifest-512x512.png?h=c8792ce927e01a494c4af48bed5dc5e3"
/>
<link
rel="stylesheet"
href="/assets/bootstrap/css/bootstrap.min.css?h=608a9825a1f76f674715160908e57785"
/>
<link
rel="stylesheet"
href="/assets/css/Lato.css?h=8253736d3a23b522f64b7e7d96d1d8ff"
/>
<link
rel="manifest"
href="/manifest.json?h=457941ffad3c027c946331c09a4d7d2f"
/>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css"
/>
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#ffffff" />
</head>
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
{/* Header (fixed to top) */}
<nav className="navbar navbar-expand bg-light fixed-top shadow-sm">
<div className="container">
<Link href="/" className="navbar-brand d-flex align-items-center">
<Image
src="/assets/img/CAT.png"
alt="CatLink"
width={89}
height={89}
/>
<span className="ms-2">CatLink</span>
</Link>
<button
className="navbar-toggler"
type="button"
data-bs-toggle="collapse"
data-bs-target="#navcol-1"
/>
<div className="collapse navbar-collapse" id="navcol-1">
<Link href="/auth/login" className="btn btn-primary ms-auto">
<i className="fa fa-user"></i>
<span className="d-none d-sm-inline"> Вход</span>
</Link>
</div>
</div>
</nav>
{/* Main content: добавляем дополнительный отступ сверху */}
<main
style={{
paddingTop: "100px", // <-- тут увеличиваем интервал
paddingBottom: "200px"
}}
// или через класс bootstrap: className="pt-5 pb-5"
>
{children}
</main>
{/* Footer (fixed to bottom) */}
<footer className="bg-light footer fixed-bottom border-top">
<div className="container py-2">
<div className="row">
<div className="col-lg-6 text-center text-lg-start mb-2 mb-lg-0">
<ul className="list-inline mb-1">
<li className="list-inline-item">
<Link href="#">About</Link>
</li>
<li className="list-inline-item"></li>
<li className="list-inline-item">
<Link href="#">Contact</Link>
</li>
<li className="list-inline-item"></li>
<li className="list-inline-item">
<Link href="#">Terms of Use</Link>
</li>
<li className="list-inline-item"></li>
<li className="list-inline-item">
<Link href="#">Privacy Policy</Link>
</li>
</ul>
<p className="text-muted small mb-0">© CatLink 2025</p>
</div>
<div className="col-lg-6 text-center text-lg-end">
<ul className="list-inline mb-0">
<li className="list-inline-item">
<Link href="#">
<i className="fa fa-facebook fa-lg fa-fw"></i>
</Link>
</li>
<li className="list-inline-item">
<Link href="#">
<i className="fa fa-twitter fa-lg fa-fw"></i>
</Link>
</li>
<li className="list-inline-item">
<Link href="#">
<i className="fa fa-instagram fa-lg fa-fw"></i>
</Link>
</li>
</ul>
</div>
</div>
</div>
</footer>
{/* Scripts */}
<Script
src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.0/jquery.min.js"
strategy="beforeInteractive"
/>
<Script
src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
strategy="beforeInteractive"
/>
<Script src="/assets/js/bs-init.js" strategy="lazyOnload" />
</body>
</html>
);
}

View File

@@ -0,0 +1,173 @@
// src/app/layout.tsx
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "../globals.css";
import { ReactNode } from "react";
import Script from "next/script";
import Link from "next/link";
import Image from "next/image";
import { LayoutWrapper } from '../components/LayoutWrapper'
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "CatLink",
description: "Ваши ссылки в одном месте",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ru">
<head>
<meta charSet="utf-8" />
<link
rel="apple-touch-icon"
type="image/png"
sizes="180x180"
href="/assets/img/apple-touch-icon.png?h=0f5e29c1169e75a7003e818478b67caa"
/>
<link
rel="icon"
type="image/png"
sizes="96x96"
href="/assets/img/favicon-96x96.png?h=c8792ce927e01a494c4af48bed5dc5e3"
/>
<link
rel="icon"
type="image/png"
sizes="192x192"
href="/assets/img/web-app-manifest-192x192.png?h=c8792ce927e01a494c4af48bed5dc5e3"
/>
<link
rel="icon"
type="image/png"
sizes="512x512"
href="/assets/img/web-app-manifest-512x512.png?h=c8792ce927e01a494c4af48bed5dc5e3"
/>
<link
rel="stylesheet"
href="/assets/bootstrap/css/bootstrap.min.css?h=608a9825a1f76f674715160908e57785"
/>
<link
rel="stylesheet"
href="/assets/css/Lato.css?h=8253736d3a23b522f64b7e7d96d1d8ff"
/>
<link
rel="manifest"
href="/manifest.json?h=457941ffad3c027c946331c09a4d7d2f"
/>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css"
/>
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#ffffff" />
</head>
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
{/* Header (fixed to top) */}
<nav className="navbar navbar-expand bg-light fixed-top shadow-sm">
<div className="container">
<Link href="/" className="navbar-brand d-flex align-items-center">
<Image
src="/assets/img/CAT.png"
alt="CatLink"
width={89}
height={89}
/>
<span className="ms-2">CatLink</span>
</Link>
<button
className="navbar-toggler"
type="button"
data-bs-toggle="collapse"
data-bs-target="#navcol-1"
/>
<div className="collapse navbar-collapse" id="navcol-1">
<Link href="/auth/login" className="btn btn-primary ms-auto">
<i className="fa fa-user"></i>
<span className="d-none d-sm-inline"> Вход</span>
</Link>
</div>
</div>
</nav>
{/* Main content: добавляем дополнительный отступ сверху */}
<main
style={{
paddingTop: "100px", // <-- тут увеличиваем интервал
paddingBottom: "200px"
}}
// или через класс bootstrap: className="pt-5 pb-5"
>
<LayoutWrapper>{children}</LayoutWrapper>
</main>
{/* Footer (fixed to bottom) */}
<footer className="bg-light footer fixed-bottom border-top">
<div className="container py-2">
<div className="row">
<div className="col-lg-6 text-center text-lg-start mb-2 mb-lg-0">
<ul className="list-inline mb-1">
<li className="list-inline-item">
<Link href="#">About</Link>
</li>
<li className="list-inline-item"></li>
<li className="list-inline-item">
<Link href="#">Contact</Link>
</li>
<li className="list-inline-item"></li>
<li className="list-inline-item">
<Link href="#">Terms of Use</Link>
</li>
<li className="list-inline-item"></li>
<li className="list-inline-item">
<Link href="#">Privacy Policy</Link>
</li>
</ul>
<p className="text-muted small mb-0">© CatLink 2025</p>
</div>
<div className="col-lg-6 text-center text-lg-end">
<ul className="list-inline mb-0">
<li className="list-inline-item">
<Link href="#">
<i className="fa fa-facebook fa-lg fa-fw"></i>
</Link>
</li>
<li className="list-inline-item">
<Link href="#">
<i className="fa fa-twitter fa-lg fa-fw"></i>
</Link>
</li>
<li className="list-inline-item">
<Link href="#">
<i className="fa fa-instagram fa-lg fa-fw"></i>
</Link>
</li>
</ul>
</div>
</div>
</div>
</footer>
{/* Scripts */}
<Script
src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.0/jquery.min.js"
strategy="beforeInteractive"
/>
<Script
src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
strategy="beforeInteractive"
/>
<Script src="/assets/js/bs-init.js" strategy="lazyOnload" />
</body>
</html>
);
}

View File

@@ -0,0 +1,127 @@
// src/app/layout.tsx
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "../globals.css";
import { ReactNode } from "react";
import Script from "next/script";
import Link from "next/link";
import Image from "next/image";
import { LayoutWrapper } from '../components/LayoutWrapper'
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "CatLink",
description: "Ваши ссылки в одном месте",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
{/* Header (fixed to top) */}
<nav className="navbar navbar-expand bg-light fixed-top shadow-sm">
<div className="container">
<Link href="/" className="navbar-brand d-flex align-items-center">
<Image
src="/assets/img/CAT.png"
alt="CatLink"
width={89}
height={89}
/>
<span className="ms-2">CatLink</span>
</Link>
<button
className="navbar-toggler"
type="button"
data-bs-toggle="collapse"
data-bs-target="#navcol-1"
/>
<div className="collapse navbar-collapse" id="navcol-1">
<Link href="/auth/login" className="btn btn-primary ms-auto">
<i className="fa fa-user"></i>
<span className="d-none d-sm-inline"> Вход</span>
</Link>
</div>
</div>
</nav>
{/* Main content: добавляем дополнительный отступ сверху */}
<main
style={{
paddingTop: "100px", // <-- тут увеличиваем интервал
paddingBottom: "200px"
}}
// или через класс bootstrap: className="pt-5 pb-5"
>
<LayoutWrapper>{children}</LayoutWrapper>
</main>
{/* Footer (fixed to bottom) */}
<footer className="bg-light footer fixed-bottom border-top">
<div className="container py-2">
<div className="row">
<div className="col-lg-6 text-center text-lg-start mb-2 mb-lg-0">
<ul className="list-inline mb-1">
<li className="list-inline-item">
<Link href="#">About</Link>
</li>
<li className="list-inline-item"></li>
<li className="list-inline-item">
<Link href="#">Contact</Link>
</li>
<li className="list-inline-item"></li>
<li className="list-inline-item">
<Link href="#">Terms of Use</Link>
</li>
<li className="list-inline-item"></li>
<li className="list-inline-item">
<Link href="#">Privacy Policy</Link>
</li>
</ul>
<p className="text-muted small mb-0">© CatLink 2025</p>
</div>
<div className="col-lg-6 text-center text-lg-end">
<ul className="list-inline mb-0">
<li className="list-inline-item">
<Link href="#">
<i className="fa fa-facebook fa-lg fa-fw"></i>
</Link>
</li>
<li className="list-inline-item">
<Link href="#">
<i className="fa fa-twitter fa-lg fa-fw"></i>
</Link>
</li>
<li className="list-inline-item">
<Link href="#">
<i className="fa fa-instagram fa-lg fa-fw"></i>
</Link>
</li>
</ul>
</div>
</div>
</div>
</footer>
{/* Scripts */}
<Script
src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.0/jquery.min.js"
strategy="beforeInteractive"
/>
<Script
src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
strategy="beforeInteractive"
/>
<Script src="/assets/js/bs-init.js" strategy="lazyOnload" />
</body>
</html>
);
}

View File

@@ -0,0 +1,127 @@
// src/app/layout.tsx
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "../globals.css";
import { ReactNode } from "react";
import Script from "next/script";
import Link from "next/link";
import Image from "next/image";
import { LayoutWrapper } from '../components/LayoutWrapper'
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "CatLink",
description: "Ваши ссылки в одном месте",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ru">
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
{/* Header (fixed to top) */}
<nav className="navbar navbar-expand bg-light fixed-top shadow-sm">
<div className="container">
<Link href="/" className="navbar-brand d-flex align-items-center">
<Image
src="/assets/img/CAT.png"
alt="CatLink"
width={89}
height={89}
/>
<span className="ms-2">CatLink</span>
</Link>
<button
className="navbar-toggler"
type="button"
data-bs-toggle="collapse"
data-bs-target="#navcol-1"
/>
<div className="collapse navbar-collapse" id="navcol-1">
<Link href="/auth/login" className="btn btn-primary ms-auto">
<i className="fa fa-user"></i>
<span className="d-none d-sm-inline"> Вход</span>
</Link>
</div>
</div>
</nav>
{/* Main content: добавляем дополнительный отступ сверху */}
<main
style={{
paddingTop: "100px", // <-- тут увеличиваем интервал
paddingBottom: "200px"
}}
// или через класс bootstrap: className="pt-5 pb-5"
>
<LayoutWrapper>{children}</LayoutWrapper>
</main>
{/* Footer (fixed to bottom) */}
<footer className="bg-light footer fixed-bottom border-top">
<div className="container py-2">
<div className="row">
<div className="col-lg-6 text-center text-lg-start mb-2 mb-lg-0">
<ul className="list-inline mb-1">
<li className="list-inline-item">
<Link href="#">About</Link>
</li>
<li className="list-inline-item"></li>
<li className="list-inline-item">
<Link href="#">Contact</Link>
</li>
<li className="list-inline-item"></li>
<li className="list-inline-item">
<Link href="#">Terms of Use</Link>
</li>
<li className="list-inline-item"></li>
<li className="list-inline-item">
<Link href="#">Privacy Policy</Link>
</li>
</ul>
<p className="text-muted small mb-0">© CatLink 2025</p>
</div>
<div className="col-lg-6 text-center text-lg-end">
<ul className="list-inline mb-0">
<li className="list-inline-item">
<Link href="#">
<i className="fa fa-facebook fa-lg fa-fw"></i>
</Link>
</li>
<li className="list-inline-item">
<Link href="#">
<i className="fa fa-twitter fa-lg fa-fw"></i>
</Link>
</li>
<li className="list-inline-item">
<Link href="#">
<i className="fa fa-instagram fa-lg fa-fw"></i>
</Link>
</li>
</ul>
</div>
</div>
</div>
</footer>
{/* Scripts */}
<Script
src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.0/jquery.min.js"
strategy="beforeInteractive"
/>
<Script
src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
strategy="beforeInteractive"
/>
<Script src="/assets/js/bs-init.js" strategy="lazyOnload" />
</body>
</html>
);
}

View File

@@ -0,0 +1,173 @@
// src/app/layout.tsx
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "../globals.css";
import { ReactNode } from "react";
import Script from "next/script";
import Link from "next/link";
import Image from "next/image";
import { LayoutWrapper } from '../components/LayoutWrapper'
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "CatLink",
description: "Ваши ссылки в одном месте",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ru">
<head>
<meta charSet="utf-8" />
<link
rel="apple-touch-icon"
type="image/png"
sizes="180x180"
href="/assets/img/apple-touch-icon.png?h=0f5e29c1169e75a7003e818478b67caa"
/>
<link
rel="icon"
type="image/png"
sizes="96x96"
href="/assets/img/favicon-96x96.png?h=c8792ce927e01a494c4af48bed5dc5e3"
/>
<link
rel="icon"
type="image/png"
sizes="192x192"
href="/assets/img/web-app-manifest-192x192.png?h=c8792ce927e01a494c4af48bed5dc5e3"
/>
<link
rel="icon"
type="image/png"
sizes="512x512"
href="/assets/img/web-app-manifest-512x512.png?h=c8792ce927e01a494c4af48bed5dc5e3"
/>
<link
rel="stylesheet"
href="/assets/bootstrap/css/bootstrap.min.css?h=608a9825a1f76f674715160908e57785"
/>
<link
rel="stylesheet"
href="/assets/css/Lato.css?h=8253736d3a23b522f64b7e7d96d1d8ff"
/>
<link
rel="manifest"
href="/manifest.json?h=457941ffad3c027c946331c09a4d7d2f"
/>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css"
/>
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#ffffff" />
</head>
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
{/* Header (fixed to top) */}
<nav className="navbar navbar-expand bg-light fixed-top shadow-sm">
<div className="container">
<Link href="/" className="navbar-brand d-flex align-items-center">
<Image
src="/assets/img/CAT.png"
alt="CatLink"
width={89}
height={89}
/>
<span className="ms-2">CatLink</span>
</Link>
<button
className="navbar-toggler"
type="button"
data-bs-toggle="collapse"
data-bs-target="#navcol-1"
/>
<div className="collapse navbar-collapse" id="navcol-1">
<Link href="/auth/login" className="btn btn-primary ms-auto">
<i className="fa fa-user"></i>
<span className="d-none d-sm-inline"> Вход</span>
</Link>
</div>
</div>
</nav>
{/* Main content: добавляем дополнительный отступ сверху */}
<main
style={{
paddingTop: "100px", // <-- тут увеличиваем интервал
paddingBottom: "200px"
}}
// или через класс bootstrap: className="pt-5 pb-5"
>
<LayoutWrapper>{children}</LayoutWrapper>
</main>
{/* Footer (fixed to bottom) */}
<footer className="bg-light footer fixed-bottom border-top">
<div className="container py-2">
<div className="row">
<div className="col-lg-6 text-center text-lg-start mb-2 mb-lg-0">
<ul className="list-inline mb-1">
<li className="list-inline-item">
<Link href="#">About</Link>
</li>
<li className="list-inline-item"></li>
<li className="list-inline-item">
<Link href="#">Contact</Link>
</li>
<li className="list-inline-item"></li>
<li className="list-inline-item">
<Link href="#">Terms of Use</Link>
</li>
<li className="list-inline-item"></li>
<li className="list-inline-item">
<Link href="#">Privacy Policy</Link>
</li>
</ul>
<p className="text-muted small mb-0">© CatLink 2025</p>
</div>
<div className="col-lg-6 text-center text-lg-end">
<ul className="list-inline mb-0">
<li className="list-inline-item">
<Link href="#">
<i className="fa fa-facebook fa-lg fa-fw"></i>
</Link>
</li>
<li className="list-inline-item">
<Link href="#">
<i className="fa fa-twitter fa-lg fa-fw"></i>
</Link>
</li>
<li className="list-inline-item">
<Link href="#">
<i className="fa fa-instagram fa-lg fa-fw"></i>
</Link>
</li>
</ul>
</div>
</div>
</div>
</footer>
{/* Scripts */}
<Script
src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.0/jquery.min.js"
strategy="beforeInteractive"
/>
<Script
src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
strategy="beforeInteractive"
/>
<Script src="/assets/js/bs-init.js" strategy="lazyOnload" />
</body>
</html>
);
}

View File

@@ -0,0 +1,173 @@
// src/app/layout.tsx
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "../globals.css";
import { ReactNode } from "react";
import Script from "next/script";
import Link from "next/link";
import Image from "next/image";
import { LayoutWrapper } from "../components/LayoutWrapper";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "CatLink",
description: "Ваши ссылки в одном месте",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ru">
<head>
<meta charSet="utf-8" />
<link
rel="apple-touch-icon"
type="image/png"
sizes="180x180"
href="/assets/img/apple-touch-icon.png?h=0f5e29c1169e75a7003e818478b67caa"
/>
<link
rel="icon"
type="image/png"
sizes="96x96"
href="/assets/img/favicon-96x96.png?h=c8792ce927e01a494c4af48bed5dc5e3"
/>
<link
rel="icon"
type="image/png"
sizes="192x192"
href="/assets/img/web-app-manifest-192x192.png?h=c8792ce927e01a494c4af48bed5dc5e3"
/>
<link
rel="icon"
type="image/png"
sizes="512x512"
href="/assets/img/web-app-manifest-512x512.png?h=c8792ce927e01a494c4af48bed5dc5e3"
/>
<link
rel="stylesheet"
href="/assets/bootstrap/css/bootstrap.min.css?h=608a9825a1f76f674715160908e57785"
/>
<link
rel="stylesheet"
href="/assets/css/Lato.css?h=8253736d3a23b522f64b7e7d96d1d8ff"
/>
<link
rel="manifest"
href="/manifest.json?h=457941ffad3c027c946331c09a4d7d2f"
/>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css"
/>
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#ffffff" />
</head>
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
{/* Header (fixed to top) */}
<nav className="navbar navbar-expand bg-light fixed-top shadow-sm">
<div className="container">
<Link href="/" className="navbar-brand d-flex align-items-center">
<Image
src="/assets/img/CAT.png"
alt="CatLink"
width={89}
height={89}
/>
<span className="ms-2">CatLink</span>
</Link>
<button
className="navbar-toggler"
type="button"
data-bs-toggle="collapse"
data-bs-target="#navcol-1"
/>
<div className="collapse navbar-collapse" id="navcol-1">
<Link href="/auth/login" className="btn btn-primary ms-auto">
<i className="fa fa-user"></i>
<span className="d-none d-sm-inline"> Вход</span>
</Link>
</div>
</div>
</nav>
{/* Main content: добавляем дополнительный отступ сверху */}
<main
style={{
paddingTop: "100px", // <-- тут увеличиваем интервал
paddingBottom: "200px"
}}
// или через класс bootstrap: className="pt-5 pb-5"
>
<LayoutWrapper>{children}</LayoutWrapper>
</main>
{/* Footer (fixed to bottom) */}
<footer className="bg-light footer fixed-bottom border-top">
<div className="container py-2">
<div className="row">
<div className="col-lg-6 text-center text-lg-start mb-2 mb-lg-0">
<ul className="list-inline mb-1">
<li className="list-inline-item">
<Link href="#">About</Link>
</li>
<li className="list-inline-item"></li>
<li className="list-inline-item">
<Link href="#">Contact</Link>
</li>
<li className="list-inline-item"></li>
<li className="list-inline-item">
<Link href="#">Terms of Use</Link>
</li>
<li className="list-inline-item"></li>
<li className="list-inline-item">
<Link href="#">Privacy Policy</Link>
</li>
</ul>
<p className="text-muted small mb-0">© CatLink 2025</p>
</div>
<div className="col-lg-6 text-center text-lg-end">
<ul className="list-inline mb-0">
<li className="list-inline-item">
<Link href="#">
<i className="fa fa-facebook fa-lg fa-fw"></i>
</Link>
</li>
<li className="list-inline-item">
<Link href="#">
<i className="fa fa-twitter fa-lg fa-fw"></i>
</Link>
</li>
<li className="list-inline-item">
<Link href="#">
<i className="fa fa-instagram fa-lg fa-fw"></i>
</Link>
</li>
</ul>
</div>
</div>
</div>
</footer>
{/* Scripts */}
<Script
src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.0/jquery.min.js"
strategy="beforeInteractive"
/>
<Script
src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
strategy="beforeInteractive"
/>
<Script src="/assets/js/bs-init.js" strategy="lazyOnload" />
</body>
</html>
);
}

View File

@@ -0,0 +1,34 @@
// src/app/(protected)/layout.tsx
'use client'
import { ReactNode, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { LayoutWrapper } from '../components/LayoutWrapper'
interface Props {
children: ReactNode
}
export default function ProtectedLayout({ children }: Props) {
const router = useRouter()
useEffect(() => {
const token = localStorage.getItem('token')
if (!token) {
// если нет токена, редиректим на логин
router.replace('/auth/login')
}
}, [router])
// пока идёт редирект, ничего не рендерим
// (можно поставить спиннер, если хотите)
if (typeof window !== 'undefined' && !localStorage.getItem('token')) {
return null
}
return (
<LayoutWrapper>
{children}
</LayoutWrapper>
)
}

View File

@@ -0,0 +1,173 @@
// src/app/layout.tsx
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "../globals.css";
import { ReactNode } from "react";
import Script from "next/script";
import Link from "next/link";
import Image from "next/image";
import { LayoutWrapper } from "../components/LayoutWrapper";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "CatLink",
description: "Ваши ссылки в одном месте",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ru">
<head>
<meta charSet="utf-8" />
<link
rel="apple-touch-icon"
type="image/png"
sizes="180x180"
href="/assets/img/apple-touch-icon.png?h=0f5e29c1169e75a7003e818478b67caa"
/>
<link
rel="icon"
type="image/png"
sizes="96x96"
href="/assets/img/favicon-96x96.png?h=c8792ce927e01a494c4af48bed5dc5e3"
/>
<link
rel="icon"
type="image/png"
sizes="192x192"
href="/assets/img/web-app-manifest-192x192.png?h=c8792ce927e01a494c4af48bed5dc5e3"
/>
<link
rel="icon"
type="image/png"
sizes="512x512"
href="/assets/img/web-app-manifest-512x512.png?h=c8792ce927e01a494c4af48bed5dc5e3"
/>
<link
rel="stylesheet"
href="/assets/bootstrap/css/bootstrap.min.css?h=608a9825a1f76f674715160908e57785"
/>
<link
rel="stylesheet"
href="/assets/css/Lato.css?h=8253736d3a23b522f64b7e7d96d1d8ff"
/>
<link
rel="manifest"
href="/manifest.json?h=457941ffad3c027c946331c09a4d7d2f"
/>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css"
/>
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#ffffff" />
</head>
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
{/* Header (fixed to top) */}
<nav className="navbar navbar-expand bg-light fixed-top shadow-sm">
<div className="container">
<Link href="/" className="navbar-brand d-flex align-items-center">
<Image
src="/assets/img/CAT.png"
alt="CatLink"
width={89}
height={89}
/>
<span className="ms-2">CatLink</span>
</Link>
<button
className="navbar-toggler"
type="button"
data-bs-toggle="collapse"
data-bs-target="#navcol-1"
/>
<div className="collapse navbar-collapse" id="navcol-1">
<Link href="/auth/login" className="btn btn-primary ms-auto">
<i className="fa fa-user"></i>
<span className="d-none d-sm-inline"> Вход</span>
</Link>
</div>
</div>
</nav>
{/* Main content: добавляем дополнительный отступ сверху */}
<main
style={{
paddingTop: "100px", // <-- тут увеличиваем интервал
paddingBottom: "200px"
}}
// или через класс bootstrap: className="pt-5 pb-5"
>
<LayoutWrapper>{children}</LayoutWrapper>
</main>
{/* Footer (fixed to bottom) */}
<footer className="bg-light footer fixed-bottom border-top">
<div className="container py-2">
<div className="row">
<div className="col-lg-6 text-center text-lg-start mb-2 mb-lg-0">
<ul className="list-inline mb-1">
<li className="list-inline-item">
<Link href="#">About</Link>
</li>
<li className="list-inline-item"></li>
<li className="list-inline-item">
<Link href="#">Contact</Link>
</li>
<li className="list-inline-item"></li>
<li className="list-inline-item">
<Link href="#">Terms of Use</Link>
</li>
<li className="list-inline-item"></li>
<li className="list-inline-item">
<Link href="#">Privacy Policy</Link>
</li>
</ul>
<p className="text-muted small mb-0">© CatLink 2025</p>
</div>
<div className="col-lg-6 text-center text-lg-end">
<ul className="list-inline mb-0">
<li className="list-inline-item">
<Link href="#">
<i className="fa fa-facebook fa-lg fa-fw"></i>
</Link>
</li>
<li className="list-inline-item">
<Link href="#">
<i className="fa fa-twitter fa-lg fa-fw"></i>
</Link>
</li>
<li className="list-inline-item">
<Link href="#">
<i className="fa fa-instagram fa-lg fa-fw"></i>
</Link>
</li>
</ul>
</div>
</div>
</div>
</footer>
{/* Scripts */}
<Script
src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.0/jquery.min.js"
strategy="beforeInteractive"
/>
<Script
src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
strategy="beforeInteractive"
/>
<Script src="/assets/js/bs-init.js" strategy="lazyOnload" />
</body>
</html>
);
}

View File

@@ -0,0 +1,171 @@
// src/app/layout.tsx
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "../globals.css";
import { ReactNode } from "react";
import Script from "next/script";
import Link from "next/link";
import Image from "next/image";
import { LayoutWrapper } from "../components/LayoutWrapper";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "CatLink",
description: "Ваши ссылки в одном месте",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<head>
<meta charSet="utf-8" />
<link
rel="apple-touch-icon"
type="image/png"
sizes="180x180"
href="/assets/img/apple-touch-icon.png?h=0f5e29c1169e75a7003e818478b67caa"
/>
<link
rel="icon"
type="image/png"
sizes="96x96"
href="/assets/img/favicon-96x96.png?h=c8792ce927e01a494c4af48bed5dc5e3"
/>
<link
rel="icon"
type="image/png"
sizes="192x192"
href="/assets/img/web-app-manifest-192x192.png?h=c8792ce927e01a494c4af48bed5dc5e3"
/>
<link
rel="icon"
type="image/png"
sizes="512x512"
href="/assets/img/web-app-manifest-512x512.png?h=c8792ce927e01a494c4af48bed5dc5e3"
/>
<link
rel="stylesheet"
href="/assets/bootstrap/css/bootstrap.min.css?h=608a9825a1f76f674715160908e57785"
/>
<link
rel="stylesheet"
href="/assets/css/Lato.css?h=8253736d3a23b522f64b7e7d96d1d8ff"
/>
<link
rel="manifest"
href="/manifest.json?h=457941ffad3c027c946331c09a4d7d2f"
/>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css"
/>
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#ffffff" />
</head>
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
{/* Header (fixed to top) */}
<nav className="navbar navbar-expand bg-light fixed-top shadow-sm">
<div className="container">
<Link href="/" className="navbar-brand d-flex align-items-center">
<Image
src="/assets/img/CAT.png"
alt="CatLink"
width={89}
height={89}
/>
<span className="ms-2">CatLink</span>
</Link>
<button
className="navbar-toggler"
type="button"
data-bs-toggle="collapse"
data-bs-target="#navcol-1"
/>
<div className="collapse navbar-collapse" id="navcol-1">
<Link href="/auth/login" className="btn btn-primary ms-auto">
<i className="fa fa-user"></i>
<span className="d-none d-sm-inline"> Вход</span>
</Link>
</div>
</div>
</nav>
{/* Main content: добавляем дополнительный отступ сверху */}
<main
style={{
paddingTop: "100px", // <-- тут увеличиваем интервал
paddingBottom: "200px"
}}
// или через класс bootstrap: className="pt-5 pb-5"
>
<LayoutWrapper>{children}</LayoutWrapper>
</main>
{/* Footer (fixed to bottom) */}
<footer className="bg-light footer fixed-bottom border-top">
<div className="container py-2">
<div className="row">
<div className="col-lg-6 text-center text-lg-start mb-2 mb-lg-0">
<ul className="list-inline mb-1">
<li className="list-inline-item">
<Link href="#">About</Link>
</li>
<li className="list-inline-item"></li>
<li className="list-inline-item">
<Link href="#">Contact</Link>
</li>
<li className="list-inline-item"></li>
<li className="list-inline-item">
<Link href="#">Terms of Use</Link>
</li>
<li className="list-inline-item"></li>
<li className="list-inline-item">
<Link href="#">Privacy Policy</Link>
</li>
</ul>
<p className="text-muted small mb-0">© CatLink 2025</p>
</div>
<div className="col-lg-6 text-center text-lg-end">
<ul className="list-inline mb-0">
<li className="list-inline-item">
<Link href="#">
<i className="fa fa-facebook fa-lg fa-fw"></i>
</Link>
</li>
<li className="list-inline-item">
<Link href="#">
<i className="fa fa-twitter fa-lg fa-fw"></i>
</Link>
</li>
<li className="list-inline-item">
<Link href="#">
<i className="fa fa-instagram fa-lg fa-fw"></i>
</Link>
</li>
</ul>
</div>
</div>
</div>
</footer>
{/* Scripts */}
<Script
src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.0/jquery.min.js"
strategy="beforeInteractive"
/>
<Script
src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
strategy="beforeInteractive"
/>
<Script src="/assets/js/bs-init.js" strategy="lazyOnload" />
</body>
);
}

View File

@@ -0,0 +1,173 @@
// src/app/layout.tsx
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "../globals.css";
import { ReactNode } from "react";
import Script from "next/script";
import Link from "next/link";
import Image from "next/image";
import { LayoutWrapper } from "../components/LayoutWrapper";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "CatLink",
description: "Ваши ссылки в одном месте",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ru">
<head>
<meta charSet="utf-8" />
<link
rel="apple-touch-icon"
type="image/png"
sizes="180x180"
href="/assets/img/apple-touch-icon.png?h=0f5e29c1169e75a7003e818478b67caa"
/>
<link
rel="icon"
type="image/png"
sizes="96x96"
href="/assets/img/favicon-96x96.png?h=c8792ce927e01a494c4af48bed5dc5e3"
/>
<link
rel="icon"
type="image/png"
sizes="192x192"
href="/assets/img/web-app-manifest-192x192.png?h=c8792ce927e01a494c4af48bed5dc5e3"
/>
<link
rel="icon"
type="image/png"
sizes="512x512"
href="/assets/img/web-app-manifest-512x512.png?h=c8792ce927e01a494c4af48bed5dc5e3"
/>
<link
rel="stylesheet"
href="/assets/bootstrap/css/bootstrap.min.css?h=608a9825a1f76f674715160908e57785"
/>
<link
rel="stylesheet"
href="/assets/css/Lato.css?h=8253736d3a23b522f64b7e7d96d1d8ff"
/>
<link
rel="manifest"
href="/manifest.json?h=457941ffad3c027c946331c09a4d7d2f"
/>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css"
/>
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#ffffff" />
</head>
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
{/* Header (fixed to top) */}
<nav className="navbar navbar-expand bg-light fixed-top shadow-sm">
<div className="container">
<Link href="/" className="navbar-brand d-flex align-items-center">
<Image
src="/assets/img/CAT.png"
alt="CatLink"
width={89}
height={89}
/>
<span className="ms-2">CatLink</span>
</Link>
<button
className="navbar-toggler"
type="button"
data-bs-toggle="collapse"
data-bs-target="#navcol-1"
/>
<div className="collapse navbar-collapse" id="navcol-1">
<Link href="/auth/login" className="btn btn-primary ms-auto">
<i className="fa fa-user"></i>
<span className="d-none d-sm-inline"> Вход</span>
</Link>
</div>
</div>
</nav>
{/* Main content: добавляем дополнительный отступ сверху */}
<main
style={{
paddingTop: "100px", // <-- тут увеличиваем интервал
paddingBottom: "200px"
}}
// или через класс bootstrap: className="pt-5 pb-5"
>
<LayoutWrapper>{children}</LayoutWrapper>
</main>
{/* Footer (fixed to bottom) */}
<footer className="bg-light footer fixed-bottom border-top">
<div className="container py-2">
<div className="row">
<div className="col-lg-6 text-center text-lg-start mb-2 mb-lg-0">
<ul className="list-inline mb-1">
<li className="list-inline-item">
<Link href="#">About</Link>
</li>
<li className="list-inline-item"></li>
<li className="list-inline-item">
<Link href="#">Contact</Link>
</li>
<li className="list-inline-item"></li>
<li className="list-inline-item">
<Link href="#">Terms of Use</Link>
</li>
<li className="list-inline-item"></li>
<li className="list-inline-item">
<Link href="#">Privacy Policy</Link>
</li>
</ul>
<p className="text-muted small mb-0">© CatLink 2025</p>
</div>
<div className="col-lg-6 text-center text-lg-end">
<ul className="list-inline mb-0">
<li className="list-inline-item">
<Link href="#">
<i className="fa fa-facebook fa-lg fa-fw"></i>
</Link>
</li>
<li className="list-inline-item">
<Link href="#">
<i className="fa fa-twitter fa-lg fa-fw"></i>
</Link>
</li>
<li className="list-inline-item">
<Link href="#">
<i className="fa fa-instagram fa-lg fa-fw"></i>
</Link>
</li>
</ul>
</div>
</div>
</div>
</footer>
{/* Scripts */}
<Script
src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.0/jquery.min.js"
strategy="beforeInteractive"
/>
<Script
src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
strategy="beforeInteractive"
/>
<Script src="/assets/js/bs-init.js" strategy="lazyOnload" />
</body>
</html>
);
}

View File

@@ -0,0 +1,19 @@
// src/app/(protected)/layout.tsx
'use client'
import React from 'react'
import { LayoutWrapper } from '../components/LayoutWrapper'
export default function ProtectedLayout({
children,
}: {
children: React.ReactNode
}) {
// Корневой layout уже выдал <html><head><body>…
// Здесь просто оборачиваем содержимое в свой Wrapper
return (
<LayoutWrapper>
{children}
</LayoutWrapper>
)
}

View File

@@ -0,0 +1,80 @@
// src/app/layout.tsx
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { ReactNode } from "react";
import { LayoutWrapper } from "../components/LayoutWrapper";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "CatLink",
description: "Ваши ссылки в одном месте",
};
export default function RootLayout({
children,
}: {
children: ReactNode;
}) {
return (
<html lang="ru">
<head>
<meta charSet="utf-8" />
<link
rel="apple-touch-icon"
type="image/png"
sizes="180x180"
href="/assets/img/apple-touch-icon.png?h=0f5e29c1169e75a7003e818478b67caa"
/>
<link
rel="icon"
type="image/png"
sizes="96x96"
href="/assets/img/favicon-96x96.png?h=c8792ce927e01a494c4af48bed5dc5e3"
/>
<link
rel="icon"
type="image/png"
sizes="192x192"
href="/assets/img/web-app-manifest-192x192.png?h=c8792ce927e01a494c4af48bed5dc5e3"
/>
<link
rel="icon"
type="image/png"
sizes="512x512"
href="/assets/img/web-app-manifest-512x512.png?h=c8792ce927e01a494c4af48bed5dc5e3"
/>
{/* Bootstrap & Lato & Icons */}
<link
rel="stylesheet"
href="/assets/bootstrap/css/bootstrap.min.css?h=608a9825a1f76f674715160908e57785"
/>
<link
rel="stylesheet"
href="/assets/css/Lato.css?h=8253736d3a23b522f64b7e7d96d1d8ff"
/>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css"
/>
<link rel="manifest" href="/manifest.json?h=457941ffad3c027c946331c09a4d7d2f" />
<meta name="theme-color" content="#ffffff" />
</head>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<LayoutWrapper>{children}</LayoutWrapper>
</body>
</html>
);
}

View File

@@ -0,0 +1,80 @@
// src/app/layout.tsx
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "../../globals.css";
import { ReactNode } from "react";
import { LayoutWrapper } from "../components/LayoutWrapper";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "CatLink",
description: "Ваши ссылки в одном месте",
};
export default function RootLayout({
children,
}: {
children: ReactNode;
}) {
return (
<html lang="ru">
<head>
<meta charSet="utf-8" />
<link
rel="apple-touch-icon"
type="image/png"
sizes="180x180"
href="/assets/img/apple-touch-icon.png?h=0f5e29c1169e75a7003e818478b67caa"
/>
<link
rel="icon"
type="image/png"
sizes="96x96"
href="/assets/img/favicon-96x96.png?h=c8792ce927e01a494c4af48bed5dc5e3"
/>
<link
rel="icon"
type="image/png"
sizes="192x192"
href="/assets/img/web-app-manifest-192x192.png?h=c8792ce927e01a494c4af48bed5dc5e3"
/>
<link
rel="icon"
type="image/png"
sizes="512x512"
href="/assets/img/web-app-manifest-512x512.png?h=c8792ce927e01a494c4af48bed5dc5e3"
/>
{/* Bootstrap & Lato & Icons */}
<link
rel="stylesheet"
href="/assets/bootstrap/css/bootstrap.min.css?h=608a9825a1f76f674715160908e57785"
/>
<link
rel="stylesheet"
href="/assets/css/Lato.css?h=8253736d3a23b522f64b7e7d96d1d8ff"
/>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css"
/>
<link rel="manifest" href="/manifest.json?h=457941ffad3c027c946331c09a4d7d2f" />
<meta name="theme-color" content="#ffffff" />
</head>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<LayoutWrapper>{children}</LayoutWrapper>
</body>
</html>
);
}

View File

@@ -0,0 +1,80 @@
// src/app/layout.tsx
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "../globals.css";
import { ReactNode } from "react";
import { LayoutWrapper } from "../components/LayoutWrapper";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "CatLink",
description: "Ваши ссылки в одном месте",
};
export default function RootLayout({
children,
}: {
children: ReactNode;
}) {
return (
<html lang="ru">
<head>
<meta charSet="utf-8" />
<link
rel="apple-touch-icon"
type="image/png"
sizes="180x180"
href="/assets/img/apple-touch-icon.png?h=0f5e29c1169e75a7003e818478b67caa"
/>
<link
rel="icon"
type="image/png"
sizes="96x96"
href="/assets/img/favicon-96x96.png?h=c8792ce927e01a494c4af48bed5dc5e3"
/>
<link
rel="icon"
type="image/png"
sizes="192x192"
href="/assets/img/web-app-manifest-192x192.png?h=c8792ce927e01a494c4af48bed5dc5e3"
/>
<link
rel="icon"
type="image/png"
sizes="512x512"
href="/assets/img/web-app-manifest-512x512.png?h=c8792ce927e01a494c4af48bed5dc5e3"
/>
{/* Bootstrap & Lato & Icons */}
<link
rel="stylesheet"
href="/assets/bootstrap/css/bootstrap.min.css?h=608a9825a1f76f674715160908e57785"
/>
<link
rel="stylesheet"
href="/assets/css/Lato.css?h=8253736d3a23b522f64b7e7d96d1d8ff"
/>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css"
/>
<link rel="manifest" href="/manifest.json?h=457941ffad3c027c946331c09a4d7d2f" />
<meta name="theme-color" content="#ffffff" />
</head>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<LayoutWrapper>{children}</LayoutWrapper>
</body>
</html>
);
}

View File

@@ -0,0 +1,76 @@
// src/app/page.tsx
export default function HomePage() {
return (
<>
<header
className="text-center text-white masthead"
style={{
background: "url('/assets/img/bg-masthead.jpg') no-repeat center center",
backgroundSize: 'cover',
}}
>
<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="Введите электронную почту"
/>
<button className="btn btn-primary btn-lg" type="submit">
Начать
</button>
</form>
</div>
</div>
</div>
</header>
<section className="text-center bg-light features-icons py-5">
<div className="container">
<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>
<h3>Публикация</h3>
<p className="lead mb-0">
Делитесь единой ссылкой catlinks.kr/ваше-имя в био, мессенджерах и письмах.
</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>
<h3>Почему CatLink?</h3>
<p className="lead mb-0">
Повяжите свои миры одной «хвостовой» ссылкой.
</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>
<h3>Разместите всё важное на одной ссылке</h3>
<p className="lead mb-0">
Идите дальше, как кошка: легко и грациозно.
</p>
</div>
</div>
</div>
</div>
</section>
</>
);
}

View File

@@ -0,0 +1,16 @@
// src/app/[username]/layout.tsx
import { ReactNode } from 'react'
export const runtime = 'edge' // по желанию, можно убрать
/**
* В этом layout мы просто рендерим дочернюю страницу без
* общего Header/Footer из корневого layout.tsx
*/
export default function UserLayout({
children,
}: {
children: ReactNode
}) {
return <>{children}</>
}

View File

@@ -0,0 +1,16 @@
// src/app/[username]/layout.tsx
import { ReactNode } from 'react'
export const runtime = 'edge' // по желанию, можно убрать
/**
* В этом layout мы просто рендерим дочернюю страницу без
* общего Header/Footer из корневого layout.tsx
*/
export default function UserLayout({
children,
}: {
children: ReactNode
}) {
return <>{children}</>
}

View File

@@ -0,0 +1,7 @@
// src/app/[username]/layout.tsx
import { ReactNode } from "react";
/** Ничего не рендерим, просто передаём children */
export default function UserLayout({ children }: { children: ReactNode }) {
return <>{children}</>;
}

View File

@@ -0,0 +1,7 @@
// src/app/[username]/layout.tsx
import { ReactNode } from "react";
/** Ничего не рендерим, просто передаём children */
export default function UserLayout({ children }: { children: ReactNode }) {
return <>{children}</>;
}

View File

@@ -0,0 +1,7 @@
// src/app/[username]/layout.tsx
import { ReactNode } from "react";
/** Ничего не рендерим, просто передаём children */
export default function UserLayout({ children }: { children: ReactNode }) {
return <>{children}</>;
}

View File

@@ -0,0 +1,7 @@
// src/app/[username]/layout.tsx
import { ReactNode } from "react";
/** Ничего не рендерим, просто передаём children */
export default function UserLayout({ children }: { children: ReactNode }) {
return <>{children}</>;
}

View File

@@ -0,0 +1,24 @@
// src/app/[username]/layout.tsx
'use client'
import { ReactNode } from 'react'
import Script from 'next/script'
export default function UserLayout({
children,
}: {
children: ReactNode
}) {
return (
<>
{/* Здесь рендерим только public-children без глобального Header/Footer */}
{children}
{/* Bootstrap JS bundle (Popper+Bootstrap) */}
<Script
src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
strategy="beforeInteractive"
/>
</>
)
}

View File

@@ -0,0 +1,24 @@
// src/app/[username]/layout.tsx
'use client'
import { ReactNode } from 'react'
import Script from 'next/script'
export default function UserLayout({
children,
}: {
children: ReactNode
}) {
return (
<>
{/* Здесь рендерим только public-children без глобального Header/Footer */}
{children}
{/* Bootstrap JS bundle (Popper+Bootstrap) */}
<Script
src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
strategy="beforeInteractive"
/>
</>
)
}

View File

@@ -0,0 +1,24 @@
// src/app/[username]/layout.tsx
'use client'
import { ReactNode } from 'react'
import Script from 'next/script'
import "../globals.css";
export default function UserLayout({
children,
}: {
children: ReactNode
}) {
return (
<>
{/* Здесь рендерим только public-children без глобального Header/Footer */}
{children}
{/* Bootstrap JS bundle (Popper+Bootstrap) */}
<Script
src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
strategy="beforeInteractive"
/>
</>
)
}

View File

@@ -0,0 +1,24 @@
// src/app/[username]/layout.tsx
'use client'
import { ReactNode } from 'react'
import Script from 'next/script'
import "../globals.css";
export default function UserLayout({
children,
}: {
children: ReactNode
}) {
return (
<>
{/* Здесь рендерим только public-children без глобального Header/Footer */}
{children}
{/* Bootstrap JS bundle (Popper+Bootstrap) */}
<Script
src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
strategy="beforeInteractive"
/>
</>
)
}

View File

@@ -0,0 +1,69 @@
// src/app/[username]/layout.tsx
'use client'
import { ReactNode } from 'react'
import Script from 'next/script'
import "../globals.css";
export default function UserLayout({
children,
}: {
children: ReactNode
}) {
return (
<> <head>
<meta charSet="utf-8" />
<link
rel="apple-touch-icon"
type="image/png"
sizes="180x180"
href="/assets/img/apple-touch-icon.png?h=0f5e29c1169e75a7003e818478b67caa"
/>
<link
rel="icon"
type="image/png"
sizes="96x96"
href="/assets/img/favicon-96x96.png?h=c8792ce927e01a494c4af48bed5dc5e3"
/>
<link
rel="icon"
type="image/png"
sizes="192x192"
href="/assets/img/web-app-manifest-192x192.png?h=c8792ce927e01a494c4af48bed5dc5e3"
/>
<link
rel="icon"
type="image/png"
sizes="512x512"
href="/assets/img/web-app-manifest-512x512.png?h=c8792ce927e01a494c4af48bed5dc5e3"
/>
<link
rel="stylesheet"
href="/assets/bootstrap/css/bootstrap.min.css?h=608a9825a1f76f674715160908e57785"
/>
<link
rel="stylesheet"
href="/assets/css/Lato.css?h=8253736d3a23b522f64b7e7d96d1d8ff"
/>
<link
rel="manifest"
href="/manifest.json?h=457941ffad3c027c946331c09a4d7d2f"
/>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css"
/>
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#ffffff" />
</head>
{/* Здесь рендерим только public-children без глобального Header/Footer */}
{children}
{/* Bootstrap JS bundle (Popper+Bootstrap) */}
<Script
src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
strategy="beforeInteractive"
/>
</>
)
}

View File

@@ -0,0 +1,69 @@
// src/app/[username]/layout.tsx
'use client'
import { ReactNode } from 'react'
import Script from 'next/script'
import "../globals.css";
export default function UserLayout({
children,
}: {
children: ReactNode
}) {
return (
<> <head>
<meta charSet="utf-8" />
<link
rel="apple-touch-icon"
type="image/png"
sizes="180x180"
href="/assets/img/apple-touch-icon.png?h=0f5e29c1169e75a7003e818478b67caa"
/>
<link
rel="icon"
type="image/png"
sizes="96x96"
href="/assets/img/favicon-96x96.png?h=c8792ce927e01a494c4af48bed5dc5e3"
/>
<link
rel="icon"
type="image/png"
sizes="192x192"
href="/assets/img/web-app-manifest-192x192.png?h=c8792ce927e01a494c4af48bed5dc5e3"
/>
<link
rel="icon"
type="image/png"
sizes="512x512"
href="/assets/img/web-app-manifest-512x512.png?h=c8792ce927e01a494c4af48bed5dc5e3"
/>
<link
rel="stylesheet"
href="/assets/bootstrap/css/bootstrap.min.css?h=608a9825a1f76f674715160908e57785"
/>
<link
rel="stylesheet"
href="/assets/css/Lato.css?h=8253736d3a23b522f64b7e7d96d1d8ff"
/>
<link
rel="manifest"
href="/manifest.json?h=457941ffad3c027c946331c09a4d7d2f"
/>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css"
/>
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#ffffff" />
</head>
{/* Здесь рендерим только public-children без глобального Header/Footer */}
{children}
{/* Bootstrap JS bundle (Popper+Bootstrap) */}
<Script
src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
strategy="beforeInteractive"
/>
</>
)
}

View File

@@ -0,0 +1,114 @@
// src/app/[username]/page.tsx
import { notFound } from 'next/navigation';
import Image from 'next/image';
import Link from 'next/link';
interface LinkItem {
id: number;
title: string;
url: string;
icon?: string;
}
interface Group {
id: number;
name: string;
icon?: string;
links: LinkItem[];
}
interface UserGroupsData {
username: string;
groups: Group[];
}
export default async function UserPage({
params,
}: {
params: { username: string }; // уже не Promise
}) {
const { username } = params;
const API = process.env.NEXT_PUBLIC_API_URL!;
const res = await fetch(`${API}/api/users/${username}/public/`, {
cache: 'no-store',
});
if (res.status === 404) return notFound();
if (!res.ok) throw new Error('Ошибка загрузки публичных данных');
const data: UserGroupsData = await res.json();
return (
<main>
<h2 className="text-center mb-4">{data.username}</h2>
<div className="accordion" id="groupsAccordion">
{data.groups.map((group) => (
<div className="accordion-item" key={group.id}>
<h2 className="accordion-header" id={`heading${group.id}`}>
<button
className="accordion-button collapsed"
type="button"
data-bs-toggle="collapse"
data-bs-target={`#collapse${group.id}`}
aria-expanded="false"
aria-controls={`collapse${group.id}`}
>
{group.icon && (
<Image
src={group.icon.startsWith('http')
? group.icon
: `${API}${group.icon}`}
alt={group.name}
width={24}
height={24}
className="me-2"
/>
)}
<span className="flex-grow-1">{group.name}</span>
<span className="badge bg-primary ms-2">
{group.links.length}
</span>
</button>
</h2>
<div
id={`collapse${group.id}`}
className="accordion-collapse collapse"
aria-labelledby={`heading${group.id}`}
data-bs-parent="#groupsAccordion"
>
<div className="accordion-body">
<ul className="list-unstyled mb-0">
{group.links.map((link) => (
<li
key={link.id}
className="d-flex align-items-center mb-2"
>
{link.icon && (
<Image
src={link.icon.startsWith('http')
? link.icon
: `${API}${link.icon}`}
alt={link.title}
width={20}
height={20}
className="me-2"
/>
)}
<Link
href={link.url}
target="_blank"
className="stretched-link"
>
{link.title}
</Link>
</li>
))}
</ul>
</div>
</div>
</div>
))}
</div>
</main>
);
}

View File

@@ -0,0 +1,114 @@
// src/app/[username]/page.tsx
import { notFound } from 'next/navigation';
import Image from 'next/image';
import Link from 'next/link';
interface LinkItem {
id: number;
title: string;
url: string;
icon?: string;
}
interface Group {
id: number;
name: string;
icon?: string;
links: LinkItem[];
}
interface UserGroupsData {
username: string;
groups: Group[];
}
export default async function UserPage({
params,
}: {
params: { username: string }; // уже не Promise
}) {
const { username } = params;
const API = process.env.NEXT_PUBLIC_API_URL!;
const res = await fetch(`${API}/api/users/${username}/public/`, {
cache: 'no-store',
});
if (res.status === 404) return notFound();
if (!res.ok) throw new Error('Ошибка загрузки публичных данных');
const data: UserGroupsData = await res.json();
return (
<main>
<h2 className="text-center mb-4">{data.username}</h2>
<div className="accordion" id="groupsAccordion">
{data.groups.map((group) => (
<div className="accordion-item" key={group.id}>
<h2 className="accordion-header" id={`heading${group.id}`}>
<button
className="accordion-button collapsed"
type="button"
data-bs-toggle="collapse"
data-bs-target={`#collapse${group.id}`}
aria-expanded="false"
aria-controls={`collapse${group.id}`}
>
{group.icon && (
<Image
src={group.icon.startsWith('http')
? group.icon
: `${API}${group.icon}`}
alt={group.name}
width={24}
height={24}
className="me-2"
/>
)}
<span className="flex-grow-1">{group.name}</span>
<span className="badge bg-primary ms-2">
{group.links.length}
</span>
</button>
</h2>
<div
id={`collapse${group.id}`}
className="accordion-collapse collapse"
aria-labelledby={`heading${group.id}`}
data-bs-parent="#groupsAccordion"
>
<div className="accordion-body">
<ul className="list-unstyled mb-0">
{group.links.map((link) => (
<li
key={link.id}
className="d-flex align-items-center mb-2"
>
{link.icon && (
<Image
src={link.icon.startsWith('http')
? link.icon
: `${API}${link.icon}`}
alt={link.title}
width={20}
height={20}
className="me-2"
/>
)}
<Link
href={link.url}
target="_blank"
className="stretched-link"
>
{link.title}
</Link>
</li>
))}
</ul>
</div>
</div>
</div>
))}
</div>
</main>
);
}

View File

@@ -0,0 +1,176 @@
// src/app/layout.tsx
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "../globals.css";
import { ReactNode } from "react";
import Script from "next/script";
import Link from "next/link";
import Image from "next/image";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "CatLink",
description: "Ваши ссылки в одном месте",
};
export default function RootLayout({
children,
}: {
children: ReactNode;
}) {
return (
<html lang="ru">
<head>
<meta charSet="utf-8" />
<link
rel="apple-touch-icon"
type="image/png"
sizes="180x180"
href="/assets/img/apple-touch-icon.png?h=0f5e29c1169e75a7003e818478b67caa"
/>
<link
rel="icon"
type="image/png"
sizes="96x96"
href="/assets/img/favicon-96x96.png?h=c8792ce927e01a494c4af48bed5dc5e3"
/>
<link
rel="icon"
type="image/png"
sizes="192x192"
href="/assets/img/web-app-manifest-192x192.png?h=c8792ce927e01a494c4af48bed5dc5e3"
/>
<link
rel="icon"
type="image/png"
sizes="512x512"
href="/assets/img/web-app-manifest-512x512.png?h=c8792ce927e01a494c4af48bed5dc5e3"
/>
<link
rel="stylesheet"
href="/assets/bootstrap/css/bootstrap.min.css?h=608a9825a1f76f674715160908e57785"
/>
<link
rel="stylesheet"
href="/assets/css/Lato.css?h=8253736d3a23b522f64b7e7d96d1d8ff"
/>
<link
rel="manifest"
href="/manifest.json?h=457941ffad3c027c946331c09a4d7d2f"
/>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css"
/>
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#ffffff" />
</head>
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
{/* Header (fixed to top) */}
<nav className="navbar navbar-expand bg-light fixed-top shadow-sm">
<div className="container">
<Link href="/" className="navbar-brand d-flex align-items-center">
<Image
src="/assets/img/CAT.png"
alt="CatLink"
width={89}
height={89}
/>
<span className="ms-2">CatLink</span>
</Link>
<button
className="navbar-toggler"
type="button"
data-bs-toggle="collapse"
data-bs-target="#navcol-1"
/>
<div className="collapse navbar-collapse" id="navcol-1">
<Link href="/auth/login" className="btn btn-primary ms-auto">
<i className="fa fa-user"></i>
<span className="d-none d-sm-inline"> Вход</span>
</Link>
</div>
</div>
</nav>
{/* Main content: добавляем дополнительный отступ сверху */}
<main
style={{
paddingTop: "100px", // <-- тут увеличиваем интервал
paddingBottom: "200px"
}}
// или через класс bootstrap: className="pt-5 pb-5"
>
{children}
</main>
{/* Footer (fixed to bottom) */}
<footer className="bg-light footer fixed-bottom border-top">
<div className="container py-2">
<div className="row">
<div className="col-lg-6 text-center text-lg-start mb-2 mb-lg-0">
<ul className="list-inline mb-1">
<li className="list-inline-item">
<Link href="#">About</Link>
</li>
<li className="list-inline-item"></li>
<li className="list-inline-item">
<Link href="#">Contact</Link>
</li>
<li className="list-inline-item"></li>
<li className="list-inline-item">
<Link href="#">Terms of Use</Link>
</li>
<li className="list-inline-item"></li>
<li className="list-inline-item">
<Link href="#">Privacy Policy</Link>
</li>
</ul>
<p className="text-muted small mb-0">© CatLink 2025</p>
</div>
<div className="col-lg-6 text-center text-lg-end">
<ul className="list-inline mb-0">
<li className="list-inline-item">
<Link href="#">
<i className="fa fa-facebook fa-lg fa-fw"></i>
</Link>
</li>
<li className="list-inline-item">
<Link href="#">
<i className="fa fa-twitter fa-lg fa-fw"></i>
</Link>
</li>
<li className="list-inline-item">
<Link href="#">
<i className="fa fa-instagram fa-lg fa-fw"></i>
</Link>
</li>
</ul>
</div>
</div>
</div>
</footer>
{/* Scripts */}
<Script
src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.0/jquery.min.js"
strategy="beforeInteractive"
/>
<Script
src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
strategy="beforeInteractive"
/>
<Script src="/assets/js/bs-init.js" strategy="lazyOnload" />
</body>
</html>
);
}

View File

@@ -0,0 +1,176 @@
// src/app/layout.tsx
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "../globals.css";
import { ReactNode } from "react";
import Script from "next/script";
import Link from "next/link";
import Image from "next/image";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "CatLink",
description: "Ваши ссылки в одном месте",
};
export default function RootLayout({
children,
}: {
children: ReactNode;
}) {
return (
<html lang="ru">
<head>
<meta charSet="utf-8" />
<link
rel="apple-touch-icon"
type="image/png"
sizes="180x180"
href="/assets/img/apple-touch-icon.png?h=0f5e29c1169e75a7003e818478b67caa"
/>
<link
rel="icon"
type="image/png"
sizes="96x96"
href="/assets/img/favicon-96x96.png?h=c8792ce927e01a494c4af48bed5dc5e3"
/>
<link
rel="icon"
type="image/png"
sizes="192x192"
href="/assets/img/web-app-manifest-192x192.png?h=c8792ce927e01a494c4af48bed5dc5e3"
/>
<link
rel="icon"
type="image/png"
sizes="512x512"
href="/assets/img/web-app-manifest-512x512.png?h=c8792ce927e01a494c4af48bed5dc5e3"
/>
<link
rel="stylesheet"
href="/assets/bootstrap/css/bootstrap.min.css?h=608a9825a1f76f674715160908e57785"
/>
<link
rel="stylesheet"
href="/assets/css/Lato.css?h=8253736d3a23b522f64b7e7d96d1d8ff"
/>
<link
rel="manifest"
href="/manifest.json?h=457941ffad3c027c946331c09a4d7d2f"
/>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css"
/>
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#ffffff" />
</head>
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
{/* Header (fixed to top) */}
<nav className="navbar navbar-expand bg-light fixed-top shadow-sm">
<div className="container">
<Link href="/" className="navbar-brand d-flex align-items-center">
<Image
src="/assets/img/CAT.png"
alt="CatLink"
width={89}
height={89}
/>
<span className="ms-2">CatLink</span>
</Link>
<button
className="navbar-toggler"
type="button"
data-bs-toggle="collapse"
data-bs-target="#navcol-1"
/>
<div className="collapse navbar-collapse" id="navcol-1">
<Link href="/auth/login" className="btn btn-primary ms-auto">
<i className="fa fa-user"></i>
<span className="d-none d-sm-inline"> Вход</span>
</Link>
</div>
</div>
</nav>
{/* Main content: добавляем дополнительный отступ сверху */}
<main
style={{
paddingTop: "100px", // <-- тут увеличиваем интервал
paddingBottom: "200px"
}}
// или через класс bootstrap: className="pt-5 pb-5"
>
{children}
</main>
{/* Footer (fixed to bottom) */}
<footer className="bg-light footer fixed-bottom border-top">
<div className="container py-2">
<div className="row">
<div className="col-lg-6 text-center text-lg-start mb-2 mb-lg-0">
<ul className="list-inline mb-1">
<li className="list-inline-item">
<Link href="#">About</Link>
</li>
<li className="list-inline-item"></li>
<li className="list-inline-item">
<Link href="#">Contact</Link>
</li>
<li className="list-inline-item"></li>
<li className="list-inline-item">
<Link href="#">Terms of Use</Link>
</li>
<li className="list-inline-item"></li>
<li className="list-inline-item">
<Link href="#">Privacy Policy</Link>
</li>
</ul>
<p className="text-muted small mb-0">© CatLink 2025</p>
</div>
<div className="col-lg-6 text-center text-lg-end">
<ul className="list-inline mb-0">
<li className="list-inline-item">
<Link href="#">
<i className="fa fa-facebook fa-lg fa-fw"></i>
</Link>
</li>
<li className="list-inline-item">
<Link href="#">
<i className="fa fa-twitter fa-lg fa-fw"></i>
</Link>
</li>
<li className="list-inline-item">
<Link href="#">
<i className="fa fa-instagram fa-lg fa-fw"></i>
</Link>
</li>
</ul>
</div>
</div>
</div>
</footer>
{/* Scripts */}
<Script
src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.0/jquery.min.js"
strategy="beforeInteractive"
/>
<Script
src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
strategy="beforeInteractive"
/>
<Script src="/assets/js/bs-init.js" strategy="lazyOnload" />
</body>
</html>
);
}

View File

@@ -0,0 +1,131 @@
// src/app/layout.tsx
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "../globals.css";
import { ReactNode } from "react";
import Script from "next/script";
import Link from "next/link";
import Image from "next/image";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "CatLink",
description: "Ваши ссылки в одном месте",
};
export default function RootLayout({
children,
}: {
children: ReactNode;
}) {
return (
<html lang="ru">
<head>
<meta charSet="utf-8" />
<link
rel="apple-touch-icon"
type="image/png"
sizes="180x180"
href="/assets/img/apple-touch-icon.png?h=0f5e29c1169e75a7003e818478b67caa"
/>
<link
rel="icon"
type="image/png"
sizes="96x96"
href="/assets/img/favicon-96x96.png?h=c8792ce927e01a494c4af48bed5dc5e3"
/>
<link
rel="icon"
type="image/png"
sizes="192x192"
href="/assets/img/web-app-manifest-192x192.png?h=c8792ce927e01a494c4af48bed5dc5e3"
/>
<link
rel="icon"
type="image/png"
sizes="512x512"
href="/assets/img/web-app-manifest-512x512.png?h=c8792ce927e01a494c4af48bed5dc5e3"
/>
<link
rel="stylesheet"
href="/assets/bootstrap/css/bootstrap.min.css?h=608a9825a1f76f674715160908e57785"
/>
<link
rel="stylesheet"
href="/assets/css/Lato.css?h=8253736d3a23b522f64b7e7d96d1d8ff"
/>
<link
rel="manifest"
href="/manifest.json?h=457941ffad3c027c946331c09a4d7d2f"
/>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css"
/>
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#ffffff" />
</head>
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
{/* Header (fixed to top) */}
<nav className="navbar navbar-expand bg-light fixed-top shadow-sm">
<div className="container">
<Link href="/" className="navbar-brand d-flex align-items-center">
<Image
src="/assets/img/CAT.png"
alt="CatLink"
width={89}
height={89}
/>
<span className="ms-2">CatLink</span>
</Link>
<button
className="navbar-toggler"
type="button"
data-bs-toggle="collapse"
data-bs-target="#navcol-1"
/>
<div className="collapse navbar-collapse" id="navcol-1">
<Link href="/auth/login" className="btn btn-primary ms-auto">
<i className="fa fa-user"></i>
<span className="d-none d-sm-inline"> Вход</span>
</Link>
</div>
</div>
</nav>
{/* Main content: добавляем дополнительный отступ сверху */}
<main
style={{
paddingTop: "100px", // <-- тут увеличиваем интервал
paddingBottom: "200px"
}}
// или через класс bootstrap: className="pt-5 pb-5"
>
{children}
</main>
{/* Scripts */}
<Script
src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.0/jquery.min.js"
strategy="beforeInteractive"
/>
<Script
src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
strategy="beforeInteractive"
/>
<Script src="/assets/js/bs-init.js" strategy="lazyOnload" />
</body>
</html>
);
}

View File

@@ -0,0 +1,131 @@
// src/app/layout.tsx
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "../globals.css";
import { ReactNode } from "react";
import Script from "next/script";
import Link from "next/link";
import Image from "next/image";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "CatLink",
description: "Ваши ссылки в одном месте",
};
export default function RootLayout({
children,
}: {
children: ReactNode;
}) {
return (
<html lang="ru">
<head>
<meta charSet="utf-8" />
<link
rel="apple-touch-icon"
type="image/png"
sizes="180x180"
href="/assets/img/apple-touch-icon.png?h=0f5e29c1169e75a7003e818478b67caa"
/>
<link
rel="icon"
type="image/png"
sizes="96x96"
href="/assets/img/favicon-96x96.png?h=c8792ce927e01a494c4af48bed5dc5e3"
/>
<link
rel="icon"
type="image/png"
sizes="192x192"
href="/assets/img/web-app-manifest-192x192.png?h=c8792ce927e01a494c4af48bed5dc5e3"
/>
<link
rel="icon"
type="image/png"
sizes="512x512"
href="/assets/img/web-app-manifest-512x512.png?h=c8792ce927e01a494c4af48bed5dc5e3"
/>
<link
rel="stylesheet"
href="/assets/bootstrap/css/bootstrap.min.css?h=608a9825a1f76f674715160908e57785"
/>
<link
rel="stylesheet"
href="/assets/css/Lato.css?h=8253736d3a23b522f64b7e7d96d1d8ff"
/>
<link
rel="manifest"
href="/manifest.json?h=457941ffad3c027c946331c09a4d7d2f"
/>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css"
/>
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#ffffff" />
</head>
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
{/* Header (fixed to top) */}
<nav className="navbar navbar-expand bg-light fixed-top shadow-sm">
<div className="container">
<Link href="/" className="navbar-brand d-flex align-items-center">
<Image
src="/assets/img/CAT.png"
alt="CatLink"
width={89}
height={89}
/>
<span className="ms-2">CatLink</span>
</Link>
<button
className="navbar-toggler"
type="button"
data-bs-toggle="collapse"
data-bs-target="#navcol-1"
/>
<div className="collapse navbar-collapse" id="navcol-1">
<Link href="/auth/login" className="btn btn-primary ms-auto">
<i className="fa fa-user"></i>
<span className="d-none d-sm-inline"> Вход</span>
</Link>
</div>
</div>
</nav>
{/* Main content: добавляем дополнительный отступ сверху */}
<main
style={{
paddingTop: "100px", // <-- тут увеличиваем интервал
paddingBottom: "200px"
}}
// или через класс bootstrap: className="pt-5 pb-5"
>
{children}
</main>
{/* Scripts */}
<Script
src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.0/jquery.min.js"
strategy="beforeInteractive"
/>
<Script
src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
strategy="beforeInteractive"
/>
<Script src="/assets/js/bs-init.js" strategy="lazyOnload" />
</body>
</html>
);
}

View File

@@ -0,0 +1,31 @@
// src/app/[username]/layout.tsx
import { ReactNode } from "react";
export const dynamic = "force-dynamic"; // всегда свежие данные
export default function UserLayout({
children,
}: {
children: ReactNode;
}) {
return (
<>
{/* Подключаем стили точно так же, как в root */}
<link
rel="stylesheet"
href="/assets/bootstrap/css/bootstrap.min.css?h=608a9825a1f76f674715160908e57785"
/>
<link
rel="stylesheet"
href="/assets/css/Lato.css?h=8253736d3a23b522f64b7e7d96d1d8ff"
/>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css"
/>
{/* Только контент пользователской страницы */}
<main>{children}</main>
</>
);
}

View File

@@ -0,0 +1,101 @@
// src/app/[username]/page.tsx
import { notFound } from 'next/navigation'
import Image from 'next/image'
import Link from 'next/link'
interface LinkItem {
id: number
title: string
url: string
icon?: string
}
interface Group {
id: number
name: string
icon?: string
links: LinkItem[]
}
interface UserGroupsData {
username: string
groups: Group[]
}
// серверный компонент, т.к. делаем fetch на сервере сразу
export default async function UserPage({
params,
}: {
params: { username: string }
}) {
const { username } = params
// заменить на ваш публичный API-эндпоинт
const res = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/users/${username}/public/`,
{ cache: 'no-store' }
)
if (res.status === 404) return notFound()
if (!res.ok) throw new Error('Failed to load user data')
const data: UserGroupsData = await res.json()
return (
<main className="pb-8">
<div className="container">
<h2 className="text-center mb-4">{data.username}s Links</h2>
<div className="row gy-4">
{data.groups.map((group) => (
<div key={group.id} className="col-sm-6 col-lg-4">
<div className="card h-100 shadow-sm">
{group.icon && (
<div className="text-center pt-3">
<Image
src={
group.icon.startsWith('http')
? group.icon
: `${process.env.NEXT_PUBLIC_API_URL}${group.icon}`
}
alt={`${group.name} icon`}
width={80}
height={80}
/>
</div>
)}
<div className="card-body">
<h5 className="card-title">{group.name}</h5>
<ul className="list-unstyled">
{group.links.map((link) => (
<li key={link.id} className="mb-2 d-flex align-items-center">
{link.icon && (
<Image
src={
link.icon.startsWith('http')
? link.icon
: `${process.env.NEXT_PUBLIC_API_URL}${link.icon}`
}
alt={link.title}
width={24}
height={24}
className="me-2"
/>
)}
<Link
href={link.url}
target="_blank"
className="stretched-link"
>
{link.title}
</Link>
</li>
))}
</ul>
</div>
</div>
</div>
))}
</div>
</div>
</main>
)
}

View File

@@ -0,0 +1,109 @@
// src/app/[username]/page.tsx
import { notFound } from 'next/navigation'
import Image from 'next/image'
import Link from 'next/link'
interface LinkItem {
id: number
title: string
url: string
icon?: string
}
interface Group {
id: number
name: string
icon?: string
links: LinkItem[]
}
interface UserGroupsData {
username: string
groups: Group[]
}
export default async function UserPage({
params,
}: {
params: { username: string }
}) {
const { username } = params
const API = process.env.NEXT_PUBLIC_API_URL
// залипаем на слэше, т.к. DRF эндпоинт отдаёт именно так
const res = await fetch(`${API}/api/users/${username}/public/`, {
cache: 'no-store',
})
if (res.status === 404) {
return notFound()
}
if (!res.ok) {
throw new Error('Ошибка загрузки публичных данных пользователя')
}
const data: UserGroupsData = await res.json()
return (
<main className="pb-8">
<div className="container">
<h2 className="text-center mb-4">{data.username}</h2>
<div className="row gy-4">
{data.groups.map((group) => (
<div key={group.id} className="col-sm-6 col-lg-4">
<div className="card h-100 shadow-sm">
{group.icon && (
<div className="text-center pt-3">
<Image
src={
group.icon.startsWith('http')
? group.icon
: `${API}${group.icon}`
}
alt={`${group.name} icon`}
width={80}
height={80}
/>
</div>
)}
<div className="card-body">
<h5 className="card-title">{group.name}</h5>
<ul className="list-unstyled">
{group.links.map((link) => (
<li
key={link.id}
className="mb-2 d-flex align-items-center"
>
{link.icon && (
<Image
src={
link.icon.startsWith('http')
? link.icon
: `${API}${link.icon}`
}
alt={link.title}
width={24}
height={24}
className="me-2"
/>
)}
<Link
href={link.url}
target="_blank"
className="stretched-link"
>
{link.title}
</Link>
</li>
))}
</ul>
</div>
</div>
</div>
))}
</div>
</div>
</main>
)
}

View File

@@ -0,0 +1,109 @@
// src/app/[username]/page.tsx
import { notFound } from 'next/navigation'
import Image from 'next/image'
import Link from 'next/link'
interface LinkItem {
id: number
title: string
url: string
icon?: string
}
interface Group {
id: number
name: string
icon?: string
links: LinkItem[]
}
interface UserGroupsData {
username: string
groups: Group[]
}
export default async function UserPage({
params,
}: {
params: { username: string }
}) {
const { username } = params
const API = process.env.NEXT_PUBLIC_API_URL
// залипаем на слэше, т.к. DRF эндпоинт отдаёт именно так
const res = await fetch(`${API}/api/users/${username}/public/`, {
cache: 'no-store',
})
if (res.status === 404) {
return notFound()
}
if (!res.ok) {
throw new Error('Ошибка загрузки публичных данных пользователя')
}
const data: UserGroupsData = await res.json()
return (
<main className="pb-8">
<div className="container">
<h2 className="text-center mb-4">{data.username}</h2>
<div className="row gy-4">
{data.groups.map((group) => (
<div key={group.id} className="col-sm-6 col-lg-4">
<div className="card h-100 shadow-sm">
{group.icon && (
<div className="text-center pt-3">
<Image
src={
group.icon.startsWith('http')
? group.icon
: `${API}${group.icon}`
}
alt={`${group.name} icon`}
width={80}
height={80}
/>
</div>
)}
<div className="card-body">
<h5 className="card-title">{group.name}</h5>
<ul className="list-unstyled">
{group.links.map((link) => (
<li
key={link.id}
className="mb-2 d-flex align-items-center"
>
{link.icon && (
<Image
src={
link.icon.startsWith('http')
? link.icon
: `${API}${link.icon}`
}
alt={link.title}
width={24}
height={24}
className="me-2"
/>
)}
<Link
href={link.url}
target="_blank"
className="stretched-link"
>
{link.title}
</Link>
</li>
))}
</ul>
</div>
</div>
</div>
))}
</div>
</div>
</main>
)
}

View File

@@ -0,0 +1,108 @@
// src/app/[username]/page.tsx
import { notFound } from 'next/navigation'
import Image from 'next/image'
import Link from 'next/link'
interface LinkItem {
id: number
title: string
url: string
icon?: string
}
interface Group {
id: number
name: string
icon?: string
links: LinkItem[]
}
interface UserGroupsData {
username: string
groups: Group[]
}
export default async function UserPage({
params,
}: {
// обратите внимание: params — это Promise<{ username: string }>
params: Promise<{ username: string }>
}) {
// вот здесь мы явно ждём params
const { username } = await params
const API = process.env.NEXT_PUBLIC_API_URL
const res = await fetch(
// используем слэш в конце, как мы уже делали
`${API}/api/users/${username}/public/`,
{ cache: 'no-store' }
)
if (res.status === 404) return notFound()
if (!res.ok) throw new Error('Ошибка загрузки публичных данных')
const data: UserGroupsData = await res.json()
return (
<main className="pb-8">
<div className="container">
<h2 className="text-center mb-4">{data.username}</h2>
<div className="row gy-4">
{data.groups.map((group) => (
<div key={group.id} className="col-sm-6 col-lg-4">
<div className="card h-100 shadow-sm">
{group.icon && (
<div className="text-center pt-3">
<Image
src={
group.icon.startsWith('http')
? group.icon
: `${API}${group.icon}`
}
alt={group.name}
width={80}
height={80}
/>
</div>
)}
<div className="card-body">
<h5 className="card-title">{group.name}</h5>
<ul className="list-unstyled">
{group.links.map((link) => (
<li
key={link.id}
className="mb-2 d-flex align-items-center"
>
{link.icon && (
<Image
src={
link.icon.startsWith('http')
? link.icon
: `${API}${link.icon}`
}
alt={link.title}
width={24}
height={24}
className="me-2"
/>
)}
<Link
href={link.url}
target="_blank"
className="stretched-link"
>
{link.title}
</Link>
</li>
))}
</ul>
</div>
</div>
</div>
))}
</div>
</div>
</main>
)
}

View File

@@ -0,0 +1,108 @@
// src/app/[username]/page.tsx
import { notFound } from 'next/navigation'
import Image from 'next/image'
import Link from 'next/link'
interface LinkItem {
id: number
title: string
url: string
icon?: string
}
interface Group {
id: number
name: string
icon?: string
links: LinkItem[]
}
interface UserGroupsData {
username: string
groups: Group[]
}
export default async function UserPage({
params,
}: {
// обратите внимание: params — это Promise<{ username: string }>
params: Promise<{ username: string }>
}) {
// вот здесь мы явно ждём params
const { username } = await params
const API = process.env.NEXT_PUBLIC_API_URL
const res = await fetch(
// используем слэш в конце, как мы уже делали
`${API}/api/users/${username}/public/`,
{ cache: 'no-store' }
)
if (res.status === 404) return notFound()
if (!res.ok) throw new Error('Ошибка загрузки публичных данных')
const data: UserGroupsData = await res.json()
return (
<main className="pb-8">
<div className="container">
<h2 className="text-center mb-4">{data.username}</h2>
<div className="row gy-4">
{data.groups.map((group) => (
<div key={group.id} className="col-sm-6 col-lg-4">
<div className="card h-100 shadow-sm">
{group.icon && (
<div className="text-center pt-3">
<Image
src={
group.icon.startsWith('http')
? group.icon
: `${API}${group.icon}`
}
alt={group.name}
width={80}
height={80}
/>
</div>
)}
<div className="card-body">
<h5 className="card-title">{group.name}</h5>
<ul className="list-unstyled">
{group.links.map((link) => (
<li
key={link.id}
className="mb-2 d-flex align-items-center"
>
{link.icon && (
<Image
src={
link.icon.startsWith('http')
? link.icon
: `${API}${link.icon}`
}
alt={link.title}
width={24}
height={24}
className="me-2"
/>
)}
<Link
href={link.url}
target="_blank"
className="stretched-link"
>
{link.title}
</Link>
</li>
))}
</ul>
</div>
</div>
</div>
))}
</div>
</div>
</main>
)
}

View File

@@ -0,0 +1,132 @@
// src/app/[username]/page.tsx
import { notFound } from 'next/navigation'
import Image from 'next/image'
import Link from 'next/link'
interface LinkItem {
id: number
title: string
url: string
image?: string
}
interface Group {
id: number
name: string
image?: string
links: LinkItem[]
}
interface UserGroupsData {
username: string
groups: Group[]
}
export default async function UserPage({
params,
}: {
params: Promise<{ username: string }>
}) {
const { username } = await params
const API = process.env.NEXT_PUBLIC_API_URL
const res = await fetch(`${API}/api/users/${username}/public/`, {
cache: 'no-store',
})
if (res.status === 404) return notFound()
if (!res.ok) throw new Error('Ошибка загрузки публичных данных')
const data: UserGroupsData = await res.json()
return (
<main className="pb-8">
<div className="container">
<h2 className="text-center mb-4">{data.username}</h2>
<div className="accordion" id="groupsAccordion">
{data.groups.map((group) => {
const groupId = `group-${group.id}`
return (
<div className="accordion-item mb-3 border rounded" key={group.id}>
<h2 className="accordion-header" id={`${groupId}-header`}>
<button
className="accordion-button collapsed d-flex justify-content-between align-items-center"
type="button"
data-bs-toggle="collapse"
data-bs-target={`#${groupId}-collapse`}
aria-expanded="false"
aria-controls={`${groupId}-collapse`}
>
<div className="d-flex align-items-center">
{group.image && (
<Image
src={
group.image.startsWith('http')
? group.image
: `${API}${group.image}`
}
alt={group.name}
width={32}
height={32}
className="me-2"
/>
)}
<span>{group.name}</span>
</div>
<span className="badge bg-secondary ms-auto">
{group.links.length}
</span>
</button>
</h2>
<div
id={`${groupId}-collapse`}
className="accordion-collapse collapse"
aria-labelledby={`${groupId}-header`}
data-bs-parent="#groupsAccordion"
>
<div className="accordion-body">
{group.links.length > 0 ? (
<ul className="list-unstyled mb-0">
{group.links.map((link) => (
<li
key={link.id}
className="mb-2 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"
/>
)}
<Link
href={link.url}
target="_blank"
className="stretched-link"
>
{link.title}
</Link>
</li>
))}
</ul>
) : (
<p className="text-muted mb-0">
Нет ссылок в этой группе
</p>
)}
</div>
</div>
</div>
)
})}
</div>
</div>
</main>
)
}

View File

@@ -0,0 +1,132 @@
// src/app/[username]/page.tsx
import { notFound } from 'next/navigation'
import Image from 'next/image'
import Link from 'next/link'
interface LinkItem {
id: number
title: string
url: string
image?: string
}
interface Group {
id: number
name: string
image?: string
links: LinkItem[]
}
interface UserGroupsData {
username: string
groups: Group[]
}
export default async function UserPage({
params,
}: {
params: Promise<{ username: string }>
}) {
const { username } = await params
const API = process.env.NEXT_PUBLIC_API_URL
const res = await fetch(`${API}/api/users/${username}/public/`, {
cache: 'no-store',
})
if (res.status === 404) return notFound()
if (!res.ok) throw new Error('Ошибка загрузки публичных данных')
const data: UserGroupsData = await res.json()
return (
<main className="pb-8">
<div className="container">
<h2 className="text-center mb-4">{data.username}</h2>
<div className="accordion" id="groupsAccordion">
{data.groups.map((group) => {
const groupId = `group-${group.id}`
return (
<div className="accordion-item mb-3 border rounded" key={group.id}>
<h2 className="accordion-header" id={`${groupId}-header`}>
<button
className="accordion-button collapsed d-flex justify-content-between align-items-center"
type="button"
data-bs-toggle="collapse"
data-bs-target={`#${groupId}-collapse`}
aria-expanded="false"
aria-controls={`${groupId}-collapse`}
>
<div className="d-flex align-items-center">
{group.image && (
<Image
src={
group.image.startsWith('http')
? group.image
: `${API}${group.image}`
}
alt={group.name}
width={32}
height={32}
className="me-2"
/>
)}
<span>{group.name}</span>
</div>
<span className="badge bg-secondary ms-auto">
{group.links.length}
</span>
</button>
</h2>
<div
id={`${groupId}-collapse`}
className="accordion-collapse collapse"
aria-labelledby={`${groupId}-header`}
data-bs-parent="#groupsAccordion"
>
<div className="accordion-body">
{group.links.length > 0 ? (
<ul className="list-unstyled mb-0">
{group.links.map((link) => (
<li
key={link.id}
className="mb-2 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"
/>
)}
<Link
href={link.url}
target="_blank"
className="stretched-link"
>
{link.title}
</Link>
</li>
))}
</ul>
) : (
<p className="text-muted mb-0">
Нет ссылок в этой группе
</p>
)}
</div>
</div>
</div>
)
})}
</div>
</div>
</main>
)
}

View File

@@ -0,0 +1,131 @@
// src/app/[username]/page.tsx
import { notFound } from 'next/navigation'
import Image from 'next/image'
import Link from 'next/link'
interface LinkItem {
id: number
title: string
url: string
image?: string
}
interface Group {
id: number
name: string
image?: string
links: LinkItem[]
}
interface UserGroupsData {
username: string
groups: Group[]
}
export default async function UserPage({
params,
}: {
params: Promise<{ username: string }>
}) {
const { username } = await params
const API = process.env.NEXT_PUBLIC_API_URL
const res = await fetch(`${API}/api/users/${username}/public/`, { cache: 'no-store' })
if (res.status === 404) return notFound()
if (!res.ok) throw new Error('Ошибка загрузки публичных данных')
const data: UserGroupsData = await res.json()
return (
<main className="pb-8">
<div className="container">
<h2 className="text-center mb-4">{data.username}</h2>
<div className="accordion" id="groupsAccordion">
{data.groups.map((group) => {
const groupId = `group-${group.id}`
return (
<div className="accordion-item mb-3 border rounded" key={group.id}>
<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"
/>
)}
{/* Название группы */}
<span className="me-2">{group.name}</span>
{/* Счётчик ссылок */}
<span className="badge bg-secondary">
{group.links.length}
</span>
</button>
</h2>
<div
id={`${groupId}-collapse`}
className="accordion-collapse collapse"
aria-labelledby={`${groupId}-header`}
data-bs-parent="#groupsAccordion"
>
<div className="accordion-body">
{group.links.length > 0 ? (
<ul className="list-unstyled mb-0">
{group.links.map((link) => (
<li
key={link.id}
className="mb-2 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"
/>
)}
<Link
href={link.url}
target="_blank"
className="stretched-link"
>
{link.title}
</Link>
</li>
))}
</ul>
) : (
<p className="text-muted mb-0">
Нет ссылок в этой группе
</p>
)}
</div>
</div>
</div>
)
})}
</div>
</div>
</main>
)
}

View File

@@ -0,0 +1,131 @@
// src/app/[username]/page.tsx
import { notFound } from 'next/navigation'
import Image from 'next/image'
import Link from 'next/link'
interface LinkItem {
id: number
title: string
url: string
image?: string
}
interface Group {
id: number
name: string
image?: string
links: LinkItem[]
}
interface UserGroupsData {
username: string
groups: Group[]
}
export default async function UserPage({
params,
}: {
params: Promise<{ username: string }>
}) {
const { username } = await params
const API = process.env.NEXT_PUBLIC_API_URL
const res = await fetch(`${API}/api/users/${username}/public/`, { cache: 'no-store' })
if (res.status === 404) return notFound()
if (!res.ok) throw new Error('Ошибка загрузки публичных данных')
const data: UserGroupsData = await res.json()
return (
<main className="pb-8">
<div className="container">
<h2 className="text-center mb-4">{data.username}</h2>
<div className="accordion" id="groupsAccordion">
{data.groups.map((group) => {
const groupId = `group-${group.id}`
return (
<div className="accordion-item mb-3 border rounded" key={group.id}>
<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"
/>
)}
{/* Название группы */}
<span className="me-2">{group.name}</span>
{/* Счётчик ссылок */}
<span className="badge bg-secondary">
{group.links.length}
</span>
</button>
</h2>
<div
id={`${groupId}-collapse`}
className="accordion-collapse collapse"
aria-labelledby={`${groupId}-header`}
data-bs-parent="#groupsAccordion"
>
<div className="accordion-body">
{group.links.length > 0 ? (
<ul className="list-unstyled mb-0">
{group.links.map((link) => (
<li
key={link.id}
className="mb-2 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"
/>
)}
<Link
href={link.url}
target="_blank"
className="stretched-link"
>
{link.title}
</Link>
</li>
))}
</ul>
) : (
<p className="text-muted mb-0">
Нет ссылок в этой группе
</p>
)}
</div>
</div>
</div>
)
})}
</div>
</div>
</main>
)
}

View File

@@ -0,0 +1,132 @@
// src/app/[username]/page.tsx
import { notFound } from 'next/navigation'
import Image from 'next/image'
import Link from 'next/link'
interface LinkItem {
id: number
title: string
url: string
image?: string
}
interface Group {
id: number
name: string
image?: string
links: LinkItem[]
}
interface UserGroupsData {
username: string
groups: Group[]
}
export default async function UserPage({
params,
}: {
params: Promise<{ username: string }>
}) {
const { username } = await params
const API = process.env.NEXT_PUBLIC_API_URL
const res = await fetch(`${API}/api/users/${username}/public/`, {
cache: 'no-store',
})
if (res.status === 404) return notFound()
if (!res.ok) throw new Error('Ошибка загрузки публичных данных')
const data: UserGroupsData = await res.json()
return (
<main className="pb-8">
<div className="container">
<h2 className="text-center mb-4">{data.username}</h2>
<div className="accordion" id="groupsAccordion">
{data.groups.map((group) => {
const groupId = `group-${group.id}`
return (
<div className="accordion-item mb-3 border rounded" key={group.id}>
<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"
/>
)}
<span className="me-2">{group.name}</span>
<span className="badge bg-secondary">
{group.links.length}
</span>
</button>
</h2>
<div
id={`${groupId}-collapse`}
className="accordion-collapse collapse"
aria-labelledby={`${groupId}-header`}
data-bs-parent="#groupsAccordion"
>
<div className="accordion-body">
{group.links.length > 0 ? (
<ul className="list-unstyled mb-0">
{group.links.map((link) => (
<li
key={link.id}
className="position-relative mb-2 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"
/>
)}
<Link
href={link.url}
target="_blank"
className="stretched-link"
>
{link.title}
</Link>
</li>
))}
</ul>
) : (
<p className="text-muted mb-0">
Нет ссылок в этой группе
</p>
)}
</div>
</div>
</div>
)
})}
</div>
</div>
</main>
)
}

View File

@@ -0,0 +1,132 @@
// src/app/[username]/page.tsx
import { notFound } from 'next/navigation'
import Image from 'next/image'
import Link from 'next/link'
interface LinkItem {
id: number
title: string
url: string
image?: string
}
interface Group {
id: number
name: string
image?: string
links: LinkItem[]
}
interface UserGroupsData {
username: string
groups: Group[]
}
export default async function UserPage({
params,
}: {
params: Promise<{ username: string }>
}) {
const { username } = await params
const API = process.env.NEXT_PUBLIC_API_URL
const res = await fetch(`${API}/api/users/${username}/public/`, {
cache: 'no-store',
})
if (res.status === 404) return notFound()
if (!res.ok) throw new Error('Ошибка загрузки публичных данных')
const data: UserGroupsData = await res.json()
return (
<main className="pb-8">
<div className="container">
<h2 className="text-center mb-4">{data.username}</h2>
<div className="accordion" id="groupsAccordion">
{data.groups.map((group) => {
const groupId = `group-${group.id}`
return (
<div className="accordion-item mb-3 border rounded" key={group.id}>
<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"
/>
)}
<span className="me-2">{group.name}</span>
<span className="badge bg-secondary">
{group.links.length}
</span>
</button>
</h2>
<div
id={`${groupId}-collapse`}
className="accordion-collapse collapse"
aria-labelledby={`${groupId}-header`}
data-bs-parent="#groupsAccordion"
>
<div className="accordion-body">
{group.links.length > 0 ? (
<ul className="list-unstyled mb-0">
{group.links.map((link) => (
<li
key={link.id}
className="position-relative mb-2 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"
/>
)}
<Link
href={link.url}
target="_blank"
className="stretched-link"
>
{link.title}
</Link>
</li>
))}
</ul>
) : (
<p className="text-muted mb-0">
Нет ссылок в этой группе
</p>
)}
</div>
</div>
</div>
)
})}
</div>
</div>
</main>
)
}

View File

@@ -0,0 +1,135 @@
// src/app/[username]/page.tsx
import { notFound } from 'next/navigation'
import Image from 'next/image'
import Link from 'next/link'
interface LinkItem {
id: number
title: string
url: string
image?: string
}
interface Group {
id: number
name: string
image?: string
links: LinkItem[]
}
interface UserGroupsData {
username: string
groups: Group[]
}
export default async function UserPage({
params,
}: {
params: Promise<{ username: string }>
}) {
const { username } = await params
const API = process.env.NEXT_PUBLIC_API_URL
const res = await fetch(`${API}/api/users/${username}/public/`, {
cache: 'no-store',
})
if (res.status === 404) return notFound()
if (!res.ok) throw new Error('Ошибка загрузки публичных данных')
const data: UserGroupsData = await res.json()
return (
<main className="pb-8">
<div className="container">
<h2 className="text-center mb-4">{data.username}</h2>
<div className="accordion" id="groupsAccordion">
{data.groups.map((group) => {
const groupId = `group-${group.id}`
return (
<div className="accordion-item mb-3 border rounded" key={group.id}>
<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"
/>
)}
<span className="me-2">{group.name}</span>
<span className="badge bg-secondary">
{group.links.length}
</span>
</button>
</h2>
<div
id={`${groupId}-collapse`}
className="accordion-collapse collapse"
aria-labelledby={`${groupId}-header`}
data-bs-parent="#groupsAccordion"
>
<div className="accordion-body">
{group.links.length > 0 ? (
<ul className="list-unstyled mb-0">
{group.links.map((link) => (
<li
key={link.id}
className="d-flex align-items-center shadow-sm rounded p-2"
style={{ marginBottom: '5px' }}
>
{/* Иконка ссылки */}
{link.image && (
<Image
src={
link.image.startsWith('http')
? link.image
: `${API}${link.image}`
}
alt={link.title}
width={24}
height={24}
className="me-2"
/>
)}
{/* Заголовок + ссылка */}
<Link
href={link.url}
target="_blank"
className="flex-grow-1 text-decoration-none text-dark"
>
{link.title}
</Link>
</li>
))}
</ul>
) : (
<p className="text-muted mb-0">
Нет ссылок в этой группе
</p>
)}
</div>
</div>
</div>
)
})}
</div>
</div>
</main>
)
}

View File

@@ -0,0 +1,135 @@
// src/app/[username]/page.tsx
import { notFound } from 'next/navigation'
import Image from 'next/image'
import Link from 'next/link'
interface LinkItem {
id: number
title: string
url: string
image?: string
}
interface Group {
id: number
name: string
image?: string
links: LinkItem[]
}
interface UserGroupsData {
username: string
groups: Group[]
}
export default async function UserPage({
params,
}: {
params: Promise<{ username: string }>
}) {
const { username } = await params
const API = process.env.NEXT_PUBLIC_API_URL
const res = await fetch(`${API}/api/users/${username}/public/`, {
cache: 'no-store',
})
if (res.status === 404) return notFound()
if (!res.ok) throw new Error('Ошибка загрузки публичных данных')
const data: UserGroupsData = await res.json()
return (
<main className="pb-8">
<div className="container">
<h2 className="text-center mb-4">{data.username}</h2>
<div className="accordion" id="groupsAccordion">
{data.groups.map((group) => {
const groupId = `group-${group.id}`
return (
<div className="accordion-item mb-3 border rounded" key={group.id}>
<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"
/>
)}
<span className="me-2">{group.name}</span>
<span className="badge bg-secondary">
{group.links.length}
</span>
</button>
</h2>
<div
id={`${groupId}-collapse`}
className="accordion-collapse collapse"
aria-labelledby={`${groupId}-header`}
data-bs-parent="#groupsAccordion"
>
<div className="accordion-body">
{group.links.length > 0 ? (
<ul className="list-unstyled mb-0">
{group.links.map((link) => (
<li
key={link.id}
className="d-flex align-items-center shadow-sm rounded p-2"
style={{ marginBottom: '5px' }}
>
{/* Иконка ссылки */}
{link.image && (
<Image
src={
link.image.startsWith('http')
? link.image
: `${API}${link.image}`
}
alt={link.title}
width={24}
height={24}
className="me-2"
/>
)}
{/* Заголовок + ссылка */}
<Link
href={link.url}
target="_blank"
className="flex-grow-1 text-decoration-none text-dark"
>
{link.title}
</Link>
</li>
))}
</ul>
) : (
<p className="text-muted mb-0">
Нет ссылок в этой группе
</p>
)}
</div>
</div>
</div>
)
})}
</div>
</div>
</main>
)
}

View File

@@ -0,0 +1,138 @@
// src/app/[username]/page.tsx
import { notFound } from 'next/navigation'
import Image from 'next/image'
import Link from 'next/link'
interface LinkItem {
id: number
title: string
url: string
image?: string
}
interface Group {
id: number
name: string
image?: string
links: LinkItem[]
}
interface UserGroupsData {
username: string
groups: Group[]
}
export default async function UserPage({
params,
}: {
params: Promise<{ username: string }>
}) {
const { username } = await params
const API = process.env.NEXT_PUBLIC_API_URL
// убираем завершающий слеш
const res = await fetch(`${API}/api/users/${username}/public`, {
cache: 'no-store',
})
if (res.status === 404) return notFound()
if (!res.ok) throw new Error('Ошибка загрузки публичных данных')
const data: UserGroupsData = await res.json()
return (
<main className="pb-8">
<div className="container">
<h2 className="text-center mb-4">{data.username}</h2>
<div className="accordion" id="groupsAccordion">
{data.groups.map((group) => {
const groupId = `group-${group.id}`
return (
<div
className="accordion-item mb-3 border rounded"
key={group.id}
>
<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"
/>
)}
<span className="me-2">{group.name}</span>
<span className="badge bg-secondary">
{group.links.length}
</span>
</button>
</h2>
<div
id={`${groupId}-collapse`}
className="accordion-collapse collapse"
aria-labelledby={`${groupId}-header`}
data-bs-parent="#groupsAccordion"
>
<div className="accordion-body">
{group.links.length > 0 ? (
<ul className="list-unstyled mb-0">
{group.links.map((link) => (
<li
key={link.id}
className="position-relative mb-2 p-2 bg-white rounded shadow-sm"
style={{ marginBottom: 5 }}
>
<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"
/>
)}
<Link
href={link.url}
target="_blank"
className="stretched-link"
>
{link.title}
</Link>
</div>
</li>
))}
</ul>
) : (
<p className="text-muted mb-0">
Нет ссылок в этой группе
</p>
)}
</div>
</div>
</div>
)
})}
</div>
</div>
</main>
)
}

View File

@@ -0,0 +1,138 @@
// src/app/[username]/page.tsx
import { notFound } from 'next/navigation'
import Image from 'next/image'
import Link from 'next/link'
interface LinkItem {
id: number
title: string
url: string
image?: string
}
interface Group {
id: number
name: string
image?: string
links: LinkItem[]
}
interface UserGroupsData {
username: string
groups: Group[]
}
export default async function UserPage({
params,
}: {
params: Promise<{ username: string }>
}) {
const { username } = await params
const API = process.env.NEXT_PUBLIC_API_URL
// убираем завершающий слеш
const res = await fetch(`${API}/api/users/${username}/public`, {
cache: 'no-store',
})
if (res.status === 404) return notFound()
if (!res.ok) throw new Error('Ошибка загрузки публичных данных')
const data: UserGroupsData = await res.json()
return (
<main className="pb-8">
<div className="container">
<h2 className="text-center mb-4">{data.username}</h2>
<div className="accordion" id="groupsAccordion">
{data.groups.map((group) => {
const groupId = `group-${group.id}`
return (
<div
className="accordion-item mb-3 border rounded"
key={group.id}
>
<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"
/>
)}
<span className="me-2">{group.name}</span>
<span className="badge bg-secondary">
{group.links.length}
</span>
</button>
</h2>
<div
id={`${groupId}-collapse`}
className="accordion-collapse collapse"
aria-labelledby={`${groupId}-header`}
data-bs-parent="#groupsAccordion"
>
<div className="accordion-body">
{group.links.length > 0 ? (
<ul className="list-unstyled mb-0">
{group.links.map((link) => (
<li
key={link.id}
className="position-relative mb-2 p-2 bg-white rounded shadow-sm"
style={{ marginBottom: 5 }}
>
<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"
/>
)}
<Link
href={link.url}
target="_blank"
className="stretched-link"
>
{link.title}
</Link>
</div>
</li>
))}
</ul>
) : (
<p className="text-muted mb-0">
Нет ссылок в этой группе
</p>
)}
</div>
</div>
</div>
)
})}
</div>
</div>
</main>
)
}

View File

@@ -0,0 +1,135 @@
// src/app/[username]/page.tsx
import { notFound } from 'next/navigation'
import Image from 'next/image'
import Link from 'next/link'
interface LinkItem {
id: number
title: string
url: string
image?: string
}
interface Group {
id: number
name: string
image?: string
links: LinkItem[]
}
interface UserGroupsData {
username: string
groups: Group[]
}
export default async function UserPage({
params,
}: {
params: Promise<{ username: string }>
}) {
const { username } = await params
const API = process.env.NEXT_PUBLIC_API_URL
const res = await fetch(`${API}/api/users/${username}/public/`, {
cache: 'no-store',
})
if (res.status === 404) return notFound()
if (!res.ok) throw new Error('Ошибка загрузки публичных данных')
const data: UserGroupsData = await res.json()
return (
<main className="pb-8">
<div className="container">
<h2 className="text-center mb-4">{data.username}</h2>
<div className="accordion" id="groupsAccordion">
{data.groups.map((group) => {
const groupId = `group-${group.id}`
return (
<div className="accordion-item mb-3 border rounded" key={group.id}>
<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"
/>
)}
<span className="me-2">{group.name}</span>
<span className="badge bg-secondary">
{group.links.length}
</span>
</button>
</h2>
<div
id={`${groupId}-collapse`}
className="accordion-collapse collapse"
aria-labelledby={`${groupId}-header`}
data-bs-parent="#groupsAccordion"
>
<div className="accordion-body">
{group.links.length > 0 ? (
<ul className="list-unstyled mb-0">
{group.links.map((link) => (
<li
key={link.id}
className="d-flex align-items-center shadow-sm rounded p-2"
style={{ marginBottom: '5px' }}
>
{/* Иконка ссылки */}
{link.image && (
<Image
src={
link.image.startsWith('http')
? link.image
: `${API}${link.image}`
}
alt={link.title}
width={24}
height={24}
className="me-2"
/>
)}
{/* Заголовок + ссылка */}
<Link
href={link.url}
target="_blank"
className="flex-grow-1 text-decoration-none text-dark"
>
{link.title}
</Link>
</li>
))}
</ul>
) : (
<p className="text-muted mb-0">
Нет ссылок в этой группе
</p>
)}
</div>
</div>
</div>
)
})}
</div>
</div>
</main>
)
}

View File

@@ -0,0 +1,135 @@
// src/app/[username]/page.tsx
import { notFound } from 'next/navigation'
import Image from 'next/image'
import Link from 'next/link'
interface LinkItem {
id: number
title: string
url: string
image?: string
}
interface Group {
id: number
name: string
image?: string
links: LinkItem[]
}
interface UserGroupsData {
username: string
groups: Group[]
}
export default async function UserPage({
params,
}: {
params: Promise<{ username: string }>
}) {
const { username } = await params
const API = process.env.NEXT_PUBLIC_API_URL
const res = await fetch(`${API}/api/users/${username}/public/`, {
cache: 'no-store',
})
if (res.status === 404) return notFound()
if (!res.ok) throw new Error('Ошибка загрузки публичных данных')
const data: UserGroupsData = await res.json()
return (
<main className="pb-8">
<div className="container">
<h2 className="text-center mb-4">{data.username}</h2>
<div className="accordion" id="groupsAccordion">
{data.groups.map((group) => {
const groupId = `group-${group.id}`
return (
<div className="accordion-item mb-3 border rounded" key={group.id}>
<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"
/>
)}
<span className="me-2">{group.name}</span>
<span className="badge bg-secondary">
{group.links.length}
</span>
</button>
</h2>
<div
id={`${groupId}-collapse`}
className="accordion-collapse collapse"
aria-labelledby={`${groupId}-header`}
data-bs-parent="#groupsAccordion"
>
<div className="accordion-body">
{group.links.length > 0 ? (
<ul className="list-unstyled mb-0">
{group.links.map((link) => (
<li
key={link.id}
className="d-flex align-items-center shadow-sm rounded p-2"
style={{ marginBottom: '5px' }}
>
{/* Иконка ссылки */}
{link.image && (
<Image
src={
link.image.startsWith('http')
? link.image
: `${API}${link.image}`
}
alt={link.title}
width={24}
height={24}
className="me-2"
/>
)}
{/* Заголовок + ссылка */}
<Link
href={link.url}
target="_blank"
className="flex-grow-1 text-decoration-none text-dark"
>
{link.title}
</Link>
</li>
))}
</ul>
) : (
<p className="text-muted mb-0">
Нет ссылок в этой группе
</p>
)}
</div>
</div>
</div>
)
})}
</div>
</div>
</main>
)
}

View File

@@ -0,0 +1,141 @@
// src/app/[username]/page.tsx
import { notFound } from 'next/navigation'
import Image from 'next/image'
import Link from 'next/link'
interface LinkItem {
id: number
title: string
url: string
image?: string
}
interface Group {
id: number
name: string
image?: string
links: LinkItem[]
}
interface UserGroupsData {
username: string
groups: Group[]
}
export default async function UserPage({
params,
}: {
params: Promise<{ username: string }>
}) {
const { username } = await params
const API = process.env.NEXT_PUBLIC_API_URL
const res = await fetch(`${API}/api/users/${username}/public`, {
cache: 'no-store',
})
if (res.status === 404) return notFound()
if (!res.ok) throw new Error('Ошибка загрузки публичных данных')
const data: UserGroupsData = await res.json()
return (
<main className="pb-8">
<div className="container">
<h2 className="text-center mb-4">{data.username}</h2>
<div className="accordion" id="groupsAccordion">
{data.groups.map((group) => {
const groupId = `group-${group.id}`
return (
<div
key={group.id}
className="accordion-item mb-3"
style={{ border: '1px solid #dee2e6', borderRadius: 4 }}
>
<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
/>
)}
<span className="me-2">{group.name}</span>
<span className="badge bg-secondary rounded-pill">
{group.links.length}
</span>
</button>
</h2>
<div
id={`${groupId}-collapse`}
className="accordion-collapse collapse"
aria-labelledby={`${groupId}-header`}
data-bs-parent="#groupsAccordion"
>
<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 }}
>
<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>
</li>
))}
</ul>
) : (
<p className="text-muted mb-0">
В этой группе пока нет ссылок.
</p>
)}
</div>
</div>
</div>
)
})}
</div>
</div>
</main>
)
}

View File

@@ -0,0 +1,141 @@
// src/app/[username]/page.tsx
import { notFound } from 'next/navigation'
import Image from 'next/image'
import Link from 'next/link'
interface LinkItem {
id: number
title: string
url: string
image?: string
}
interface Group {
id: number
name: string
image?: string
links: LinkItem[]
}
interface UserGroupsData {
username: string
groups: Group[]
}
export default async function UserPage({
params,
}: {
params: Promise<{ username: string }>
}) {
const { username } = await params
const API = process.env.NEXT_PUBLIC_API_URL
const res = await fetch(`${API}/api/users/${username}/public`, {
cache: 'no-store',
})
if (res.status === 404) return notFound()
if (!res.ok) throw new Error('Ошибка загрузки публичных данных')
const data: UserGroupsData = await res.json()
return (
<main className="pb-8">
<div className="container">
<h2 className="text-center mb-4">{data.username}</h2>
<div className="accordion" id="groupsAccordion">
{data.groups.map((group) => {
const groupId = `group-${group.id}`
return (
<div
key={group.id}
className="accordion-item mb-3"
style={{ border: '1px solid #dee2e6', borderRadius: 4 }}
>
<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
/>
)}
<span className="me-2">{group.name}</span>
<span className="badge bg-secondary rounded-pill">
{group.links.length}
</span>
</button>
</h2>
<div
id={`${groupId}-collapse`}
className="accordion-collapse collapse"
aria-labelledby={`${groupId}-header`}
data-bs-parent="#groupsAccordion"
>
<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 }}
>
<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>
</li>
))}
</ul>
) : (
<p className="text-muted mb-0">
В этой группе пока нет ссылок.
</p>
)}
</div>
</div>
</div>
)
})}
</div>
</div>
</main>
)
}

View File

@@ -0,0 +1,141 @@
// src/app/[username]/page.tsx
import { notFound } from 'next/navigation'
import Image from 'next/image'
import Link from 'next/link'
interface LinkItem {
id: number
title: string
url: string
image?: string
}
interface Group {
id: number
name: string
image?: string
links: LinkItem[]
}
interface UserGroupsData {
username: string
groups: Group[]
}
export default async function UserPage({
params,
}: {
params: Promise<{ username: string }>
}) {
const { username } = await params
const API = process.env.NEXT_PUBLIC_API_URL
const res = await fetch(`${API}/api/users/${username}/public`, {
cache: 'no-store',
})
if (res.status === 404) return notFound()
if (!res.ok) throw new Error('Ошибка загрузки публичных данных')
const data: UserGroupsData = await res.json()
return (
<main className="pb-8">
<div className="container">
<h2 className="text-center mb-4">{data.username}</h2>
<div className="accordion" id="groupsAccordion">
{data.groups.map((group) => {
const groupId = `group-${group.id}`
return (
<div
key={group.id}
className="accordion-item mb-3"
style={{ border: '1px solid #dee2e6', borderRadius: 4 }}
>
<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
/>
)}
<span className="me-2">{group.name}</span>
<span className="badge bg-secondary rounded-pill">
{group.links.length}
</span>
</button>
</h2>
<div
id={`${groupId}-collapse`}
className="accordion-collapse collapse"
aria-labelledby={`${groupId}-header`}
data-bs-parent="#groupsAccordion"
>
<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 }}
>
<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>
</li>
))}
</ul>
) : (
<p className="text-muted mb-0">
В этой группе пока нет ссылок.
</p>
)}
</div>
</div>
</div>
)
})}
</div>
</div>
</main>
)
}

View File

@@ -0,0 +1,85 @@
'use client'
import { ReactNode } from 'react'
import { usePathname } from 'next/navigation'
import Link from 'next/link'
import Image from 'next/image'
import Script from 'next/script'
export function LayoutWrapper({ children }: { children: ReactNode }) {
const pathname = usePathname() || ''
const isPublicUserPage = /^\/[^\/]+$/.test(pathname)
return (
<>
{/* Header */}
{!isPublicUserPage && (
<nav className="navbar navbar-expand bg-light fixed-top shadow-sm">
<div className="container">
<Link href="/" className="navbar-brand d-flex align-items-center">
<Image src="/assets/img/CAT.png" width={89} height={89} alt="CatLink" />
<span className="ms-2">CatLink</span>
</Link>
<button
className="navbar-toggler"
type="button"
data-bs-toggle="collapse"
data-bs-target="#navcol-1"
/>
<div className="collapse navbar-collapse" id="navcol-1">
<Link href="/auth/login" className="btn btn-primary ms-auto">
<i className="fa fa-user"></i>
<span className="d-none d-sm-inline"> Вход</span>
</Link>
</div>
</div>
</nav>
)}
{/* Main wrapper: добавляем отступ сверху, если есть Header */}
<div className={!isPublicUserPage ? 'pt-5' : undefined} style={!isPublicUserPage ? { paddingTop: '100px' } : undefined}>
{children}
</div>
{/* Footer */}
{!isPublicUserPage && (
<footer className="bg-light footer fixed-bottom border-top">
<div className="container py-2">
<div className="row">
<div className="col-lg-6 text-center text-lg-start mb-2 mb-lg-0">
<ul className="list-inline mb-1">
<li className="list-inline-item"><Link href="#">About</Link></li>
<li className="list-inline-item"></li>
<li className="list-inline-item"><Link href="#">Contact</Link></li>
<li className="list-inline-item"></li>
<li className="list-inline-item"><Link href="#">Terms of Use</Link></li>
<li className="list-inline-item"></li>
<li className="list-inline-item"><Link href="#">Privacy Policy</Link></li>
</ul>
<p className="text-muted small mb-0">© CatLink 2025</p>
</div>
<div className="col-lg-6 text-center text-lg-end">
<ul className="list-inline mb-0">
<li className="list-inline-item"><Link href="#"><i className="fa fa-facebook fa-lg fa-fw"></i></Link></li>
<li className="list-inline-item"><Link href="#"><i className="fa fa-twitter fa-lg fa-fw"></i></Link></li>
<li className="list-inline-item"><Link href="#"><i className="fa fa-instagram fa-lg fa-fw"></i></Link></li>
</ul>
</div>
</div>
</div>
</footer>
)}
{/* Bootstrap-скрипты */}
<Script
src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.0/jquery.min.js"
strategy="beforeInteractive"
/>
<Script
src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
strategy="beforeInteractive"
/>
<Script src="/assets/js/bs-init.js" strategy="lazyOnload" />
</>
)
}

View File

@@ -0,0 +1,157 @@
// src/components/LayoutWrapper.tsx
'use client'
import React, { ReactNode, useEffect, useState } from 'react'
import { usePathname, useRouter } from 'next/navigation'
import Link from 'next/link'
import Image from 'next/image'
import Script from 'next/script'
interface User {
username: string
avatar: string
}
export function LayoutWrapper({ children }: { children: ReactNode }) {
const pathname = usePathname() || ''
const isPublicUserPage = /^\/[^\/]+$/.test(pathname)
const isDashboard = pathname === '/dashboard'
const [user, setUser] = useState<User | null>(null)
const router = useRouter()
// При монтировании пробуем загрузить профиль
useEffect(() => {
const token = localStorage.getItem('token')
if (token) {
fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/auth/user/`, {
headers: { Authorization: `Bearer ${token}` },
})
.then(res => {
if (!res.ok) throw new Error()
return res.json()
})
.then(data => {
// fullname или username
const name = data.full_name?.trim() || data.username
setUser({ username: name, avatar: data.avatar })
})
.catch(() => {
// сбросить некорректный токен
localStorage.removeItem('token')
setUser(null)
})
}
}, [])
const handleLogout = () => {
localStorage.removeItem('token')
router.push('/')
}
return (
<>
{/* Шапка не выводим на публичных страницах /[username] */}
{!isPublicUserPage && (
<nav className="navbar navbar-expand bg-light fixed-top shadow-sm">
<div className="container">
<Link href="/" className="navbar-brand d-flex align-items-center">
<Image
src="/assets/img/CAT.png"
alt="CatLink"
width={89}
height={89}
/>
<span className="ms-2">CatLink</span>
</Link>
<button
className="navbar-toggler"
type="button"
data-bs-toggle="collapse"
data-bs-target="#navcol-1"
/>
<div className="collapse navbar-collapse" id="navcol-1">
{!user && (
<Link href="/auth/login" className="btn btn-primary ms-auto">
<i className="fa fa-user"></i>
<span className="d-none d-sm-inline"> Вход</span>
</Link>
)}
{user && (
<div className="ms-auto d-flex align-items-center gap-3">
<Image
src={
user.avatar.startsWith('http')
? user.avatar
: `${process.env.NEXT_PUBLIC_API_URL}${user.avatar}`
}
alt="Avatar"
width={32}
height={32}
className="rounded-circle"
/>
<span>{user.username}</span>
{!isDashboard && (
<Link href="/dashboard" className="btn btn-outline-secondary btn-sm">
Дашборд
</Link>
)}
<button
onClick={handleLogout}
className="btn btn-outline-danger btn-sm"
>
Выход
</button>
</div>
)}
</div>
</div>
</nav>
)}
{/* отступ, чтобы контент не прятался под фиксированным хедером */}
{!isPublicUserPage && <div style={{ height: 70 }} />}
{children}
{/* Подвал не выводим на публичных страницах */}
{!isPublicUserPage && (
<footer className="bg-light footer fixed-bottom border-top">
<div className="container py-2">
<div className="row">
<div className="col-lg-6 text-center text-lg-start mb-2 mb-lg-0">
<ul className="list-inline mb-1">
<li className="list-inline-item"><Link href="#">About</Link></li>
<li className="list-inline-item"></li>
<li className="list-inline-item"><Link href="#">Contact</Link></li>
<li className="list-inline-item"></li>
<li className="list-inline-item"><Link href="#">Terms of Use</Link></li>
<li className="list-inline-item"></li>
<li className="list-inline-item"><Link href="#">Privacy Policy</Link></li>
</ul>
<p className="text-muted small mb-0">© CatLink 2025</p>
</div>
<div className="col-lg-6 text-center text-lg-end">
<ul className="list-inline mb-0">
<li className="list-inline-item"><Link href="#"><i className="fa fa-facebook fa-lg fa-fw"></i></Link></li>
<li className="list-inline-item"><Link href="#"><i className="fa fa-twitter fa-lg fa-fw"></i></Link></li>
<li className="list-inline-item"><Link href="#"><i className="fa fa-instagram fa-lg fa-fw"></i></Link></li>
</ul>
</div>
</div>
</div>
</footer>
)}
{/* Bootstrap JS */}
<Script
src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.0/jquery.min.js"
strategy="beforeInteractive"
/>
<Script
src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
strategy="beforeInteractive"
/>
<Script src="/assets/js/bs-init.js" strategy="lazyOnload" />
</>
)
}

View File

@@ -0,0 +1,66 @@
// src/app/components/ProfileCard.tsx
'use client'
import React from 'react'
import Image from 'next/image'
export interface ProfileCardProps {
avatar: string
full_name: string
email: string
bio?: string
last_login: string
date_joined: string
totalGroups: number // ← добавили
totalLinks: number // ← добавили
}
export const ProfileCard: React.FC<ProfileCardProps> = ({
avatar,
full_name,
email,
bio,
last_login,
date_joined,
totalGroups,
totalLinks,
}) => {
return (
<div className="bg-white shadow rounded mx-auto mt-8" style={{ maxWidth: 600 }}>
<div
className="rounded-top"
style={{
backgroundImage: `url('/assets/img/iceland.jpg')`,
height: 150,
backgroundSize: 'cover',
backgroundPosition: 'center',
}}
/>
<div className="text-center position-relative" style={{ marginTop: -75 }}>
<Image
className="rounded-circle border border-white"
src={avatar}
alt="Avatar"
width={150}
height={150}
/>
</div>
<div className="px-4 pb-4 text-center">
<h3 className="mt-3">{full_name}</h3>
<p className="text-muted mb-2">{email}</p>
<p className="mb-3">{bio ?? 'Описание профиля отсутствует.'}</p>
<div className="d-flex justify-content-around">
<div>
<p className="mb-0 text-uppercase small">Всего групп</p>
<p className="fw-bold">{totalGroups}</p>
</div>
<div>
<p className="mb-0 text-uppercase small">Всего ссылок</p>
<p className="fw-bold">{totalLinks}</p>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,29 @@
'use client'
import { ReactNode, useEffect } from 'react'
export function Modal({ children, onClose }: { children: ReactNode; onClose: () => void }) {
// Закрыть по Escape
useEffect(() => {
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
document.addEventListener('keyup', onKey)
return () => document.removeEventListener('keyup', onKey)
}, [onClose])
return (
<>
<div
className="modal fade show"
style={{ display: 'block', backgroundColor: 'rgba(0,0,0,0.5)' }}
onClick={onClose}
>
<div className="modal-dialog" onClick={(e) => e.stopPropagation()}>
<div className="modal-content p-3">
{children}
</div>
</div>
</div>
<div className="modal-backdrop fade show" />
</>
)
}

View File

@@ -0,0 +1,176 @@
// src/app/layout.tsx
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { ReactNode } from "react";
import Script from "next/script";
import Link from "next/link";
import Image from "next/image";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "CatLink",
description: "Ваши ссылки в одном месте",
};
export default function RootLayout({
children,
}: {
children: ReactNode;
}) {
return (
<html lang="ru">
<head>
<meta charSet="utf-8" />
<link
rel="apple-touch-icon"
type="image/png"
sizes="180x180"
href="/assets/img/apple-touch-icon.png?h=0f5e29c1169e75a7003e818478b67caa"
/>
<link
rel="icon"
type="image/png"
sizes="96x96"
href="/assets/img/favicon-96x96.png?h=c8792ce927e01a494c4af48bed5dc5e3"
/>
<link
rel="icon"
type="image/png"
sizes="192x192"
href="/assets/img/web-app-manifest-192x192.png?h=c8792ce927e01a494c4af48bed5dc5e3"
/>
<link
rel="icon"
type="image/png"
sizes="512x512"
href="/assets/img/web-app-manifest-512x512.png?h=c8792ce927e01a494c4af48bed5dc5e3"
/>
<link
rel="stylesheet"
href="/assets/bootstrap/css/bootstrap.min.css?h=608a9825a1f76f674715160908e57785"
/>
<link
rel="stylesheet"
href="/assets/css/Lato.css?h=8253736d3a23b522f64b7e7d96d1d8ff"
/>
<link
rel="manifest"
href="/manifest.json?h=457941ffad3c027c946331c09a4d7d2f"
/>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css"
/>
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#ffffff" />
</head>
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
{/* Header (fixed to top) */}
<nav className="navbar navbar-expand bg-light fixed-top shadow-sm">
<div className="container">
<Link href="/" className="navbar-brand d-flex align-items-center">
<Image
src="/assets/img/CAT.png"
alt="CatLink"
width={89}
height={89}
/>
<span className="ms-2">CatLink</span>
</Link>
<button
className="navbar-toggler"
type="button"
data-bs-toggle="collapse"
data-bs-target="#navcol-1"
/>
<div className="collapse navbar-collapse" id="navcol-1">
<Link href="/auth/login" className="btn btn-primary ms-auto">
<i className="fa fa-user"></i>
<span className="d-none d-sm-inline"> Вход</span>
</Link>
</div>
</div>
</nav>
{/* Main content: добавляем дополнительный отступ сверху */}
<main
style={{
paddingTop: "100px", // <-- тут увеличиваем интервал
paddingBottom: "200px"
}}
// или через класс bootstrap: className="pt-5 pb-5"
>
{children}
</main>
{/* Footer (fixed to bottom) */}
<footer className="bg-light footer fixed-bottom border-top">
<div className="container py-2">
<div className="row">
<div className="col-lg-6 text-center text-lg-start mb-2 mb-lg-0">
<ul className="list-inline mb-1">
<li className="list-inline-item">
<Link href="#">About</Link>
</li>
<li className="list-inline-item"></li>
<li className="list-inline-item">
<Link href="#">Contact</Link>
</li>
<li className="list-inline-item"></li>
<li className="list-inline-item">
<Link href="#">Terms of Use</Link>
</li>
<li className="list-inline-item"></li>
<li className="list-inline-item">
<Link href="#">Privacy Policy</Link>
</li>
</ul>
<p className="text-muted small mb-0">© CatLink 2025</p>
</div>
<div className="col-lg-6 text-center text-lg-end">
<ul className="list-inline mb-0">
<li className="list-inline-item">
<Link href="#">
<i className="fa fa-facebook fa-lg fa-fw"></i>
</Link>
</li>
<li className="list-inline-item">
<Link href="#">
<i className="fa fa-twitter fa-lg fa-fw"></i>
</Link>
</li>
<li className="list-inline-item">
<Link href="#">
<i className="fa fa-instagram fa-lg fa-fw"></i>
</Link>
</li>
</ul>
</div>
</div>
</div>
</footer>
{/* Scripts */}
<Script
src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.0/jquery.min.js"
strategy="beforeInteractive"
/>
<Script
src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
strategy="beforeInteractive"
/>
<Script src="/assets/js/bs-init.js" strategy="lazyOnload" />
</body>
</html>
);
}

View File

@@ -0,0 +1,19 @@
// src/app/layout.tsx
import "./globals.css";
import type { Metadata } from "next";
import { ReactNode } from "react";
export const metadata: Metadata = { title: "CatLink", description: "..." };
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="ru">
<head>
{/* ваши meta, favicon, manifest */}
</head>
<body>
{children}
</body>
</html>
);
}

View File

@@ -0,0 +1,62 @@
// src/app/layout.tsx
import "./globals.css";
import type { Metadata } from "next";
import { ReactNode } from "react";
export const metadata: Metadata = { title: "CatLink", description: "..." };
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="ru">
<head>
<meta charSet="utf-8" />
<link
rel="apple-touch-icon"
type="image/png"
sizes="180x180"
href="/assets/img/apple-touch-icon.png?h=0f5e29c1169e75a7003e818478b67caa"
/>
<link
rel="icon"
type="image/png"
sizes="96x96"
href="/assets/img/favicon-96x96.png?h=c8792ce927e01a494c4af48bed5dc5e3"
/>
<link
rel="icon"
type="image/png"
sizes="192x192"
href="/assets/img/web-app-manifest-192x192.png?h=c8792ce927e01a494c4af48bed5dc5e3"
/>
<link
rel="icon"
type="image/png"
sizes="512x512"
href="/assets/img/web-app-manifest-512x512.png?h=c8792ce927e01a494c4af48bed5dc5e3"
/>
<link
rel="stylesheet"
href="/assets/bootstrap/css/bootstrap.min.css?h=608a9825a1f76f674715160908e57785"
/>
<link
rel="stylesheet"
href="/assets/css/Lato.css?h=8253736d3a23b522f64b7e7d96d1d8ff"
/>
<link
rel="manifest"
href="/manifest.json?h=457941ffad3c027c946331c09a4d7d2f"
/>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css"
/>
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#ffffff" />
</head>
<body>
{children}
</body>
</html>
);
}

View File

@@ -0,0 +1,164 @@
// src/app/layout.tsx
import type { Metadata } from 'next';
import { Geist, Geist_Mono } from 'next/font/google';
import './globals.css';
import Script from 'next/script';
import Link from 'next/link';
import Image from 'next/image';
import { ReactNode } from 'react';
const geistSans = Geist({
variable: '--font-geist-sans',
subsets: ['latin'],
});
const geistMono = Geist_Mono({
variable: '--font-geist-mono',
subsets: ['latin'],
});
export const metadata: Metadata = {
title: 'CatLink',
description: 'Ваши ссылки в одном месте',
};
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="ru">
<head>
<meta charSet="utf-8" />
<link
rel="apple-touch-icon"
type="image/png"
sizes="180x180"
href="/assets/img/apple-touch-icon.png?h=0f5e29c1169e75a7003e818478b67caa"
/>
<link
rel="icon"
type="image/png"
sizes="96x96"
href="/assets/img/favicon-96x96.png?h=c8792ce927e01a494c4af48bed5dc5e3"
/>
<link
rel="icon"
type="image/png"
sizes="192x192"
href="/assets/img/web-app-manifest-192x192.png?h=c8792ce927e01a494c4af48bed5dc5e3"
/>
<link
rel="icon"
type="image/png"
sizes="512x512"
href="/assets/img/web-app-manifest-512x512.png?h=c8792ce927e01a494c4af48bed5dc5e3"
/>
<link
rel="stylesheet"
href="/assets/bootstrap/css/bootstrap.min.css?h=608a9825a1f76f674715160908e57785"
/>
<link
rel="stylesheet"
href="/assets/css/Lato.css?h=8253736d3a23b522f64b7e7d96d1d8ff"
/>
<link rel="manifest" href="/manifest.json?h=457941ffad3c027c946331c09a4d7d2f" />
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css"
/>
<meta name="theme-color" content="#ffffff" />
</head>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{/* Header */}
<nav className="navbar navbar-expand bg-light fixed-top shadow-sm">
<div className="container">
<Link href="/" className="navbar-brand d-flex align-items-center">
<Image
src="/assets/img/CAT.png"
alt="CatLink"
width={89}
height={89}
/>
<span className="ms-2">CatLink</span>
</Link>
<button
className="navbar-toggler"
type="button"
data-bs-toggle="collapse"
data-bs-target="#navcol-1"
/>
<div className="collapse navbar-collapse" id="navcol-1">
<Link href="/auth/login" className="btn btn-primary ms-auto">
<i className="fa fa-user"></i>
<span className="d-none d-sm-inline"> Вход</span>
</Link>
</div>
</div>
</nav>
{/* Основной контент с отступами, чтобы header/footer не накладывались */}
<main className="pt-5 pb-5">
{children}
</main>
{/* Footer */}
<footer className="bg-light footer fixed-bottom border-top">
<div className="container py-2">
<div className="row">
<div className="col-lg-6 text-center text-lg-start mb-2 mb-lg-0">
<ul className="list-inline mb-1">
<li className="list-inline-item">
<Link href="#">About</Link>
</li>
<li className="list-inline-item"></li>
<li className="list-inline-item">
<Link href="#">Contact</Link>
</li>
<li className="list-inline-item"></li>
<li className="list-inline-item">
<Link href="#">Terms of Use</Link>
</li>
<li className="list-inline-item"></li>
<li className="list-inline-item">
<Link href="#">Privacy Policy</Link>
</li>
</ul>
<p className="text-muted small mb-0">© CatLink 2025</p>
</div>
<div className="col-lg-6 text-center text-lg-end">
<ul className="list-inline mb-0">
<li className="list-inline-item">
<Link href="#">
<i className="fa fa-facebook fa-lg fa-fw"></i>
</Link>
</li>
<li className="list-inline-item">
<Link href="#">
<i className="fa fa-twitter fa-lg fa-fw"></i>
</Link>
</li>
<li className="list-inline-item">
<Link href="#">
<i className="fa fa-instagram fa-lg fa-fw"></i>
</Link>
</li>
</ul>
</div>
</div>
</div>
</footer>
{/* Подключаем скрипты */}
<Script
src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.0/jquery.min.js"
strategy="beforeInteractive"
/>
<Script
src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
strategy="beforeInteractive"
/>
<Script src="/assets/js/bs-init.js" strategy="lazyOnload" />
</body>
</html>
);
}

View File

@@ -0,0 +1,173 @@
// src/app/layout.tsx
'use client'; // <-- превращаем в клиентский, чтобы юзать usePathname
import type { Metadata } from 'next';
import { Geist, Geist_Mono } from 'next/font/google';
import './globals.css';
import { ReactNode } from 'react';
import Script from 'next/script';
import Link from 'next/link';
import Image from 'next/image';
import { usePathname } from 'next/navigation';
const geistSans = Geist({ variable: '--font-geist-sans', subsets: ['latin'] });
const geistMono = Geist_Mono({ variable: '--font-geist-mono', subsets: ['latin'] });
export const metadata: Metadata = {
title: 'CatLink',
description: 'Ваши ссылки в одном месте',
};
export default function RootLayout({ children }: { children: ReactNode }) {
const pathname = usePathname() || '';
// Показывать header/footer, если URL НЕ вида "/что-то" (один сегмент без слэша в конце)
const isPublicUserPage = /^\/[^\/]+$/.test(pathname);
return (
<html lang="ru">
<head>
<meta charSet="utf-8" />
<link
rel="apple-touch-icon"
type="image/png"
sizes="180x180"
href="/assets/img/apple-touch-icon.png?h=0f5e29c1169e75a7003e818478b67caa"
/>
<link
rel="icon"
type="image/png"
sizes="96x96"
href="/assets/img/favicon-96x96.png?h=c8792ce927e01a494c4af48bed5dc5e3"
/>
<link
rel="icon"
type="image/png"
sizes="192x192"
href="/assets/img/web-app-manifest-192x192.png?h=c8792ce927e01a494c4af48bed5dc5e3"
/>
<link
rel="icon"
type="image/png"
sizes="512x512"
href="/assets/img/web-app-manifest-512x512.png?h=c8792ce927e01a494c4af48bed5dc5e3"
/>
{/* Bootstrap & шрифты */}
<link
rel="stylesheet"
href="/assets/bootstrap/css/bootstrap.min.css?h=608a9825a1f76f674715160908e57785"
/>
<link
rel="stylesheet"
href="/assets/css/Lato.css?h=8253736d3a23b522f64b7e7d96d1d8ff"
/>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css"
/>
{/* PWA manifest */}
<link
rel="manifest"
href="/manifest.json?h=457941ffad3c027c946331c09a4d7d2f"
/>
<meta name="theme-color" content="#ffffff" />
</head>
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
{/* Header */}
{!isPublicUserPage && (
<nav className="navbar navbar-expand bg-light fixed-top shadow-sm">
<div className="container">
<Link href="/" className="navbar-brand d-flex align-items-center">
<Image
src="/assets/img/CAT.png"
alt="CatLink"
width={89}
height={89}
/>
<span className="ms-2">CatLink</span>
</Link>
<button
className="navbar-toggler"
type="button"
data-bs-toggle="collapse"
data-bs-target="#navcol-1"
/>
<div className="collapse navbar-collapse" id="navcol-1">
<Link href="/auth/login" className="btn btn-primary ms-auto">
<i className="fa fa-user"></i>
<span className="d-none d-sm-inline"> Вход</span>
</Link>
</div>
</div>
</nav>
)}
{/* Main content */}
<main
className={!isPublicUserPage ? 'pt-5' : ''}
style={{ paddingTop: !isPublicUserPage ? '100px' : undefined }}
>
{children}
</main>
{/* Footer */}
{!isPublicUserPage && (
<footer className="bg-light footer fixed-bottom border-top">
<div className="container py-2">
<div className="row">
<div className="col-lg-6 text-center text-lg-start mb-2 mb-lg-0">
<ul className="list-inline mb-1">
<li className="list-inline-item">
<Link href="#">About</Link>
</li>
<li className="list-inline-item"></li>
<li className="list-inline-item">
<Link href="#">Contact</Link>
</li>
<li className="list-inline-item"></li>
<li className="list-inline-item">
<Link href="#">Terms of Use</Link>
</li>
<li className="list-inline-item"></li>
<li className="list-inline-item">
<Link href="#">Privacy Policy</Link>
</li>
</ul>
<p className="text-muted small mb-0">© CatLink 2025</p>
</div>
<div className="col-lg-6 text-center text-lg-end">
<ul className="list-inline mb-0">
<li className="list-inline-item">
<Link href="#">
<i className="fa fa-facebook fa-lg fa-fw"></i>
</Link>
</li>
<li className="list-inline-item">
<Link href="#">
<i className="fa fa-twitter fa-lg fa-fw"></i>
</Link>
</li>
<li className="list-inline-item">
<Link href="#">
<i className="fa fa-instagram fa-lg fa-fw"></i>
</Link>
</li>
</ul>
</div>
</div>
</div>
</footer>
)}
{/* Скрипты Bootstrap */}
<Script
src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.0/jquery.min.js"
strategy="beforeInteractive"
/>
<Script
src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
strategy="beforeInteractive"
/>
<Script src="/assets/js/bs-init.js" strategy="lazyOnload" />
</body>
</html>
);
}

View File

@@ -0,0 +1,78 @@
// src/app/layout.tsx
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { ReactNode } from "react";
import Link from "next/link";
import Image from "next/image";
import { LayoutWrapper } from "../components/LayoutWrapper";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "CatLink",
description: "Ваши ссылки в одном месте",
};
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="ru">
<head>
<meta charSet="utf-8" />
<link
rel="apple-touch-icon"
type="image/png"
sizes="180x180"
href="/assets/img/apple-touch-icon.png?h=0f5e29c1169e75a7003e818478b67caa"
/>
<link
rel="icon"
type="image/png"
sizes="96x96"
href="/assets/img/favicon-96x96.png?h=c8792ce927e01a494c4af48bed5dc5e3"
/>
<link
rel="icon"
type="image/png"
sizes="192x192"
href="/assets/img/web-app-manifest-192x192.png?h=c8792ce927e01a494c4af48bed5dc5e3"
/>
<link
rel="icon"
type="image/png"
sizes="512x512"
href="/assets/img/web-app-manifest-512x512.png?h=c8792ce927e01a494c4af48bed5dc5e3"
/>
<link
rel="stylesheet"
href="/assets/bootstrap/css/bootstrap.min.css?h=608a9825a1f76f674715160908e57785"
/>
<link
rel="stylesheet"
href="/assets/css/Lato.css?h=8253736d3a23b522f64b7e7d96d1d8ff"
/>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css"
/>
<link
rel="manifest"
href="/manifest.json?h=457941ffad3c027c946331c09a4d7d2f"
/>
<meta name="theme-color" content="#ffffff" />
</head>
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
{/* Всё, что зависит от pathname, сейчас внутри клиентского LayoutWrapper */}
<LayoutWrapper>{children}</LayoutWrapper>
</body>
</html>
);
}

View File

@@ -0,0 +1,78 @@
// src/app/layout.tsx
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { ReactNode } from "react";
import Link from "next/link";
import Image from "next/image";
import { LayoutWrapper } from "components/LayoutWrapper";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "CatLink",
description: "Ваши ссылки в одном месте",
};
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="ru">
<head>
<meta charSet="utf-8" />
<link
rel="apple-touch-icon"
type="image/png"
sizes="180x180"
href="/assets/img/apple-touch-icon.png?h=0f5e29c1169e75a7003e818478b67caa"
/>
<link
rel="icon"
type="image/png"
sizes="96x96"
href="/assets/img/favicon-96x96.png?h=c8792ce927e01a494c4af48bed5dc5e3"
/>
<link
rel="icon"
type="image/png"
sizes="192x192"
href="/assets/img/web-app-manifest-192x192.png?h=c8792ce927e01a494c4af48bed5dc5e3"
/>
<link
rel="icon"
type="image/png"
sizes="512x512"
href="/assets/img/web-app-manifest-512x512.png?h=c8792ce927e01a494c4af48bed5dc5e3"
/>
<link
rel="stylesheet"
href="/assets/bootstrap/css/bootstrap.min.css?h=608a9825a1f76f674715160908e57785"
/>
<link
rel="stylesheet"
href="/assets/css/Lato.css?h=8253736d3a23b522f64b7e7d96d1d8ff"
/>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css"
/>
<link
rel="manifest"
href="/manifest.json?h=457941ffad3c027c946331c09a4d7d2f"
/>
<meta name="theme-color" content="#ffffff" />
</head>
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
{/* Всё, что зависит от pathname, сейчас внутри клиентского LayoutWrapper */}
<LayoutWrapper>{children}</LayoutWrapper>
</body>
</html>
);
}

View File

@@ -0,0 +1,78 @@
// src/app/layout.tsx
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { ReactNode } from "react";
import Link from "next/link";
import Image from "next/image";
import { LayoutWrapper } from "./components/LayoutWrapper";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "CatLink",
description: "Ваши ссылки в одном месте",
};
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="ru">
<head>
<meta charSet="utf-8" />
<link
rel="apple-touch-icon"
type="image/png"
sizes="180x180"
href="/assets/img/apple-touch-icon.png?h=0f5e29c1169e75a7003e818478b67caa"
/>
<link
rel="icon"
type="image/png"
sizes="96x96"
href="/assets/img/favicon-96x96.png?h=c8792ce927e01a494c4af48bed5dc5e3"
/>
<link
rel="icon"
type="image/png"
sizes="192x192"
href="/assets/img/web-app-manifest-192x192.png?h=c8792ce927e01a494c4af48bed5dc5e3"
/>
<link
rel="icon"
type="image/png"
sizes="512x512"
href="/assets/img/web-app-manifest-512x512.png?h=c8792ce927e01a494c4af48bed5dc5e3"
/>
<link
rel="stylesheet"
href="/assets/bootstrap/css/bootstrap.min.css?h=608a9825a1f76f674715160908e57785"
/>
<link
rel="stylesheet"
href="/assets/css/Lato.css?h=8253736d3a23b522f64b7e7d96d1d8ff"
/>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css"
/>
<link
rel="manifest"
href="/manifest.json?h=457941ffad3c027c946331c09a4d7d2f"
/>
<meta name="theme-color" content="#ffffff" />
</head>
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
{/* Всё, что зависит от pathname, сейчас внутри клиентского LayoutWrapper */}
<LayoutWrapper>{children}</LayoutWrapper>
</body>
</html>
);
}

View File

@@ -0,0 +1,78 @@
// src/app/layout.tsx
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { ReactNode } from "react";
import Link from "next/link";
import Image from "next/image";
import { LayoutWrapper } from "./components/LayoutWrapper";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "CatLink",
description: "Ваши ссылки в одном месте",
};
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="ru">
<head>
<meta charSet="utf-8" />
<link
rel="apple-touch-icon"
type="image/png"
sizes="180x180"
href="/assets/img/apple-touch-icon.png?h=0f5e29c1169e75a7003e818478b67caa"
/>
<link
rel="icon"
type="image/png"
sizes="96x96"
href="/assets/img/favicon-96x96.png?h=c8792ce927e01a494c4af48bed5dc5e3"
/>
<link
rel="icon"
type="image/png"
sizes="192x192"
href="/assets/img/web-app-manifest-192x192.png?h=c8792ce927e01a494c4af48bed5dc5e3"
/>
<link
rel="icon"
type="image/png"
sizes="512x512"
href="/assets/img/web-app-manifest-512x512.png?h=c8792ce927e01a494c4af48bed5dc5e3"
/>
<link
rel="stylesheet"
href="/assets/bootstrap/css/bootstrap.min.css?h=608a9825a1f76f674715160908e57785"
/>
<link
rel="stylesheet"
href="/assets/css/Lato.css?h=8253736d3a23b522f64b7e7d96d1d8ff"
/>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css"
/>
<link
rel="manifest"
href="/manifest.json?h=457941ffad3c027c946331c09a4d7d2f"
/>
<meta name="theme-color" content="#ffffff" />
</head>
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
{/* Всё, что зависит от pathname, сейчас внутри клиентского LayoutWrapper */}
<LayoutWrapper>{children}</LayoutWrapper>
</body>
</html>
);
}

View File

@@ -0,0 +1,80 @@
// src/app/layout.tsx
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { ReactNode } from "react";
import { LayoutWrapper } from "../components/LayoutWrapper";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "CatLink",
description: "Ваши ссылки в одном месте",
};
export default function RootLayout({
children,
}: {
children: ReactNode;
}) {
return (
<html lang="ru">
<head>
<meta charSet="utf-8" />
<link
rel="apple-touch-icon"
type="image/png"
sizes="180x180"
href="/assets/img/apple-touch-icon.png?h=0f5e29c1169e75a7003e818478b67caa"
/>
<link
rel="icon"
type="image/png"
sizes="96x96"
href="/assets/img/favicon-96x96.png?h=c8792ce927e01a494c4af48bed5dc5e3"
/>
<link
rel="icon"
type="image/png"
sizes="192x192"
href="/assets/img/web-app-manifest-192x192.png?h=c8792ce927e01a494c4af48bed5dc5e3"
/>
<link
rel="icon"
type="image/png"
sizes="512x512"
href="/assets/img/web-app-manifest-512x512.png?h=c8792ce927e01a494c4af48bed5dc5e3"
/>
{/* Bootstrap & Lato & Icons */}
<link
rel="stylesheet"
href="/assets/bootstrap/css/bootstrap.min.css?h=608a9825a1f76f674715160908e57785"
/>
<link
rel="stylesheet"
href="/assets/css/Lato.css?h=8253736d3a23b522f64b7e7d96d1d8ff"
/>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css"
/>
<link rel="manifest" href="/manifest.json?h=457941ffad3c027c946331c09a4d7d2f" />
<meta name="theme-color" content="#ffffff" />
</head>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<LayoutWrapper>{children}</LayoutWrapper>
</body>
</html>
);
}

View File

@@ -0,0 +1,80 @@
// src/app/layout.tsx
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { ReactNode } from "react";
import { LayoutWrapper } from "./components/LayoutWrapper";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "CatLink",
description: "Ваши ссылки в одном месте",
};
export default function RootLayout({
children,
}: {
children: ReactNode;
}) {
return (
<html lang="ru">
<head>
<meta charSet="utf-8" />
<link
rel="apple-touch-icon"
type="image/png"
sizes="180x180"
href="/assets/img/apple-touch-icon.png?h=0f5e29c1169e75a7003e818478b67caa"
/>
<link
rel="icon"
type="image/png"
sizes="96x96"
href="/assets/img/favicon-96x96.png?h=c8792ce927e01a494c4af48bed5dc5e3"
/>
<link
rel="icon"
type="image/png"
sizes="192x192"
href="/assets/img/web-app-manifest-192x192.png?h=c8792ce927e01a494c4af48bed5dc5e3"
/>
<link
rel="icon"
type="image/png"
sizes="512x512"
href="/assets/img/web-app-manifest-512x512.png?h=c8792ce927e01a494c4af48bed5dc5e3"
/>
{/* Bootstrap & Lato & Icons */}
<link
rel="stylesheet"
href="/assets/bootstrap/css/bootstrap.min.css?h=608a9825a1f76f674715160908e57785"
/>
<link
rel="stylesheet"
href="/assets/css/Lato.css?h=8253736d3a23b522f64b7e7d96d1d8ff"
/>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css"
/>
<link rel="manifest" href="/manifest.json?h=457941ffad3c027c946331c09a4d7d2f" />
<meta name="theme-color" content="#ffffff" />
</head>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<LayoutWrapper>{children}</LayoutWrapper>
</body>
</html>
);
}

View File

@@ -0,0 +1,75 @@
// src/app/page.tsx
export default function HomePage() {
return (
<>
<header
className="text-center text-white masthead"
style={{
background: "url('/assets/img/bg-masthead.jpg') no-repeat center center",
backgroundSize: 'cover',
}}
>
<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="Введите электронную почту"
/>
<button className="btn btn-primary btn-lg" type="submit">
Начать
</button>
</form>
</div>
</div>
</div>
</header>
<section className="text-center bg-light features-icons py-5">
<div className="container">
<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>
<h3>Публикация</h3>
<p className="lead mb-0">
Делитесь единой ссылкой catlinks.kr/ваше-имя в био, мессенджерах и письмах.
</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>
<h3>Почему CatLink?</h3>
<p className="lead mb-0">
Повяжите свои миры одной «хвостовой» ссылкой.
</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>
<h3>Разместите всё важное на одной ссылке</h3>
<p className="lead mb-0">
Идите дальше, как кошка: легко и грациозно.
</p>
</div>
</div>
</div>
</div>
</section>
</>
);
}

Some files were not shown because too many files have changed in this diff Show More