init commit

This commit is contained in:
2025-05-06 20:44:33 +09:00
commit 91f0d54563
5567 changed files with 948185 additions and 0 deletions

View File

@@ -0,0 +1 @@
NEXT_PUBLIC_API_URL=http://127.0.0.1:8000

View File

@@ -0,0 +1,173 @@
<!DOCTYPE html>
<html data-bs-theme="light" lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no">
<title>Home - Brand</title>
<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="96x96" href="/assets/img/favicon-96x96.png?h=c8792ce927e01a494c4af48bed5dc5e3" media="(prefers-color-scheme: dark)">
<link rel="icon" type="image/png" sizes="96x96" href="/assets/img/favicon-96x96.png?h=c8792ce927e01a494c4af48bed5dc5e3">
<link rel="icon" type="image/png" sizes="96x96" href="/assets/img/favicon-96x96.png?h=c8792ce927e01a494c4af48bed5dc5e3" media="(prefers-color-scheme: dark)">
<link rel="icon" type="image/png" sizes="180x180" href="/assets/img/apple-touch-icon.png?h=0f5e29c1169e75a7003e818478b67caa">
<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="manifest" href="/manifest.json?h=457941ffad3c027c946331c09a4d7d2f" crossorigin="use-credentials">
<link rel="stylesheet" href="/assets/css/Lato.css?h=8253736d3a23b522f64b7e7d96d1d8ff">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/simple-line-icons/2.5.5/css/simple-line-icons.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/animate.css/3.5.2/animate.min.css">
</head>
<body>
<nav class="navbar navbar-expand bg-light">
<div class="container"><a class="navbar-brand" href="#"><img src="/assets/img/CAT.png?h=c38a6c0cbff3db2cee57966787d8189b" width="89" height="89">CatLink</a><button data-bs-toggle="collapse" class="navbar-toggler" data-bs-target="#navcol-1"></button>
<div class="collapse navbar-collapse" id="navcol-1"><a class="btn btn-primary ms-auto" role="button" href="#">Вход</a></div>
</div>
</nav>
<header class="text-center text-white masthead" style="background:url('/assets/img/bg-masthead.jpg?h=3d56ee9570bd6ab1d22f0827b18a0a99')no-repeat center center;background-size:cover;">
<div class="overlay"></div>
<div class="container">
<div class="row">
<div class="col-xl-9 mx-auto position-relative">
<h1 class="mb-5">Ваши ссылки. Ваш стиль. Ваш CatLink.</h1>
</div>
<div class="col-md-10 col-lg-8 col-xl-7 mx-auto position-relative">
<form>
<div class="row">
<div class="col-12 col-md-9 mb-2 mb-md-0"><input class="form-control form-control-lg" type="email" placeholder="Введите электронную почту"></div>
<div class="col-12 col-md-3 col-xl-1"><button class="btn btn-primary btn-lg" type="submit">Начать</button></div>
</div>
</form>
</div>
</div>
</div>
</header>
<section class="text-center bg-light features-icons">
<div class="container">
<div class="row">
<div class="col-lg-4">
<div class="mx-auto features-icons-item mb-5 mb-lg-0 mb-lg-3">
<div class="d-flex features-icons-icon"><i class="icon-link m-auto text-primary" data-bss-hover-animate="pulse"></i></div>
<h3>Публикация</h3>
<p class="lead mb-0">Делитесь единой ссылкой catlinks.kr/ваше-имя в био, мессенджерах и письмах.</p>
</div>
</div>
<div class="col-lg-4">
<div class="mx-auto features-icons-item mb-5 mb-lg-0 mb-lg-3">
<div class="d-flex features-icons-icon"><i class="icon-question m-auto text-primary" data-bss-hover-animate="pulse"></i></div>
<h3>Почему CatLink?</h3>
<p class="lead mb-0">повяжите свои миры одной «хвостовой» ссылкой.</p>
</div>
</div>
<div class="col-lg-4">
<div class="mx-auto features-icons-item mb-5 mb-lg-0 mb-lg-3">
<div class="d-flex features-icons-icon"><i class="icon-social-instagram m-auto text-primary" data-bss-hover-animate="pulse"></i></div>
<h3>Разместите всё важное на одной ссылке</h3>
<p class="lead mb-0">и идите дальше, как кошка: легко и грациозно.</p>
</div>
</div>
</div>
</div>
</section>
<section class="showcase">
<div class="container-fluid p-0">
<div class="row g-0">
<div class="col-lg-6 text-white order-lg-2 showcase-img" style="background-image:url(&quot;/assets/img/bg-showcase-1.jpg?h=717dfd74ae2c9ffe2373428a05a3f602&quot;);"><span></span></div>
<div class="col-lg-6 my-auto order-lg-1 showcase-text">
<h2>Fully Responsive Design</h2>
<p class="lead mb-0">When you use a theme created with Bootstrap, you know that the theme will look great on any device, whether it's a phone, tablet, or desktop the page will behave responsively!</p>
</div>
</div>
<div class="row g-0">
<div class="col-lg-6 text-white showcase-img" style="background-image:url(&quot;/assets/img/bg-showcase-2.jpg?h=82f59ff9dc7ce5bb277d6dfa737a6e45&quot;);"><span></span></div>
<div class="col-lg-6 my-auto order-lg-1 showcase-text">
<h2>Updated For Bootstrap 5</h2>
<p class="lead mb-0">Newly improved, and full of great utility classes, Bootstrap 5 is leading the way in mobile responsive web development! All of the themes are now using Bootstrap 5!</p>
</div>
</div>
<div class="row g-0">
<div class="col-lg-6 text-white order-lg-2 showcase-img" style="background-image:url(&quot;/assets/img/bg-showcase-3.jpg?h=c7ec0329b8412e48f1b91e5c6a8cc7cf&quot;);"><span></span></div>
<div class="col-lg-6 my-auto order-lg-1 showcase-text">
<h2>Easy to Use &amp;&nbsp;Customize</h2>
<p class="lead mb-0">Landing Page is just HTML and CSS with a splash of SCSS for users who demand some deeper customization options. Out of the box, just add your content and images, and your new landing page will be ready to go!</p>
</div>
</div>
</div>
</section>
<section class="text-center bg-light testimonials">
<div class="container">
<h2 class="mb-5">What people are saying...</h2>
<div class="row">
<div class="col-lg-4">
<div class="mx-auto testimonial-item mb-5 mb-lg-0"><img class="rounded-circle img-fluid mb-3" src="/assets/img/testimonials-1.jpg?h=c9a15635305654b24ce5a3055e22f73e">
<h5>Margaret E.</h5>
<p class="fw-light mb-0">"This is fantastic! Thanks so much guys!"</p>
</div>
</div>
<div class="col-lg-4">
<div class="mx-auto testimonial-item mb-5 mb-lg-0"><img class="rounded-circle img-fluid mb-3" src="/assets/img/testimonials-2.jpg?h=2f7c16e307b7da2bdf38d580d9a3fed9">
<h5>Fred S.</h5>
<p class="fw-light mb-0">"Bootstrap is amazing. I've been using it to create lots of super nice landing pages."</p>
</div>
</div>
<div class="col-lg-4">
<div class="mx-auto testimonial-item mb-5 mb-lg-0"><img class="rounded-circle img-fluid mb-3" src="/assets/img/testimonials-3.jpg?h=39503ac082e01a410b496ed9ce0df8e6">
<h5>Sarah W.</h5>
<p class="fw-light mb-0">"Thanks so much for making these free resources available to us!"</p>
</div>
</div>
</div>
</div>
</section>
<section class="text-center text-white call-to-action" style="background:url(&quot;/assets/img/bg-masthead.jpg?h=3d56ee9570bd6ab1d22f0827b18a0a99&quot;) no-repeat center center;background-size:cover;">
<div class="overlay"></div>
<div class="container">
<div class="row">
<div class="col-xl-9 mx-auto position-relative">
<h2 class="mb-4">Ready to get started? Sign up now!</h2>
</div>
<div class="col-md-10 col-lg-8 col-xl-7 mx-auto position-relative">
<form>
<div class="row">
<div class="col-12 col-md-9 mb-2 mb-md-0"><input class="form-control form-control-lg" type="email" placeholder="Enter your email..."></div>
<div class="col-12 col-md-3"><button class="btn btn-primary btn-lg" type="submit">Sign up!</button></div>
</div>
</form>
</div>
</div>
</div>
</section>
<footer class="bg-light footer">
<div class="container">
<div class="row">
<div class="col-lg-6 text-center text-lg-start my-auto h-100">
<ul class="list-inline mb-2">
<li class="list-inline-item"><a href="#">About</a></li>
<li class="list-inline-item"><span></span></li>
<li class="list-inline-item"><a href="#">Contact</a></li>
<li class="list-inline-item"><span></span></li>
<li class="list-inline-item"><a href="#">Terms of &nbsp;Use</a></li>
<li class="list-inline-item"><span></span></li>
<li class="list-inline-item"><a href="#">Privacy Policy</a></li>
</ul>
<p class="text-muted small mb-4 mb-lg-0">© Brand 2025. All Rights Reserved.</p>
</div>
<div class="col-lg-6 text-center text-lg-end my-auto h-100">
<ul class="list-inline mb-0">
<li class="list-inline-item"><a href="#"><i class="fa fa-facebook fa-2x fa-fw"></i></a></li>
<li class="list-inline-item"><a href="#"><i class="fa fa-twitter fa-2x fa-fw"></i></a></li>
<li class="list-inline-item"><a href="#"><i class="fa fa-instagram fa-2x fa-fw"></i></a></li>
</ul>
</div>
</div>
</div>
</footer>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.0/jquery.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="/assets/js/bs-init.js?h=ec5d4df3c798a2943b2ecbac76ebfde0"></script>
</body>
</html>

View File

@@ -0,0 +1,106 @@
<!DOCTYPE html>
<html data-bs-theme="light" lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no">
<title>Home - Brand</title>
<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="96x96" href="/assets/img/favicon-96x96.png?h=c8792ce927e01a494c4af48bed5dc5e3" media="(prefers-color-scheme: dark)">
<link rel="icon" type="image/png" sizes="96x96" href="/assets/img/favicon-96x96.png?h=c8792ce927e01a494c4af48bed5dc5e3">
<link rel="icon" type="image/png" sizes="96x96" href="/assets/img/favicon-96x96.png?h=c8792ce927e01a494c4af48bed5dc5e3" media="(prefers-color-scheme: dark)">
<link rel="icon" type="image/png" sizes="180x180" href="/assets/img/apple-touch-icon.png?h=0f5e29c1169e75a7003e818478b67caa">
<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="manifest" href="/manifest.json?h=457941ffad3c027c946331c09a4d7d2f" crossorigin="use-credentials">
<link rel="stylesheet" href="/assets/css/Lato.css?h=8253736d3a23b522f64b7e7d96d1d8ff">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/simple-line-icons/2.5.5/css/simple-line-icons.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/animate.css/3.5.2/animate.min.css">
</head>
<body>
<nav class="navbar navbar-expand bg-light">
<div class="container"><a class="navbar-brand" href="#"><img src="/assets/img/CAT.png?h=c38a6c0cbff3db2cee57966787d8189b" width="89" height="89">CatLink</a><button data-bs-toggle="collapse" class="navbar-toggler" data-bs-target="#navcol-1"></button>
<div class="collapse navbar-collapse" id="navcol-1"><a class="btn btn-primary ms-auto" role="button" href="#">Вход</a></div>
</div>
</nav>
<header class="text-center text-white masthead" style="background:url('/assets/img/bg-masthead.jpg?h=3d56ee9570bd6ab1d22f0827b18a0a99')no-repeat center center;background-size:cover;">
<div class="overlay"></div>
<div class="container">
<div class="row">
<div class="col-xl-9 mx-auto position-relative">
<h1 class="mb-5">Ваши ссылки. Ваш стиль. Ваш CatLink.</h1>
</div>
<div class="col-md-10 col-lg-8 col-xl-7 mx-auto position-relative">
<form>
<div class="row">
<div class="col-12 col-md-9 mb-2 mb-md-0"><input class="form-control form-control-lg" type="email" placeholder="Введите электронную почту"></div>
<div class="col-12 col-md-3 col-xl-1"><button class="btn btn-primary btn-lg" type="submit">Начать</button></div>
</div>
</form>
</div>
</div>
</div>
</header>
<section class="text-center bg-light features-icons">
<div class="container">
<div class="row">
<div class="col-lg-4">
<div class="mx-auto features-icons-item mb-5 mb-lg-0 mb-lg-3">
<div class="d-flex features-icons-icon"><i class="icon-link m-auto text-primary" data-bss-hover-animate="pulse"></i></div>
<h3>Публикация</h3>
<p class="lead mb-0">Делитесь единой ссылкой catlinks.kr/ваше-имя в био, мессенджерах и письмах.</p>
</div>
</div>
<div class="col-lg-4">
<div class="mx-auto features-icons-item mb-5 mb-lg-0 mb-lg-3">
<div class="d-flex features-icons-icon"><i class="icon-question m-auto text-primary" data-bss-hover-animate="pulse"></i></div>
<h3>Почему CatLink?</h3>
<p class="lead mb-0">повяжите свои миры одной «хвостовой» ссылкой.</p>
</div>
</div>
<div class="col-lg-4">
<div class="mx-auto features-icons-item mb-5 mb-lg-0 mb-lg-3">
<div class="d-flex features-icons-icon"><i class="icon-social-instagram m-auto text-primary" data-bss-hover-animate="pulse"></i></div>
<h3>Разместите всё важное на одной ссылке</h3>
<p class="lead mb-0">и идите дальше, как кошка: легко и грациозно.</p>
</div>
</div>
</div>
</div>
</section>
<footer class="bg-light footer">
<div class="container">
<div class="row">
<div class="col-lg-6 text-center text-lg-start my-auto h-100">
<ul class="list-inline mb-2">
<li class="list-inline-item"><a href="#">About</a></li>
<li class="list-inline-item"><span></span></li>
<li class="list-inline-item"><a href="#">Contact</a></li>
<li class="list-inline-item"><span></span></li>
<li class="list-inline-item"><a href="#">Terms of &nbsp;Use</a></li>
<li class="list-inline-item"><span></span></li>
<li class="list-inline-item"><a href="#">Privacy Policy</a></li>
</ul>
<p class="text-muted small mb-4 mb-lg-0">© Brand 2025. All Rights Reserved.</p>
</div>
<div class="col-lg-6 text-center text-lg-end my-auto h-100">
<ul class="list-inline mb-0">
<li class="list-inline-item"><a href="#"><i class="fa fa-facebook fa-2x fa-fw"></i></a></li>
<li class="list-inline-item"><a href="#"><i class="fa fa-twitter fa-2x fa-fw"></i></a></li>
<li class="list-inline-item"><a href="#"><i class="fa fa-instagram fa-2x fa-fw"></i></a></li>
</ul>
</div>
</div>
</div>
</footer>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.0/jquery.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="/assets/js/bs-init.js?h=ec5d4df3c798a2943b2ecbac76ebfde0"></script>
</body>
</html>

View File

@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;

View File

@@ -0,0 +1,9 @@
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
experimental: {
allowedDevOrigins: ['http://192.168.219.114:3000'],
},
};
module.exports = nextConfig;

View File

@@ -0,0 +1,9 @@
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
experimental: {
allowedDevOrigins: ['http://192.168.219.114'],
},
};
module.exports = nextConfig;

View File

@@ -0,0 +1,9 @@
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
experimental: {
allowedDevOrigins: ['http://192.168.219.114:3000'],
},
};
module.exports = nextConfig;

View File

@@ -0,0 +1,10 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
// Если вы хотите явно указать, что используете App Router:
experimental: { appDir: true },
// Разрешённые origin для дев-сборки:
allowedDevOrigins: ['http://192.168.219.114:3000']
};
module.exports = nextConfig;

View File

@@ -0,0 +1,10 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
// Если вы хотите явно указать, что используете App Router:
experimental: { appDir: true },
// Разрешённые origin для дев-сборки:
allowedDevOrigins: ['http://192.168.219.114:3001'],
};
module.exports = nextConfig;

View File

@@ -0,0 +1,10 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
// Если вы хотите явно указать, что используете App Router:
experimental: { },
// Разрешённые origin для дев-сборки:
allowedDevOrigins: ['http://192.168.219.114:3001'],
};
module.exports = nextConfig;

View File

@@ -0,0 +1,12 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
// Если вы хотите явно указать, что используете App Router:
experimental: { },
// Разрешённые origin для дев-сборки:
};
module.exports = nextConfig;
module.exports = {
allowedDevOrigins: ['local-origin.dev', '*.local-origin.dev'],
}

View File

@@ -0,0 +1,12 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
// Если вы хотите явно указать, что используете App Router:
experimental: { },
// Разрешённые origin для дев-сборки:
};
// module.exports = nextConfig;
module.exports = {
allowedDevOrigins: ['local-origin.dev', '*.local-origin.dev'],
}

View File

@@ -0,0 +1,12 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
// Если вы хотите явно указать, что используете App Router:
experimental: { },
// Разрешённые origin для дев-сборки:
};
// module.exports = nextConfig;
module.exports = {
allowedDevOrigins: ['local-origin.dev', '*.local-origin.dev', 'localhost', '192.168.219.114', 'localhost:3000', '192.168.219.114:3000'],
}

View File

@@ -0,0 +1,12 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
// Если вы хотите явно указать, что используете App Router:
experimental: { },
// Разрешённые origin для дев-сборки:
};
// module.exports = nextConfig;
module.exports = {
allowedDevOrigins: ['local-origin.dev', '*.local-origin.dev', 'localhost', '192.168.219.114', 'localhost:3000', '192.168.219.114:3000', '192.168.219.114"3001', '0.0.0.0'],
}

View File

@@ -0,0 +1,16 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
// Если вы хотите явно указать, что используете App Router:
experimental: { },
// Разрешённые origin для дев-сборки:
};
// module.exports = nextConfig;
module.exports = {
allowedDevOrigins: ['local-origin.dev', '*.local-origin.dev', 'localhost', '192.168.219.114', 'localhost:3000', '192.168.219.114:3000', '192.168.219.114"3001', '0.0.0.0'],
plugins: {
"@tailwindcss/postcss": {}, // вместо `tailwindcss`
autoprefixer: {},
},
}

View File

@@ -0,0 +1,14 @@
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
// Если вам не нужно явное разрешение App Router — эту секцию можно убрать.
// experimental: { appDir: true },
// Протокол обязателен, указывайте точный origin, включая порт.
allowedDevOrigins: [
'http://localhost:3001',
'http://192.168.219.114:3001'
],
};
module.exports = nextConfig;

View File

@@ -0,0 +1,17 @@
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
// Если вам не нужно явное разрешение App Router — эту секцию можно убрать.
// experimental: { appDir: true },
// Протокол обязателен, указывайте точный origin, включая порт.
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',
],
};
module.exports = nextConfig;

View File

@@ -0,0 +1,23 @@
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
// Если вам не нужно явное разрешение App Router — эту секцию можно убрать.
// experimental: { appDir: true },
// Протокол обязателен, указывайте точный origin, включая порт.
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',
],
return [
{
source: '/api/:path*',
destination: 'http://127.0.0.1:8000/api/:path*' // проксируем API
},
]
};

View File

@@ -0,0 +1,23 @@
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
// Если вам не нужно явное разрешение App Router — эту секцию можно убрать.
// experimental: { appDir: true },
// Протокол обязателен, указывайте точный origin, включая порт.
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',
],
return [
{
source: '/api/:path*',
destination: 'http://127.0.0.1:8000/api/:path*', // проксируем API
},
]
};

View File

@@ -0,0 +1,27 @@
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
// Если вам не нужно явное разрешение App Router — эту секцию можно убрать.
// experimental: { appDir: true },
// Протокол обязателен, указывайте точный origin, включая порт.
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',
],
// Проксируем все запросы /api/* на ваш Django-сервер
async rewrites() {
return [
{
source: '/api/:path*',
destination: 'http://127.0.0.1:8000/api/:path*'
}
];
},
};
};

View File

@@ -0,0 +1,27 @@
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
// Если вам не нужно явное разрешение App Router — эту секцию можно убрать.
// experimental: { appDir: true },
// Протокол обязателен, указывайте точный origin, включая порт.
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',
],
// Проксируем все запросы /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,24 @@
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
// Разрешённые origin для дев-сборки
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',
],
// Проксирование запросов /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,23 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
// Разрешаем в деве обращения с вашего адреса
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',
],
// Проксируем все запросы /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,25 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
// Разрешаем в деве обращения с вашего адреса
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',
],
// Проксируем все запросы /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,28 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
// Разрешаем 127.0.0.1 и localhost в качестве источников картинок
images: {
domains: ['127.0.0.1', 'localhost'],
},
// Разрешаем в деве обращения с вашего адреса
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',
],
// Проксируем все запросы /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,28 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
// Разрешаем 127.0.0.1 и localhost в качестве источников картинок
images: {
remotePatterns: ['127.0.0.1', 'localhost'],
},
// Разрешаем в деве обращения с вашего адреса
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',
],
// Проксируем все запросы /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,41 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
// Разрешаем 127.0.0.1 и localhost в качестве источников картинок
images: {
remotePatterns: [
{
protocol: 'http',
hostname: '127.0.0.1',
port: '8000',
pathname: '/media/**', // <-- Media files live under /media/…
},
{
protocol: 'http',
hostname: 'localhost',
port: '8000',
pathname: '/media/**',
},
],
},
// Разрешаем в деве обращения с вашего адреса
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',
],
// Проксируем все запросы /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,41 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
// Разрешаем 127.0.0.1 и localhost в качестве источников картинок
images: {
remotePatterns: [
{
protocol: 'http',
hostname: '127.0.0.1',
port: '3001',
pathname: '/media/**', // <-- Media files live under /media/…
},
{
protocol: 'http',
hostname: 'localhost',
port: '3001',
pathname: '/media/**',
},
],
},
// Разрешаем в деве обращения с вашего адреса
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',
],
// Проксируем все запросы /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,41 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
// Разрешаем 127.0.0.1 и localhost в качестве источников картинок
images: {
remotePatterns: [
{
protocol: 'http',
hostname: '127.0.0.1',
port: '8000',
pathname: '/media/**', // <-- Media files live under /media/…
},
{
protocol: 'http',
hostname: 'localhost',
port: '8000',
pathname: '/media/**',
},
],
},
// Разрешаем в деве обращения с вашего адреса
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',
],
// Проксируем все запросы /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,54 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
remotePatterns: [
// Все файлы из MEDIA_URL (/media/...)
{
protocol: 'http',
hostname: '127.0.0.1',
port: '8000', // <— вот!
pathname: '/media/**',
},
{
protocol: 'http',
hostname: 'localhost',
port: '8000',
pathname: '/media/**',
},
// Если у вас еще прямой доступ по /avatars/...
{
protocol: 'http',
hostname: '127.0.0.1',
port: '8000',
pathname: '/avatars/**',
},
{
protocol: 'http',
hostname: 'localhost',
port: '8000',
pathname: '/avatars/**',
},
],
},
// Разрешаем в деве обращения с вашего адреса
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',
],
// Проксируем все запросы /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,39 @@
/** @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 и т.д.
},
{
protocol: 'http',
hostname: 'localhost',
port: '8000',
pathname: '/storage/**',
},
],
},
// Разрешаем в деве обращения с вашего адреса
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',
],
// Проксируем все запросы /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,34 @@
/** @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',
],
// Проксируем все запросы /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,34 @@
/** @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',
],
// Проксируем все запросы /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,28 @@
{
"name": "linktree-frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"next": "15.3.1",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@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,29 @@
{
"name": "linktree-frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"next": "15.3.1",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@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,14 @@
// pages/index.tsx
import type { NextPage } from 'next';
const Home: NextPage = () => {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<h1 className="text-3xl font-bold">
Добро пожаловать в LinkTree Clone!
</h1>
</div>
);
};
export default Home;

View File

@@ -0,0 +1,5 @@
const config = {
plugins: ["@tailwindcss/postcss"],
};
export default config;

View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@@ -0,0 +1,7 @@
// postcss.config.js
module.exports = {
plugins: {
'@tailwindcss/postcss': {}, // новый пакет-плагин для Tailwind v4+
autoprefixer: {},
},
};

View File

@@ -0,0 +1,39 @@
// src/app/auth/login/page.tsx
'use client'
import { useForm } from 'react-hook-form'
type FormData = { email: string; password: string }
export default function LoginPage() {
const { register, handleSubmit } = useForm<FormData>()
const onSubmit = (data: FormData) => {
// TODO: отправить запрос на API
console.log(data)
}
return (
<main className="max-w-md mx-auto p-6">
<h1 className="text-2xl font-semibold mb-4">Вход</h1>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<input
{...register('email', { required: true })}
type="email"
placeholder="Email"
className="w-full p-2 border rounded"
/>
<input
{...register('password', { required: true })}
type="password"
placeholder="Пароль"
className="w-full p-2 border rounded"
/>
<button
type="submit"
className="w-full py-2 bg-blue-600 text-white rounded"
>
Войти
</button>
</form>
</main>
)
}

View File

@@ -0,0 +1,33 @@
// src/app/auth/login/page.tsx
'use client'
import { useForm } from 'react-hook-form'
type FormData = { email: string; password: string }
export default function LoginPage() {
const { register, handleSubmit } = useForm<FormData>()
const onSubmit = (data: FormData) => {
// TODO: отправить запрос на API
console.log(data)
}
return (
<div id="myModal">
<div class="modal-dialog modal-login">
<div class="modal-content">
<div class="modal-header">
<h4 class="h4 modal-title">Member Login</h4><button class="btn btn-primary close" type="button" aria-hidden="true" data-bs-dismiss="modal">x</button>
</div>
<div class="modal-body">
<form action="confirmation" method="post">
<div class="form-group"><i class="fa fa-star fa-user"></i><input class="form-control" type="text" placeholder="Username" required="required" /></div>
<div class="form-group"><i class="fa fa-star fa-lock"></i><input class="form-control" type="password" placeholder="Password" required="required" /></div>
<div class="form-group"><button class="btn btn-primary btn-block btn-lg" type="submit" value="Login">Login</button></div>
</form>
</div>
<div class="modal-footer"><a href="forgot.html">Forgot Password?</a></div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,50 @@
// src/app/auth/login/page.tsx
'use client'
import { useForm } from 'react-hook-form'
type FormData = { email: string; password: string }
export default function LoginPage() {
const { register, handleSubmit } = useForm<FormData>()
const onSubmit = (data: FormData) => {
// TODO: отправить запрос на API
console.log(data)
}
return (
<div class="d-flex d-xl-flex align-items-center align-items-xl-center" style="width: 100%;height: 100%;">
<div class="container">
<div class="row justify-content-center">
<div class="col-md-9 col-lg-12 col-xl-10">
<div class="card shadow-lg o-hidden border-0 my-5">
<div class="card-body p-0">
<div class="row">
<div class="col-lg-6 d-none d-lg-flex">
<div class="flex-grow-1 bg-login-image" style="background-image: url('durvill_logo.jpg');"></div>
</div>
<div class="col-lg-6">
<div class="p-5">
<div class="text-center">
<h4 class="text-dark mb-4">Welcome back!</h4>
</div>
<form class="user">
<div class="mb-3"><input id="exampleInputEmail" class="form-control form-control-user" type="email" aria-describedby="emailHelp" placeholder="Enter Email Address..." name="email" /></div>
<div class="mb-3"><input id="exampleInputPassword" class="form-control form-control-user" type="password" placeholder="Password" name="password" /></div>
<div class="mb-3">
<div class="custom-control custom-checkbox small"></div>
</div><button class="btn btn-primary d-block btn-user w-100" type="submit" style="background: #01703E;">Login</button>
<hr />
<hr />
</form>
<div class="text-center"><a class="small" href="forgot-password.html" style="color: #01703E;">Forgot Password?</a></div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,50 @@
// src/app/auth/login/page.tsx
'use client'
import { useForm } from 'react-hook-form'
type FormData = { email: string; password: string }
export default function LoginPage() {
const { register, handleSubmit } = useForm<FormData>()
const onSubmit = (data: FormData) => {
// TODO: отправить запрос на API
console.log(data)
}
return (
<div className="d-flex d-xl-flex align-items-center align-items-xl-center" style="width: 100%;height: 100%;">
<div classNameName="container">
<div className="row justify-content-center">
<div className="col-md-9 col-lg-12 col-xl-10">
<div className="card shadow-lg o-hidden border-0 my-5">
<div className="card-body p-0">
<div className="row">
<div className="col-lg-6 d-none d-lg-flex">
<div className="flex-grow-1 bg-login-image" style="background-image: url('durvill_logo.jpg');"></div>
</div>
<div className="col-lg-6">
<div className="p-5">
<div className="text-center">
<h4 className="text-dark mb-4">Welcome back!</h4>
</div>
<form className="user">
<div className="mb-3"><input id="exampleInputEmail" className="form-control form-control-user" type="email" aria-describedby="emailHelp" placeholder="Enter Email Address..." name="email" /></div>
<div className="mb-3"><input id="exampleInputPassword" className="form-control form-control-user" type="password" placeholder="Password" name="password" /></div>
<div className="mb-3">
<div className="custom-control custom-checkbox small"></div>
</div><button className="btn btn-primary d-block btn-user w-100" type="submit" style="background: #01703E;">Login</button>
<hr />
<hr />
</form>
<div className="text-center"><a className="small" href="forgot-password.html" style="color: #01703E;">Forgot Password?</a></div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,52 @@
// src/app/auth/login/page.tsx
'use client'
import { useForm } from 'react-hook-form'
type FormData = { email: string; password: string }
export default function LoginPage() {
const { register, handleSubmit } = useForm<FormData>()
const onSubmit = (data: FormData) => {
// TODO: отправить запрос на API
console.log(data)
}
return (
<>
<div className="d-flex d-xl-flex align-items-center align-items-xl-center" style="width: 100%;height: 100%;">
<div classNameName="container">
<div className="row justify-content-center">
<div className="col-md-9 col-lg-12 col-xl-10">
<div className="card shadow-lg o-hidden border-0 my-5">
<div className="card-body p-0">
<div className="row">
<div className="col-lg-6 d-none d-lg-flex">
<div className="flex-grow-1 bg-login-image" style="background-image: url('durvill_logo.jpg');"></div>
</div>
<div className="col-lg-6">
<div className="p-5">
<div className="text-center">
<h4 className="text-dark mb-4">Welcome back!</h4>
</div>
<form className="user">
<div className="mb-3"><input id="exampleInputEmail" className="form-control form-control-user" type="email" aria-describedby="emailHelp" placeholder="Enter Email Address..." name="email" /></div>
<div className="mb-3"><input id="exampleInputPassword" className="form-control form-control-user" type="password" placeholder="Password" name="password" /></div>
<div className="mb-3">
<div className="custom-control custom-checkbox small"></div>
</div><button className="btn btn-primary d-block btn-user w-100" type="submit" style="background: #01703E;">Login</button>
<hr />
<hr />
</form>
<div className="text-center"><a className="small" href="forgot-password.html" style="color: #01703E;">Forgot Password?</a></div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</>
)
}

View File

@@ -0,0 +1,52 @@
// src/app/auth/login/page.tsx
'use client'
import { useForm } from 'react-hook-form'
type FormData = { email: string; password: string }
export default function LoginPage() {
const { register, handleSubmit } = useForm<FormData>()
const onSubmit = (data: FormData) => {
// TODO: отправить запрос на API
console.log(data)
}
return (
<>
<div className="d-flex d-xl-flex align-items-center align-items-xl-center" style="width: 100%;height: 100%;">
<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 o-hidden border-0 my-5">
<div className="card-body p-0">
<div className="row">
<div className="col-lg-6 d-none d-lg-flex">
<div className="flex-grow-1 bg-login-image" style="background-image: url('durvill_logo.jpg');"></div>
</div>
<div className="col-lg-6">
<div className="p-5">
<div className="text-center">
<h4 className="text-dark mb-4">Welcome back!</h4>
</div>
<form className="user">
<div className="mb-3"><input id="exampleInputEmail" className="form-control form-control-user" type="email" aria-describedby="emailHelp" placeholder="Enter Email Address..." name="email" /></div>
<div className="mb-3"><input id="exampleInputPassword" className="form-control form-control-user" type="password" placeholder="Password" name="password" /></div>
<div className="mb-3">
<div className="custom-control custom-checkbox small"></div>
</div><button className="btn btn-primary d-block btn-user w-100" type="submit" style="background: #01703E;">Login</button>
<hr />
<hr />
</form>
<div className="text-center"><a className="small" href="forgot-password.html" style="color: #01703E;">Forgot Password?</a></div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</>
)
}

View File

@@ -0,0 +1,124 @@
// src/app/auth/login/page.tsx
'use client';
import { useForm } from 'react-hook-form';
interface FormData {
email: string;
password: string;
rememberMe: boolean;
}
export default function LoginPage() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormData>();
const onSubmit = (data: FormData) => {
// TODO: отправить запрос на API
console.log(data);
};
return (
<div
className="d-flex align-items-center"
style={{ width: '100%', height: '100%' }}
>
<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 o-hidden border-0 my-5">
<div className="card-body p-0">
<div className="row">
<div className="col-lg-6 d-none d-lg-flex">
<div
className="flex-grow-1 bg-login-image"
style={{
backgroundImage: "url('/assets/img/durvill_logo.jpg')",
}}
/>
</div>
<div className="col-lg-6">
<div className="p-5">
<div className="text-center">
<h4 className="text-dark mb-4">Welcome back!</h4>
</div>
<form className="user" onSubmit={handleSubmit(onSubmit)}>
<div className="mb-3">
<input
id="exampleInputEmail"
type="email"
aria-describedby="emailHelp"
placeholder="Enter Email Address..."
className={`form-control form-control-user ${
errors.email ? 'border-red-500' : ''
}`}
{...register('email', {
required: 'Email обязателен',
})}
/>
{errors.email && (
<span className="text-red-500">
{errors.email.message}
</span>
)}
</div>
<div className="mb-3">
<input
id="exampleInputPassword"
type="password"
placeholder="Password"
className={`form-control form-control-user ${
errors.password ? 'border-red-500' : ''
}`}
{...register('password', {
required: 'Пароль обязателен',
})}
/>
{errors.password && (
<span className="text-red-500">
{errors.password.message}
</span>
)}
</div>
<div className="mb-3 form-check">
<input
id="rememberMe"
type="checkbox"
className="form-check-input"
{...register('rememberMe')}
/>
<label htmlFor="rememberMe" className="form-check-label">
Запомнить меня
</label>
</div>
<button
type="submit"
className="btn btn-primary btn-user w-100"
style={{ background: '#01703E' }}
>
Login
</button>
</form>
<hr />
<div className="text-center">
<a
href="/forgot-password"
className="small"
style={{ color: '#01703E' }}
>
Forgot Password?
</a>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,124 @@
// src/app/auth/login/page.tsx
'use client';
import { useForm } from 'react-hook-form';
interface FormData {
email: string;
password: string;
rememberMe: boolean;
}
export default function LoginPage() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormData>();
const onSubmit = (data: FormData) => {
// TODO: отправить запрос на API
console.log(data);
};
return (
<div
className="d-flex align-items-center"
style={{ width: '100%', height: '100%' }}
>
<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 o-hidden border-0 my-5">
<div className="card-body p-0">
<div className="row">
<div className="col-lg-6 d-none d-lg-flex">
<div
className="flex-grow-1 bg-login-image"
style={{
backgroundImage: "url('/assets/img/durvill_logo.jpg')",
}}
/>
</div>
<div className="col-lg-6">
<div className="p-5">
<div className="text-center">
<h4 className="text-dark mb-4">Добро пожаловать!</h4>
</div>
<form className="user" onSubmit={handleSubmit(onSubmit)}>
<div className="mb-3">
<input
id="exampleInputEmail"
type="email"
aria-describedby="emailHelp"
placeholder="Enter Email Address..."
className={`form-control form-control-user ${
errors.email ? 'border-red-500' : ''
}`}
{...register('email', {
required: 'Email обязателен',
})}
/>
{errors.email && (
<span className="text-red-500">
{errors.email.message}
</span>
)}
</div>
<div className="mb-3">
<input
id="exampleInputPassword"
type="password"
placeholder="Password"
className={`form-control form-control-user ${
errors.password ? 'border-red-500' : ''
}`}
{...register('password', {
required: 'Пароль обязателен',
})}
/>
{errors.password && (
<span className="text-red-500">
{errors.password.message}
</span>
)}
</div>
<div className="mb-3 form-check">
<input
id="rememberMe"
type="checkbox"
className="form-check-input"
{...register('rememberMe')}
/>
<label htmlFor="rememberMe" className="form-check-label">
Запомнить меня
</label>
</div>
<button
type="submit"
className="btn btn-primary btn-user w-100"
style={{ background: '#01703E' }}
>
Вход
</button>
</form>
<hr />
<div className="text-center">
<a
href="/forgot-password"
className="small"
style={{ color: '#01703E' }}
>
Забыли пароль?
</a>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,152 @@
// src/app/auth/login/page.tsx
'use client';
import { useForm } from 'react-hook-form';
import Image from 'next/image';
import Link from 'next/link';
interface FormData {
email: string;
password: string;
rememberMe: boolean;
}
export default function LoginPage() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormData>();
const onSubmit = (data: FormData) => {
// TODO: отправить запрос на API
console.log(data);
};
return (
<>
{/* Navbar */}
<nav className="navbar navbar-expand bg-light">
<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"
></button>
<div className="collapse navbar-collapse" id="navcol-1">
<Link href="/auth/login" className="btn btn-primary ms-auto d-flex align-items-center">
<i className="fa fa-user me-1"></i>
<span className="d-none d-sm-inline">Вход</span>
</Link>
</div>
</div>
</nav>
{/* Login Form */}
<div
className="d-flex align-items-center"
style={{ width: '100%', height: '100%' }}
>
<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 o-hidden border-0 my-5">
<div className="card-body p-0">
<div className="row">
<div className="col-lg-6 d-none d-lg-flex">
<div
className="flex-grow-1 bg-login-image"
style={{
backgroundImage: "url('/assets/img/durvill_logo.jpg')",
}}
/>
</div>
<div className="col-lg-6">
<div className="p-5">
<div className="text-center">
<h4 className="text-dark mb-4">Добро пожаловать!</h4>
</div>
<form className="user" onSubmit={handleSubmit(onSubmit)}>
<div className="mb-3">
<input
id="exampleInputEmail"
type="email"
aria-describedby="emailHelp"
placeholder="Enter Email Address..."
className={`form-control form-control-user ${
errors.email ? 'border-red-500' : ''
}`}
{...register('email', {
required: 'Email обязателен',
})}
/>
{errors.email && (
<span className="text-red-500">
{errors.email.message}
</span>
)}
</div>
<div className="mb-3">
<input
id="exampleInputPassword"
type="password"
placeholder="Password"
className={`form-control form-control-user ${
errors.password ? 'border-red-500' : ''
}`}
{...register('password', {
required: 'Пароль обязателен',
})}
/>
{errors.password && (
<span className="text-red-500">
{errors.password.message}
</span>
)}
</div>
<div className="mb-3 form-check">
<input
id="rememberMe"
type="checkbox"
className="form-check-input"
{...register('rememberMe')}
/>
<label htmlFor="rememberMe" className="form-check-label">
Запомнить меня
</label>
</div>
<button
type="submit"
className="btn btn-primary btn-user w-100"
style={{ background: '#01703E' }}
>
Войти
</button>
</form>
<hr />
<div className="text-center">
<Link href="/forgot-password" className="small" style={{ color: '#01703E' }}>
Забыл пароль?
</Link>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,147 @@
// src/app/auth/login/page.tsx
'use client';
import { useForm } from 'react-hook-form';
import Image from 'next/image';
import Link from 'next/link';
interface FormData {
email: string;
password: string;
rememberMe: boolean;
}
export default function LoginPage() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormData>();
const onSubmit = (data: FormData) => {
// TODO: отправить запрос на API
console.log(data);
};
return (
<>
{/* Navbar */}
<nav className="navbar navbar-expand bg-light">
<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"
></button>
</div>
</nav>
{/* Login Form */}
<div
className="d-flex align-items-center"
style={{ width: '100%', height: '100%' }}
>
<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 o-hidden border-0 my-5">
<div className="card-body p-0">
<div className="row">
<div className="col-lg-6 d-none d-lg-flex">
<div
className="flex-grow-1 bg-login-image"
style={{
backgroundImage: "url('/assets/img/durvill_logo.jpg')",
}}
/>
</div>
<div className="col-lg-6">
<div className="p-5">
<div className="text-center">
<h4 className="text-dark mb-4">Добро пожаловать!</h4>
</div>
<form className="user" onSubmit={handleSubmit(onSubmit)}>
<div className="mb-3">
<input
id="exampleInputEmail"
type="email"
aria-describedby="emailHelp"
placeholder="Enter Email Address..."
className={`form-control form-control-user ${
errors.email ? 'border-red-500' : ''
}`}
{...register('email', {
required: 'Email обязателен',
})}
/>
{errors.email && (
<span className="text-red-500">
{errors.email.message}
</span>
)}
</div>
<div className="mb-3">
<input
id="exampleInputPassword"
type="password"
placeholder="Password"
className={`form-control form-control-user ${
errors.password ? 'border-red-500' : ''
}`}
{...register('password', {
required: 'Пароль обязателен',
})}
/>
{errors.password && (
<span className="text-red-500">
{errors.password.message}
</span>
)}
</div>
<div className="mb-3 form-check">
<input
id="rememberMe"
type="checkbox"
className="form-check-input"
{...register('rememberMe')}
/>
<label htmlFor="rememberMe" className="form-check-label">
Запомнить меня
</label>
</div>
<button
type="submit"
className="btn btn-primary btn-user w-100"
style={{ background: '#01703E' }}
>
Войти
</button>
</form>
<hr />
<div className="text-center">
<Link href="/forgot-password" className="small" style={{ color: '#01703E' }}>
Забыл пароль?
</Link>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,113 @@
// src/app/auth/login/page.tsx
'use client';
import { useState, useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import Image from 'next/image';
type FormData = {
username: string;
password: string;
};
export default function LoginPage() {
const router = useRouter();
const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm<FormData>();
const [apiError, setApiError] = useState<string | null>(null);
useEffect(() => {
// если уже залогинен, редирект на dashboard
if (typeof window !== 'undefined' && localStorage.getItem('token')) {
router.push('/dashboard');
}
}, [router]);
async function onSubmit(data: FormData) {
setApiError(null);
try {
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/auth/login/`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!res.ok) {
const err = await res.json();
setApiError(err.detail || 'Ошибка входа');
return;
}
const { access } = await res.json();
localStorage.setItem('token', access);
router.push('/dashboard');
} catch (e: any) {
setApiError('Сетевая ошибка');
}
}
return (
<div className="min-h-screen flex flex-col">
{/* Navbar */}
<nav className="bg-white shadow">
<div className="max-w-4xl mx-auto px-4 py-3 flex justify-between items-center">
<Link href="/" className="flex items-center">
<Image src="/assets/img/CAT.png" alt="CatLink" width={32} height={32} />
<span className="ml-2 font-bold text-xl">CatLink</span>
</Link>
<Link href="/auth/login" className="text-blue-600 hover:underline">
Войти
</Link>
</div>
</nav>
{/* Form */}
<main className="flex-grow flex items-center justify-center bg-gray-50">
<div className="bg-white p-8 rounded shadow-md w-full max-w-md">
<h2 className="text-2xl font-semibold mb-6 text-center">Вход в CatLink</h2>
{apiError && <p className="text-red-600 mb-4 text-center">{apiError}</p>}
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div>
<label htmlFor="username" className="block mb-1">Имя пользователя</label>
<input
id="username"
type="text"
{...register('username', { required: 'Введите имя пользователя' })}
className={`w-full px-3 py-2 border rounded ${errors.username ? 'border-red-500' : 'border-gray-300'}`}
/>
{errors.username && <p className="text-red-500 mt-1 text-sm">{errors.username.message}</p>}
</div>
<div>
<label htmlFor="password" className="block mb-1">Пароль</label>
<input
id="password"
type="password"
{...register('password', { required: 'Введите пароль' })}
className={`w-full px-3 py-2 border rounded ${errors.password ? 'border-red-500' : 'border-gray-300'}`}
/>
{errors.password && <p className="text-red-500 mt-1 text-sm">{errors.password.message}</p>}
</div>
<button
type="submit"
disabled={isSubmitting}
className="w-full bg-blue-600 text-white py-2 rounded hover:bg-blue-700 transition"
>
{isSubmitting ? 'Вхожу...' : 'Войти'}
</button>
</form>
<p className="text-sm text-center mt-4">
Нет аккаунта?{' '}
<Link href="/auth/register" className="text-blue-600 hover:underline">
Регистрация
</Link>
</p>
</div>
</main>
{/* Footer */}
<footer className="bg-white py-4 shadow-inner">
<div className="max-w-4xl mx-auto text-center text-gray-500 text-sm">
© {new Date().getFullYear()} CatLink. Все права защищены.
</div>
</footer>
</div>
);
}

View File

@@ -0,0 +1,169 @@
// src/app/auth/login/page.tsx
'use client';
import { useState, useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
type FormData = {
username: string;
password: string;
remember?: boolean;
};
export default function LoginPage() {
const router = useRouter();
const {
register,
handleSubmit,
formState: { errors, isSubmitting }
} = useForm<FormData>();
const [apiError, setApiError] = useState<string | null>(null);
useEffect(() => {
if (typeof window !== 'undefined' && localStorage.getItem('token')) {
router.push('/dashboard');
}
}, [router]);
async function onSubmit(data: FormData) {
setApiError(null);
try {
const res = await fetch('/api/auth/login/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: data.username,
password: data.password
})
});
if (!res.ok) {
const json = await res.json();
setApiError(json.detail || 'Ошибка входа');
return;
}
const { access } = await res.json();
localStorage.setItem('token', access);
router.push('/dashboard');
} catch {
setApiError('Сетевая ошибка');
}
}
return (
<div
className="d-flex d-xl-flex align-items-center align-items-xl-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 o-hidden border-0 my-5">
<div className="card-body p-0">
<div className="row">
{/* Левая половина с картинкой */}
<div className="col-lg-6 d-none d-lg-flex">
<div
className="flex-grow-1 bg-login-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">
<h4 className="text-dark mb-4">Welcome back!</h4>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="user">
<div className="mb-3">
<input
id="exampleInputEmail"
className={`form-control form-control-user ${
errors.username ? 'is-invalid' : ''
}`}
type="text"
placeholder="Enter Username..."
{...register('username', {
required: 'Введите имя пользователя'
})}
/>
{errors.username && (
<div className="invalid-feedback">
{errors.username.message}
</div>
)}
</div>
<div className="mb-3">
<input
id="exampleInputPassword"
className={`form-control form-control-user ${
errors.password ? 'is-invalid' : ''
}`}
type="password"
placeholder="Password"
{...register('password', {
required: 'Введите пароль'
})}
/>
{errors.password && (
<div className="invalid-feedback">
{errors.password.message}
</div>
)}
</div>
<div className="mb-3">
<div className="form-check small">
<input
type="checkbox"
className="form-check-input"
id="rememberCheck"
{...register('remember')}
/>
<label
className="form-check-label"
htmlFor="rememberCheck"
>
Remember Me
</label>
</div>
</div>
{apiError && (
<p className="text-danger small mb-3 text-center">
{apiError}
</p>
)}
<button
className="btn btn-primary d-block btn-user w-100"
type="submit"
style={{ background: '#01703E' }}
disabled={isSubmitting}
>
{isSubmitting ? 'Вхожу...' : 'Login'}
</button>
<hr />
</form>
<div className="text-center">
<Link
href="/auth/forgot-password"
className="small"
style={{ color: '#01703E' }}
>
Forgot Password?
</Link>
</div>
</div>
</div>
{/* /Правая половина */}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,169 @@
// src/app/auth/login/page.tsx
'use client';
import { useState, useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
type FormData = {
username: string;
password: string;
remember?: boolean;
};
export default function LoginPage() {
const router = useRouter();
const {
register,
handleSubmit,
formState: { errors, isSubmitting }
} = useForm<FormData>();
const [apiError, setApiError] = useState<string | null>(null);
useEffect(() => {
if (typeof window !== 'undefined' && localStorage.getItem('token')) {
router.push('/dashboard');
}
}, [router]);
async function onSubmit(data: FormData) {
setApiError(null);
try {
const res = await fetch('/api/auth/login/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: data.username,
password: data.password
})
});
if (!res.ok) {
const json = await res.json();
setApiError(json.detail || 'Ошибка входа');
return;
}
const { access } = await res.json();
localStorage.setItem('token', access);
router.push('/dashboard');
} catch {
setApiError('Сетевая ошибка');
}
}
return (
<div
className="d-flex d-xl-flex align-items-center align-items-xl-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 o-hidden border-0 my-5">
<div className="card-body p-0">
<div className="row">
{/* Левая половина с картинкой */}
<div className="col-lg-6 d-none d-lg-flex">
<div
className="flex-grow-1 bg-login-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">
<h4 className="text-dark mb-4">Welcome back!</h4>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="user">
<div className="mb-3">
<input
id="exampleInputEmail"
className={`form-control form-control-user ${
errors.username ? 'is-invalid' : ''
}`}
type="text"
placeholder="Enter Username..."
{...register('username', {
required: 'Введите имя пользователя'
})}
/>
{errors.username && (
<div className="invalid-feedback">
{errors.username.message}
</div>
)}
</div>
<div className="mb-3">
<input
id="exampleInputPassword"
className={`form-control form-control-user ${
errors.password ? 'is-invalid' : ''
}`}
type="password"
placeholder="Password"
{...register('password', {
required: 'Введите пароль'
})}
/>
{errors.password && (
<div className="invalid-feedback">
{errors.password.message}
</div>
)}
</div>
<div className="mb-3">
<div className="form-check small">
<input
type="checkbox"
className="form-check-input"
id="rememberCheck"
{...register('remember')}
/>
<label
className="form-check-label"
htmlFor="rememberCheck"
>
Remember Me
</label>
</div>
</div>
{apiError && (
<p className="text-danger small mb-3 text-center">
{apiError}
</p>
)}
<button
className="btn btn-primary d-block btn-user w-100"
type="submit"
style={{ background: '#01703E' }}
disabled={isSubmitting}
>
{isSubmitting ? 'Вхожу...' : 'Login'}
</button>
<hr />
</form>
<div className="text-center">
<Link
href="/auth/forgot-password"
className="small"
style={{ color: '#01703E' }}
>
Forgot Password?
</Link>
</div>
</div>
</div>
{/* /Правая половина */}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,169 @@
// src/app/auth/login/page.tsx
'use client';
import { useState, useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
type FormData = {
username: string;
password: string;
remember?: boolean;
};
export default function LoginPage() {
const router = useRouter();
const {
register,
handleSubmit,
formState: { errors, isSubmitting }
} = useForm<FormData>();
const [apiError, setApiError] = useState<string | null>(null);
useEffect(() => {
if (typeof window !== 'undefined' && localStorage.getItem('token')) {
router.push('/dashboard');
}
}, [router]);
async function onSubmit(data: FormData) {
setApiError(null);
try {
const res = await fetch('http://127.0.0.1:8000/api/auth/login/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: data.username,
password: data.password
})
});
if (!res.ok) {
const json = await res.json();
setApiError(json.detail || 'Ошибка входа');
return;
}
const { access } = await res.json();
localStorage.setItem('token', access);
router.push('/dashboard');
} catch {
setApiError('Сетевая ошибка');
}
}
return (
<div
className="d-flex d-xl-flex align-items-center align-items-xl-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 o-hidden border-0 my-5">
<div className="card-body p-0">
<div className="row">
{/* Левая половина с картинкой */}
<div className="col-lg-6 d-none d-lg-flex">
<div
className="flex-grow-1 bg-login-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">
<h4 className="text-dark mb-4">Welcome back!</h4>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="user">
<div className="mb-3">
<input
id="exampleInputEmail"
className={`form-control form-control-user ${
errors.username ? 'is-invalid' : ''
}`}
type="text"
placeholder="Enter Username..."
{...register('username', {
required: 'Введите имя пользователя'
})}
/>
{errors.username && (
<div className="invalid-feedback">
{errors.username.message}
</div>
)}
</div>
<div className="mb-3">
<input
id="exampleInputPassword"
className={`form-control form-control-user ${
errors.password ? 'is-invalid' : ''
}`}
type="password"
placeholder="Password"
{...register('password', {
required: 'Введите пароль'
})}
/>
{errors.password && (
<div className="invalid-feedback">
{errors.password.message}
</div>
)}
</div>
<div className="mb-3">
<div className="form-check small">
<input
type="checkbox"
className="form-check-input"
id="rememberCheck"
{...register('remember')}
/>
<label
className="form-check-label"
htmlFor="rememberCheck"
>
Remember Me
</label>
</div>
</div>
{apiError && (
<p className="text-danger small mb-3 text-center">
{apiError}
</p>
)}
<button
className="btn btn-primary d-block btn-user w-100"
type="submit"
style={{ background: '#01703E' }}
disabled={isSubmitting}
>
{isSubmitting ? 'Вхожу...' : 'Login'}
</button>
<hr />
</form>
<div className="text-center">
<Link
href="/auth/forgot-password"
className="small"
style={{ color: '#01703E' }}
>
Forgot Password?
</Link>
</div>
</div>
</div>
{/* /Правая половина */}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,148 @@
// src/app/auth/login/page.tsx
'use client'
import { useState, useEffect } from 'react'
import { useForm } from 'react-hook-form'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
type FormData = { username: string; password: string; remember?: boolean }
export default function LoginPage() {
const router = useRouter()
const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm<FormData>()
const [apiError, setApiError] = useState<string | null>(null)
useEffect(() => {
if (typeof window !== 'undefined' && localStorage.getItem('token')) {
router.push('/dashboard')
}
}, [router])
async function onSubmit(data: FormData) {
setApiError(null)
try {
const res = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/auth/login/`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: data.username,
password: data.password
}),
}
)
if (!res.ok) {
const json = await res.json()
setApiError(json.detail || 'Ошибка входа')
return
}
const { access } = await res.json()
localStorage.setItem('token', access)
router.push('/dashboard')
} catch {
setApiError('Сетевая ошибка')
}
}
return (
<div
className="d-flex align-items-center"
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-login-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">Welcome back!</h4>
</div>
<form onSubmit={handleSubmit(onSubmit)}>
<div className="mb-3">
<input
type="text"
placeholder="Enter Username..."
className={`form-control form-control-user ${
errors.username ? 'is-invalid' : ''
}`}
{...register('username', { required: 'Введите имя пользователя' })}
/>
{errors.username && (
<div className="invalid-feedback">
{errors.username.message}
</div>
)}
</div>
<div className="mb-3">
<input
type="password"
placeholder="Password"
className={`form-control form-control-user ${
errors.password ? 'is-invalid' : ''
}`}
{...register('password', { required: 'Введите пароль' })}
/>
{errors.password && (
<div className="invalid-feedback">
{errors.password.message}
</div>
)}
</div>
<div className="mb-3 form-check small">
<input
type="checkbox"
className="form-check-input"
id="rememberCheck"
{...register('remember')}
/>
<label className="form-check-label" htmlFor="rememberCheck">
Remember Me
</label>
</div>
{apiError && (
<p className="text-danger small mb-3 text-center">
{apiError}
</p>
)}
<button
type="submit"
className="btn btn-primary d-block w-100 btn-user"
style={{ background: '#01703E' }}
disabled={isSubmitting}
>
{isSubmitting ? 'Вхожу...' : 'Login'}
</button>
</form>
<hr />
<div className="text-center">
<Link
href="/auth/forgot-password"
className="small"
style={{ color: '#01703E' }}
>
Forgot Password?
</Link>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,151 @@
// src/app/auth/login/page.tsx
'use client'
import { useState, useEffect } from 'react'
import { useForm } from 'react-hook-form'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
type FormData = { username: string; password: string; remember?: boolean }
export default function LoginPage() {
const router = useRouter()
const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm<FormData>()
const [apiError, setApiError] = useState<string | null>(null)
useEffect(() => {
if (typeof window !== 'undefined' && localStorage.getItem('token')) {
router.push('/dashboard')
}
}, [router])
async function onSubmit(data: FormData) {
setApiError(null)
// если переменная окружения не подхватилась — BASE = ''
const BASE = process.env.NEXT_PUBLIC_API_URL ?? ''
// если BASE пустая строка, получится '/api/auth/login/', иначе 'http://127.0.0.1:8000/api/auth/login/'
const url = `${BASE}/api/auth/login/`.replace(/\/\/api\//, '/api/')
try {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: data.username,
password: data.password,
}),
})
if (!res.ok) {
const json = await res.json()
setApiError(json.detail || 'Ошибка входа')
return
}
const { access } = await res.json()
localStorage.setItem('token', access)
router.push('/dashboard')
} catch {
setApiError('Сетевая ошибка')
}
}
return (
<div
className="d-flex align-items-center"
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-login-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">Welcome back!</h4>
</div>
<form onSubmit={handleSubmit(onSubmit)}>
<div className="mb-3">
<input
type="text"
placeholder="Enter Username..."
className={`form-control form-control-user ${
errors.username ? 'is-invalid' : ''
}`}
{...register('username', { required: 'Введите имя пользователя' })}
/>
{errors.username && (
<div className="invalid-feedback">
{errors.username.message}
</div>
)}
</div>
<div className="mb-3">
<input
type="password"
placeholder="Password"
className={`form-control form-control-user ${
errors.password ? 'is-invalid' : ''
}`}
{...register('password', { required: 'Введите пароль' })}
/>
{errors.password && (
<div className="invalid-feedback">
{errors.password.message}
</div>
)}
</div>
<div className="mb-3 form-check small">
<input
type="checkbox"
className="form-check-input"
id="rememberCheck"
{...register('remember')}
/>
<label className="form-check-label" htmlFor="rememberCheck">
Remember Me
</label>
</div>
{apiError && (
<p className="text-danger small mb-3 text-center">
{apiError}
</p>
)}
<button
type="submit"
className="btn btn-primary d-block w-100 btn-user"
style={{ background: '#01703E' }}
disabled={isSubmitting}
>
{isSubmitting ? 'Вхожу...' : 'Login'}
</button>
</form>
<hr />
<div className="text-center">
<Link
href="/auth/forgot-password"
className="small"
style={{ color: '#01703E' }}
>
Forgot Password?
</Link>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
)
}

View File

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

View File

@@ -0,0 +1,80 @@
// src/components/ProfileCard.tsx
'use client'
import Image from 'next/image'
import { format } from 'date-fns'
import ru from 'date-fns/locale/ru'
interface ProfileCardProps {
avatar: string // URL из поля `avatar`
full_name: string // из поля `full_name`
email: string // из поля `email`
bio?: string // из поля `bio`
last_login: string // ISO-строка из `last_login`
date_joined: string // ISO-строка из `date_joined`
}
export function ProfileCard({
avatar,
full_name,
email,
bio,
last_login,
date_joined,
}: ProfileCardProps) {
// Форматируем дату в «DD.MM.YYYY HH:mm»
const fmt = (iso: string) => {
try {
return format(new Date(iso), 'dd.MM.yyyy HH:mm', { locale: ru })
} catch {
return iso
}
}
return (
<div
className="card shadow rounded mx-auto my-4"
style={{ maxWidth: 600 }}
>
<div className="card-body text-center">
{/* Avatar */}
<div className="mb-3">
<Image
className="rounded-circle border border-white"
src={avatar}
alt="Avatar"
width={150}
height={150}
/>
</div>
{/* Full Name */}
<h3 className="mb-1">
{full_name || '—'}
</h3>
{/* Email */}
<p className="text-muted mb-3">{email}</p>
{/* Bio */}
<p className="mb-4">
{bio && bio.trim()
? bio
: 'Описание профиля отсутствует.'}
</p>
{/* Даты регистрации и последнего входа */}
<div className="d-flex justify-content-around">
<div className="text-start">
<p className="mb-1 small text-uppercase">Зарегистрирован</p>
<p className="mb-0">{fmt(date_joined)}</p>
</div>
<div className="text-end">
<p className="mb-1 small text-uppercase">Последний вход</p>
<p className="mb-0">{fmt(last_login)}</p>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,80 @@
// src/components/ProfileCard.tsx
'use client'
import Image from 'next/image'
import { format } from 'date-fns'
import ru from 'date-fns/locale/ru'
interface ProfileCardProps {
avatar: string // API теперь отдаёт что-то вроде "frontend/assets/img/avatars/3.png"
full_name: string
email: string
bio?: string
last_login: string
date_joined: string
}
export function ProfileCard({
avatar,
full_name,
email,
bio,
last_login,
date_joined,
}: ProfileCardProps) {
// Если API отдаёт относительный путь без /media/, добавляем префикс:
const avatarSrc = avatar.startsWith('http')
? avatar
: `${process.env.NEXT_PUBLIC_API_URL}/media/${avatar}`
const fmt = (iso: string) => {
try {
return format(new Date(iso), 'dd.MM.yyyy HH:mm', { locale: ru })
} catch {
return iso
}
}
return (
<div
className="card shadow rounded mx-auto my-4"
style={{ maxWidth: 600 }}
>
<div className="card-body text-center">
{/* Avatar */}
<div className="mb-3">
<Image
className="rounded-circle border border-white"
src={avatarSrc}
alt="Avatar"
width={150}
height={150}
/>
</div>
{/* Full Name */}
<h3 className="mb-1">{full_name || '—'}</h3>
{/* Email */}
<p className="text-muted mb-3">{email}</p>
{/* Bio */}
<p className="mb-4">
{bio && bio.trim() ? bio : 'Описание профиля отсутствует.'}
</p>
{/* Даты регистрации и последнего входа */}
<div className="d-flex justify-content-around">
<div className="text-start">
<p className="mb-1 small text-uppercase">Зарегистрирован</p>
<p className="mb-0">{fmt(date_joined)}</p>
</div>
<div className="text-end">
<p className="mb-1 small text-uppercase">Последний вход</p>
<p className="mb-0">{fmt(last_login)}</p>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,31 @@
import Link from 'next/link'
export function Footer() {
return (
<footer className="bg-light footer py-4">
<div className="container">
<div className="row">
<div className="col-lg-6 text-center text-lg-start mb-3 mb-lg-0">
<ul className="list-inline mb-2">
<li className="list-inline-item"><Link href="#">About</Link></li>
<li className="list-inline-item"><span></span></li>
<li className="list-inline-item"><Link href="#">Contact</Link></li>
<li className="list-inline-item"><span></span></li>
<li className="list-inline-item"><Link href="#">Terms of Use</Link></li>
<li className="list-inline-item"><span></span></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-2x fa-fw" /></Link></li>
<li className="list-inline-item"><Link href="#"><i className="fa fa-twitter fa-2x fa-fw" /></Link></li>
<li className="list-inline-item"><Link href="#"><i className="fa fa-instagram fa-2x fa-fw" /></Link></li>
</ul>
</div>
</div>
</div>
</footer>
)
}

View File

@@ -0,0 +1,31 @@
import Link from 'next/link'
export function Footer() {
return (
<footer className="bg-light footer py-5 border-top">
<div className="container">
<div className="row">
<div className="col-lg-6 text-center text-lg-start mb-3 mb-lg-0">
<ul className="list-inline mb-2">
<li className="list-inline-item"><Link href="#">About</Link></li>
<li className="list-inline-item"><span></span></li>
<li className="list-inline-item"><Link href="#">Contact</Link></li>
<li className="list-inline-item"><span></span></li>
<li className="list-inline-item"><Link href="#">Terms of Use</Link></li>
<li className="list-inline-item"><span></span></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-2x fa-fw" /></Link></li>
<li className="list-inline-item"><Link href="#"><i className="fa fa-twitter fa-2x fa-fw" /></Link></li>
<li className="list-inline-item"><Link href="#"><i className="fa fa-instagram fa-2x fa-fw" /></Link></li>
</ul>
</div>
</div>
</div>
</footer>
)
}

View File

@@ -0,0 +1,28 @@
'use client'
import Link from 'next/link'
import Image from 'next/image'
export function Header() {
return (
<nav className="navbar navbar-expand bg-light">
<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>
)
}

View File

@@ -0,0 +1,89 @@
// src/components/Header.tsx
'use client'
import { useState, useEffect } from 'react'
import Link from 'next/link'
import Image from 'next/image'
import { useRouter } from 'next/navigation'
interface UserProfile {
username: string
first_name?: string
avatarUrl?: string
}
export function Header() {
const [user, setUser] = useState<UserProfile | null>(null)
const [loading, setLoading] = useState(true)
const router = useRouter()
useEffect(() => {
const token = typeof window !== 'undefined' && 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('unauthenticated')
return res.json()
})
.then((data: UserProfile) => setUser(data))
.catch(() => setUser(null))
.finally(() => setLoading(false))
} else {
setLoading(false)
}
}, [])
const handleLogout = () => {
localStorage.removeItem('token')
router.push('/')
setUser(null)
}
return (
<nav className="navbar navbar-expand bg-light">
<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">
{!loading && user ? (
<div className="d-flex align-items-center ms-auto">
<Link href="/dashboard" className="d-flex align-items-center me-3 text-decoration-none">
<Image
src={user.avatarUrl ?? '/assets/img/avatar-dhg.png'}
alt="avatar"
width={32}
height={32}
className="rounded-circle"
/>
<span className="ms-2">{user.first_name || user.username}</span>
</Link>
<button
className="btn btn-outline-danger"
onClick={handleLogout}
>
Выход
</button>
</div>
) : (
!loading && (
<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>
)
}

View File

@@ -0,0 +1,72 @@
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import Image from 'next/image'
interface UserProfile {
username: string
first_name?: string
avatarUrl?: string
}
export function Header() {
const [user, setUser] = useState<UserProfile | null>(null)
const router = useRouter()
useEffect(() => {
const token = localStorage.getItem('token')
if (!token) {
return
}
fetch('/api/auth/user/', {
headers: { Authorization: `Bearer ${token}` },
})
.then(res => {
if (!res.ok) throw new Error('unauth')
return res.json()
})
.then(setUser)
.catch(() => {
localStorage.removeItem('token')
router.push('/auth/login')
})
}, [router])
const logout = () => {
localStorage.removeItem('token')
router.push('/')
}
return (
<nav className="navbar navbar-expand bg-light fixed-top">
<div className="container">
<Link href="/" className="navbar-brand">
<Image src="/assets/img/CAT.png" width={89} height={89} alt="CatLink"/>
</Link>
<div className="collapse navbar-collapse">
{user ? (
<div className="ms-auto d-flex align-items-center">
<Link href="/dashboard" className="me-3 d-flex align-items-center">
<Image
src={user.avatarUrl || '/assets/img/avatar-dhg.png'}
width={32}
height={32}
className="rounded-circle"
alt="avatar"
/>
<span className="ms-2">{user.first_name || user.username}</span>
</Link>
<button className="btn btn-outline-danger" onClick={logout}>
Выход
</button>
</div>
) : (
<Link href="/auth/login" className="btn btn-primary ms-auto">
Вход
</Link>
)}
</div>
</div>
</nav>
)
}

View File

@@ -0,0 +1,158 @@
// src/app/dashboard/page.tsx
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import Image from 'next/image'
interface LinkItem {
id: number
title: string
url: string
icon?: string
}
interface Group {
id: number
name: string
links: LinkItem[]
}
export default function DashboardPage() {
const router = useRouter()
const [links, setLinks] = useState<LinkItem[]>([])
const [groups, setGroups] = useState<Group[]>([])
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
}
Promise.all([
fetch('/api/links/', {
headers: { Authorization: `Bearer ${token}` },
}).then((res) => {
if (!res.ok) throw new Error('Не удалось загрузить ссылки')
return res.json()
}),
fetch('/api/groups/', {
headers: { Authorization: `Bearer ${token}` },
}).then((res) => {
if (!res.ok) throw new Error('Не удалось загрузить группы')
return res.json()
}),
])
.then(([linksData, groupsData]) => {
setLinks(linksData)
setGroups(groupsData)
setLoading(false)
})
.catch((err) => {
console.error(err)
setError(err.message)
setLoading(false)
})
}, [router])
if (loading) {
return (
<div className="flex items-center justify-center h-[80vh]">
Загрузка...
</div>
)
}
if (error) {
return (
<div className="max-w-2xl mx-auto p-6">
<p className="text-red-600">{error}</p>
</div>
)
}
return (
<div className="max-w-4xl mx-auto py-8">
<div className="flex items-center justify-between mb-6">
<h1 className="text-3xl font-bold">Ваш дашборд</h1>
<Link
href="/dashboard/new-link"
className="btn btn-primary bg-green-600 hover:bg-green-700"
>
Добавить ссылку
</Link>
</div>
{/* Список всех ссылок */}
<section className="mb-10">
<h2 className="text-2xl font-semibold mb-4">Все ссылки</h2>
<ul className="space-y-3">
{links.map((link) => (
<li
key={link.id}
className="flex items-center p-4 border rounded hover:shadow"
>
{link.icon && (
<Image
src={link.icon}
alt={link.title}
width={32}
height={32}
className="mr-4 rounded"
/>
)}
<a
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline flex-1"
>
{link.title}
</a>
<Link
href={`/dashboard/edit-link/${link.id}`}
className="text-sm text-gray-500 hover:underline"
>
Редактировать
</Link>
</li>
))}
</ul>
</section>
{/* Список групп */}
<section>
<h2 className="text-2xl font-semibold mb-4">Группы</h2>
<ul className="space-y-6">
{groups.map((group) => (
<li key={group.id}>
<h3 className="text-xl font-medium mb-2">{group.name}</h3>
{group.links.length ? (
<ul className="ml-4 space-y-2">
{group.links.map((lnk) => (
<li key={lnk.id}>
<a
href={lnk.url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
>
{lnk.title}
</a>
</li>
))}
</ul>
) : (
<p className="ml-4 text-gray-500">Нет ссылок в этой группе</p>
)}
</li>
))}
</ul>
</section>
</div>
)
}

View File

@@ -0,0 +1,150 @@
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import Image from 'next/image'
interface LinkItem { id: number; title: string; url: string; icon?: string }
interface Group { id: number; name: string; links: LinkItem[] }
export default function DashboardPage() {
const router = useRouter()
const [links, setLinks] = useState<LinkItem[]>([])
const [groups, setGroups] = useState<Group[]>([])
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
}
// базовый URL
const API = process.env.NEXT_PUBLIC_API_URL!
Promise.all([
fetch(`${API}/api/links/`, {
headers: { Authorization: `Bearer ${token}` }
}).then(res => {
if (!res.ok) throw new Error('Не удалось загрузить ссылки')
return res.json()
}),
fetch(`${API}/api/groups/`, {
headers: { Authorization: `Bearer ${token}` }
}).then(res => {
if (!res.ok) throw new Error('Не удалось загрузить группы')
return res.json()
}),
])
.then(([linksData, groupsData]) => {
setLinks(linksData)
setGroups(groupsData)
setLoading(false)
})
.catch(err => {
console.error(err)
setError(err.message)
setLoading(false)
})
}, [router])
if (loading) {
return (
<div className="flex items-center justify-center h-[80vh]">
Загрузка...
</div>
)
}
if (error) {
return (
<div className="max-w-2xl mx-auto p-6">
<p className="text-red-600">{error}</p>
</div>
)
}
return (
<div className="max-w-4xl mx-auto py-8">
<div className="flex items-center justify-between mb-6">
<h1 className="text-3xl font-bold">Ваш дашборд</h1>
<Link
href="/dashboard/new-link"
className="btn btn-primary bg-green-600 hover:bg-green-700"
>
Добавить ссылку
</Link>
</div>
{/* Список всех ссылок */}
<section className="mb-10">
<h2 className="text-2xl font-semibold mb-4">Все ссылки</h2>
<ul className="space-y-3">
{links.map((link) => (
<li
key={link.id}
className="flex items-center p-4 border rounded hover:shadow"
>
{link.icon && (
<Image
src={link.icon}
alt={link.title}
width={32}
height={32}
className="mr-4 rounded"
/>
)}
<a
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline flex-1"
>
{link.title}
</a>
<Link
href={`/dashboard/edit-link/${link.id}`}
className="text-sm text-gray-500 hover:underline"
>
Редактировать
</Link>
</li>
))}
</ul>
</section>
{/* Список групп */}
<section>
<h2 className="text-2xl font-semibold mb-4">Группы</h2>
<ul className="space-y-6">
{groups.map((group) => (
<li key={group.id}>
<h3 className="text-xl font-medium mb-2">{group.name}</h3>
{group.links.length ? (
<ul className="ml-4 space-y-2">
{group.links.map((lnk) => (
<li key={lnk.id}>
<a
href={lnk.url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
>
{lnk.title}
</a>
</li>
))}
</ul>
) : (
<p className="ml-4 text-gray-500">Нет ссылок в этой группе</p>
)}
</li>
))}
</ul>
</section>
</div>
)
}

View File

@@ -0,0 +1,194 @@
// src/app/dashboard/page.tsx
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import Image from 'next/image'
interface User {
id: number
username: string
email: string
first_name?: string
last_name?: string
}
interface LinkItem {
id: number
title: string
url: string
icon?: string
}
interface Group {
id: number
name: string
links: LinkItem[]
}
export default function DashboardPage() {
const router = useRouter()
const [user, setUser] = useState<User | null>(null)
const [links, setLinks] = useState<LinkItem[]>([])
const [groups, setGroups] = useState<Group[]>([])
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!
// Одновременно фетчим профиль, ссылки и группы
Promise.all([
fetch(`${API}/api/auth/user/`, {
headers: { Authorization: `Bearer ${token}` }
}).then(res => {
if (!res.ok) throw new Error('Не удалось получить профиль')
return res.json() as Promise<User>
}),
fetch(`${API}/api/links/`, {
headers: { Authorization: `Bearer ${token}` }
}).then(res => {
if (!res.ok) throw new Error('Не удалось загрузить ссылки')
return res.json() as Promise<LinkItem[]>
}),
fetch(`${API}/api/groups/`, {
headers: { Authorization: `Bearer ${token}` }
}).then(res => {
if (!res.ok) throw new Error('Не удалось загрузить группы')
return res.json() as Promise<Group[]>
}),
])
.then(([userData, linksData, groupsData]) => {
setUser(userData)
setLinks(linksData)
setGroups(groupsData)
setLoading(false)
})
.catch(err => {
console.error(err)
setError(err.message)
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">{error}</div>
}
return (
<div className="max-w-4xl mx-auto py-8 space-y-8">
{/* Блок профиля */}
{user && (
<section className="p-4 bg-white rounded shadow">
<h1 className="text-2xl font-bold">
Привет, {user.first_name || user.username}!
</h1>
<p className="text-gray-600">Email: {user.email}</p>
{user.last_name && (
<p className="text-gray-600">
Имя: {user.first_name} {user.last_name}
</p>
)}
</section>
)}
{/* Все ссылки */}
<section className="bg-white rounded shadow p-4">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-semibold">Все ссылки</h2>
<Link
href="/dashboard/new-link"
className="btn btn-sm btn-success"
>
Добавить ссылку
</Link>
</div>
{links.length ? (
<ul className="space-y-2">
{links.map(link => (
<li key={link.id} className="flex items-center">
{link.icon && (
<Image
src={link.icon}
alt={link.title}
width={32}
height={32}
className="mr-3 rounded"
/>
)}
<a
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline flex-1"
>
{link.title}
</a>
<Link
href={`/dashboard/edit-link/${link.id}`}
className="text-sm text-gray-500 hover:underline"
>
Ред.
</Link>
</li>
))}
</ul>
) : (
<p className="text-gray-500">У вас ещё нет сохранённых ссылок.</p>
)}
</section>
{/* Группы ссылок */}
<section className="bg-white rounded shadow p-4">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-semibold">Группы ссылок</h2>
<Link
href="/dashboard/new-group"
className="btn btn-sm btn-success"
>
Новая группа
</Link>
</div>
{groups.length ? (
<ul className="space-y-4">
{groups.map(group => (
<li key={group.id}>
<h3 className="font-medium">{group.name}</h3>
{group.links.length ? (
<ul className="ml-4 space-y-1">
{group.links.map(lnk => (
<li key={lnk.id}>
<a
href={lnk.url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
>
{lnk.title}
</a>
</li>
))}
</ul>
) : (
<p className="text-gray-500 ml-4">Пусто</p>
)}
</li>
))}
</ul>
) : (
<p className="text-gray-500">Нет групп.</p>
)}
</section>
</div>
)
}

View File

@@ -0,0 +1,207 @@
// src/app/dashboard/page.tsx
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import Image from 'next/image'
interface User {
id: number
username: string
email: string
first_name?: string
last_name?: string
}
interface LinkItem {
id: number
title: string
url: string
icon?: string
}
interface Group {
id: number
name: string
links?: LinkItem[] // может прийти undefined, поэтому опционально
}
export default function DashboardPage() {
const router = useRouter()
const [user, setUser] = useState<User | null>(null)
const [links, setLinks] = useState<LinkItem[]>([])
const [groups, setGroups] = useState<Group[]>([])
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 ?? ''
Promise.all([
// Профиль
fetch(`${API}/api/auth/user/`, {
headers: { Authorization: `Bearer ${token}` },
}).then(res => {
if (!res.ok) throw new Error('Не удалось получить профиль')
return res.json() as Promise<User>
}),
// Ссылки
fetch(`${API}/api/links/`, {
headers: { Authorization: `Bearer ${token}` },
}).then(res => {
if (!res.ok) throw new Error('Не удалось загрузить ссылки')
return res.json() as Promise<LinkItem[]>
}),
// Группы
fetch(`${API}/api/groups/`, {
headers: { Authorization: `Bearer ${token}` },
}).then(res => {
if (!res.ok) throw new Error('Не удалось загрузить группы')
return res.json() as Promise<Group[]>
}),
])
.then(([userData, linksData, groupsData]) => {
setUser(userData)
setLinks(linksData)
setGroups(groupsData)
setLoading(false)
})
.catch(err => {
console.error(err)
setError(err.message)
setLoading(false)
})
}, [router])
if (loading) {
return (
<div className="flex items-center justify-center h-screen">
Загрузка...
</div>
)
}
if (error) {
return (
<div className="max-w-2xl mx-auto p-6">
<p className="text-red-600">{error}</p>
</div>
)
}
return (
<div className="max-w-4xl mx-auto py-8 space-y-8">
{/* Профиль пользователя */}
{user && (
<section className="p-6 bg-white rounded shadow">
<h1 className="text-2xl font-bold">
Привет, {user.first_name || user.username}!
</h1>
<p className="text-gray-600">Email: {user.email}</p>
{user.last_name && (
<p className="text-gray-600">
Имя: {user.first_name} {user.last_name}
</p>
)}
</section>
)}
{/* Ссылки */}
<section className="bg-white rounded shadow p-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-semibold">Все ссылки</h2>
<Link
href="/dashboard/new-link"
className="btn btn-sm btn-success"
>
Добавить ссылку
</Link>
</div>
{links.length > 0 ? (
<ul className="space-y-3">
{links.map(link => (
<li
key={link.id}
className="flex items-center p-3 border rounded hover:shadow"
>
{link.icon && (
<Image
src={link.icon}
alt={link.title}
width={32}
height={32}
className="mr-4 rounded"
/>
)}
<a
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline flex-1"
>
{link.title}
</a>
<Link
href={`/dashboard/edit-link/${link.id}`}
className="text-sm text-gray-500 hover:underline"
>
Ред.
</Link>
</li>
))}
</ul>
) : (
<p className="text-gray-500">У вас ещё нет ссылок.</p>
)}
</section>
{/* Группы ссылок */}
<section className="bg-white rounded shadow p-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-semibold">Группы ссылок</h2>
<Link
href="/dashboard/new-group"
className="btn btn-sm btn-success"
>
Новая группа
</Link>
</div>
{(groups?.length ?? 0) > 0 ? (
<ul className="space-y-4">
{groups.map(group => (
<li key={group.id}>
<h3 className="font-medium">{group.name}</h3>
{((group.links ?? []).length > 0) ? (
<ul className="ml-4 space-y-1">
{(group.links ?? []).map(lnk => (
<li key={lnk.id}>
<a
href={lnk.url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
>
{lnk.title}
</a>
</li>
))}
</ul>
) : (
<p className="text-gray-500 ml-4">Нет ссылок в группе</p>
)}
</li>
))}
</ul>
) : (
<p className="text-gray-500">У вас ещё нет групп.</p>
)}
</section>
</div>
)
}

View File

@@ -0,0 +1,271 @@
// src/app/dashboard/page.tsx
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import Image from 'next/image'
interface User {
id: number
username: string
email: string
first_name?: string
last_name?: string
bio?: string
avatarUrl?: string
coverUrl?: string
friendsCount?: number
shareCount?: number
}
interface LinkItem {
id: number
title: string
url: string
}
interface Group {
id: number
name: string
icon?: string
links?: LinkItem[]
}
export default function DashboardPage() {
const router = useRouter()
const [user, setUser] = useState<User | 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 ?? ''
Promise.all([
fetch(`${API}/api/auth/user/`, {
headers: { Authorization: `Bearer ${token}` },
}).then(res => {
if (!res.ok) throw new Error('Не удалось получить профиль')
return res.json() as Promise<User>
}),
fetch(`${API}/api/groups/`, {
headers: { Authorization: `Bearer ${token}` },
}).then(res => {
if (!res.ok) throw new Error('Не удалось загрузить группы')
return res.json() as Promise<Group[]>
}),
])
.then(([userData, groupsData]) => {
setUser(userData)
setGroups(groupsData)
setLoading(false)
})
.catch(err => {
console.error(err)
setError(err.message)
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>
)
}
return (
<div>
{/* Profile Card */}
<div
className="text-center profile-card"
style={{ margin: 15, backgroundColor: '#ffffff' }}
>
<div
className="profile-card-img"
style={{
backgroundImage: `url('${user?.coverUrl ?? '/assets/img/iceland.jpg'}')`,
height: 150,
backgroundSize: 'cover',
}}
/>
<div>
<Image
className="rounded-circle"
src={user?.avatarUrl ?? '/assets/img/avatar-dhg.png'}
alt="Avatar"
width={150}
height={150}
style={{ marginTop: -70 }}
/>
<h3>{user?.first_name || user?.username}</h3>
<p style={{ padding: '5px 20px 0 20px' }}>
{user?.bio ||
'Профиль пользователя не заполнен.'}
</p>
</div>
<div
className="row"
style={{ padding: '20px 0 10px 0' }}
>
<div className="col-md-6">
<p className="text-end text-nowrap">Friends</p>
<p className="text-end">
<strong>{user?.friendsCount ?? 0}</strong>
</p>
</div>
<div className="col-md-6">
<p>Shares</p>
<p>
<strong>{user?.shareCount ?? 0}</strong>
</p>
</div>
</div>
</div>
{/* Groups Section */}
<section>
<div className="container">
<div className="row justify-content-center">
<div className="col-xl-10 col-xxl-9">
<div className="card shadow">
<div className="card-header d-flex flex-wrap justify-content-center align-items-center justify-content-sm-between gap-3">
<h5 className="display-6 text-nowrap text-capitalize mb-0">
Группы ссылок
</h5>
<div className="input-group input-group-sm w-auto">
<input
className="form-control form-control-sm"
type="text"
placeholder="Поиск..."
/>
<button
className="btn btn-outline-primary btn-sm"
type="button"
>
🔍
</button>
</div>
</div>
<div className="card-body p-0">
<div className="table-responsive">
<table className="table table-striped table-hover mb-0">
<thead>
<tr>
<th>Название группы</th>
<th>Ссылок</th>
<th>Иконка</th>
<th className="text-center">Действия</th>
</tr>
</thead>
<tbody>
{groups.map(group => (
<Fragment key={group.id}>
<tr
style={{ cursor: 'pointer' }}
onClick={() =>
setExpandedGroup(
expandedGroup === group.id
? null
: group.id
)
}
>
<td className="text-truncate" style={{ maxWidth: 200 }}>
{group.name}
</td>
<td className="text-truncate" style={{ maxWidth: 200 }}>
{(group.links ?? []).length}
</td>
<td>{group.icon || '-'}</td>
<td className="text-center">
<button className="btn btn-sm btn-link p-0 me-2">
👁
</button>
<Link
href={`/dashboard/edit-group/${group.id}`}
className="btn btn-sm btn-link p-0 me-2"
>
</Link>
<button className="btn btn-sm btn-link p-0 text-danger">
🗑
</button>
</td>
</tr>
{expandedGroup === group.id && (
<tr>
<td colSpan={4} className="bg-light p-3">
<ul className="list-unstyled mb-0">
{(group.links ?? []).map(link => (
<li key={link.id} className="mb-2">
<a
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="text-primary"
>
{link.title}
</a>
</li>
))}
</ul>
</td>
</tr>
)}
</Fragment>
))}
</tbody>
</table>
</div>
</div>
<div className="card-footer">
<nav>
<ul className="pagination pagination-sm mb-0 justify-content-center">
<li className="page-item">
<Link className="page-link" href="?page=prev">
«
</Link>
</li>
<li className="page-item">
<Link className="page-link" href="?page=1">
1
</Link>
</li>
<li className="page-item">
<Link className="page-link" href="?page=2">
2
</Link>
</li>
<li className="page-item">
<Link className="page-link" href="?page=3">
3
</Link>
</li>
<li className="page-item">
<Link className="page-link" href="?page=next">
»
</Link>
</li>
</ul>
</nav>
</div>
</div>
</div>
</div>
</div>
</section>
</div>
)
}

View File

@@ -0,0 +1,248 @@
// 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 Image from 'next/image'
interface User {
id: number
username: string
email: string
first_name?: string
last_name?: string
bio?: string
avatarUrl?: string
coverUrl?: string
friendsCount?: number
shareCount?: number
}
interface LinkItem {
id: number
title: string
url: string
}
interface Group {
id: number
name: string
icon?: string
links?: LinkItem[]
}
export default function DashboardPage() {
const router = useRouter()
const [user, setUser] = useState<User | 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 ?? ''
Promise.all([
fetch(`${API}/api/auth/user/`, {
headers: { Authorization: `Bearer ${token}` },
}).then(res => {
if (!res.ok) throw new Error('Не удалось получить профиль')
return res.json() as Promise<User>
}),
fetch(`${API}/api/groups/`, {
headers: { Authorization: `Bearer ${token}` },
}).then(res => {
if (!res.ok) throw new Error('Не удалось загрузить группы')
return res.json() as Promise<Group[]>
}),
])
.then(([userData, groupsData]) => {
setUser(userData)
setGroups(groupsData)
setLoading(false)
})
.catch(err => {
console.error(err)
setError(err.message)
setLoading(false)
})
}, [router])
if (loading) {
return (
<div className="flex items-center justify-center h-screen">
Загрузка...
</div>
)
}
if (error) {
return (
<div className="max-w-xl mx-auto p-6">
<p className="text-red-600">{error}</p>
</div>
)
}
return (
<div>
{/* Profile Card */}
<div
className="text-center profile-card mx-4 my-6 bg-white"
style={{ maxWidth: 600, margin: 'auto' }}
>
<div
className="profile-card-img"
style={{
backgroundImage: `url('${user?.coverUrl ?? '/assets/img/iceland.jpg'}')`,
height: 150,
backgroundSize: 'cover',
}}
/>
<div>
<Image
className="rounded-circle"
src={user?.avatarUrl ?? '/assets/img/avatar-dhg.png'}
alt="Avatar"
width={150}
height={150}
style={{ marginTop: -70 }}
/>
<h3 className="mt-2">{user?.first_name || user?.username}</h3>
<p className="px-4">
{user?.bio || 'Профиль пользователя не заполнен.'}
</p>
</div>
<div
className="row"
style={{ padding: '20px 0 10px 0', maxWidth: 600, margin: 'auto' }}
>
<div className="col-6 text-end">
<p className="mb-0">Friends</p>
<p><strong>{user?.friendsCount ?? 0}</strong></p>
</div>
<div className="col-6 text-start">
<p className="mb-0">Shares</p>
<p><strong>{user?.shareCount ?? 0}</strong></p>
</div>
</div>
</div>
{/* Groups Section */}
<section className="mb-8">
<div className="container">
<div className="row justify-content-center">
<div className="col-xl-10 col-xxl-9">
<div className="card shadow">
<div className="card-header d-flex flex-wrap justify-content-between align-items-center gap-3">
<h5 className="display-6 mb-0">Группы ссылок</h5>
<div className="input-group input-group-sm" style={{ maxWidth: 200 }}>
<input
className="form-control form-control-sm"
type="search"
placeholder="Поиск..."
/>
<button className="btn btn-outline-primary btn-sm" type="button">
🔍
</button>
</div>
</div>
<div className="card-body p-0">
<div className="table-responsive">
<table className="table table-striped table-hover mb-0">
<thead>
<tr>
<th>Название</th>
<th>Ссылок</th>
<th>Иконка</th>
<th className="text-center">Действия</th>
</tr>
</thead>
<tbody>
{groups.map(group => (
<Fragment key={group.id}>
<tr
style={{ cursor: 'pointer' }}
onClick={() =>
setExpandedGroup(
expandedGroup === group.id ? null : group.id
)
}
>
<td className="text-truncate" style={{ maxWidth: 200 }}>
{group.name}
</td>
<td>
{(group.links ?? []).length}
</td>
<td>{group.icon ?? '-'}</td>
<td className="text-center">
<button className="btn btn-sm btn-link p-0 me-2">
👁
</button>
<Link
href={`/dashboard/edit-group/${group.id}`}
className="btn btn-sm btn-link p-0 me-2"
>
</Link>
<button className="btn btn-sm btn-link p-0 text-danger">
🗑
</button>
</td>
</tr>
{expandedGroup === group.id && (
<tr>
<td colSpan={4} className="bg-light p-3">
<ul className="list-unstyled mb-0">
{(group.links ?? []).map(link => (
<li key={link.id} className="mb-2">
<a
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="text-primary"
>
{link.title}
</a>
</li>
))}
</ul>
</td>
</tr>
)}
</Fragment>
))}
</tbody>
</table>
</div>
</div>
<div className="card-footer">
<nav>
<ul className="pagination pagination-sm mb-0 justify-content-center">
<li className="page-item"><Link href="?page=prev" className="page-link">«</Link></li>
<li className="page-item"><Link href="?page=1" className="page-link">1</Link></li>
<li className="page-item"><Link href="?page=2" className="page-link">2</Link></li>
<li className="page-item"><Link href="?page=3" className="page-link">3</Link></li>
<li className="page-item"><Link href="?page=next" className="page-link">»</Link></li>
</ul>
</nav>
</div>
</div>
</div>
</div>
</div>
</section>
</div>
)
}

View File

@@ -0,0 +1,248 @@
// 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 Image from 'next/image'
interface User {
id: number
username: string
email: string
first_name?: string
last_name?: string
bio?: string
avatarUrl?: string
coverUrl?: string
friendsCount?: number
shareCount?: number
}
interface LinkItem {
id: number
title: string
url: string
}
interface Group {
id: number
name: string
icon?: string
links?: LinkItem[]
}
export default function DashboardPage() {
const router = useRouter()
const [user, setUser] = useState<User | 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 ?? ''
Promise.all([
fetch(`${API}/api/auth/user/`, {
headers: { Authorization: `Bearer ${token}` },
}).then(res => {
if (!res.ok) throw new Error('Не удалось получить профиль')
return res.json() as Promise<User>
}),
fetch(`${API}/api/groups/`, {
headers: { Authorization: `Bearer ${token}` },
}).then(res => {
if (!res.ok) throw new Error('Не удалось загрузить группы')
return res.json() as Promise<Group[]>
}),
])
.then(([userData, groupsData]) => {
setUser(userData)
setGroups(groupsData)
setLoading(false)
})
.catch(err => {
console.error(err)
setError(err.message)
setLoading(false)
})
}, [router])
if (loading) {
return (
<div className="flex items-center justify-center h-screen">
Загрузка...
</div>
)
}
if (error) {
return (
<div className="max-w-xl mx-auto p-6">
<p className="text-red-600">{error}</p>
</div>
)
}
return (
<div>
{/* Profile Card */}
<div
className="text-center profile-card mx-4 my-6 bg-white"
style={{ maxWidth: 600, margin: 'auto' }}
>
<div
className="profile-card-img"
style={{
backgroundImage: `url('${user?.coverUrl ?? '/assets/img/iceland.jpg'}')`,
height: 150,
backgroundSize: 'cover',
}}
/>
<div>
<Image
className="rounded-circle"
src={user?.avatarUrl ?? '/assets/img/avatar-dhg.png'}
alt="Avatar"
width={150}
height={150}
style={{ marginTop: -70 }}
/>
<h3 className="mt-2">{user?.first_name || user?.username}</h3>
<p className="px-4">
{user?.bio || 'Профиль пользователя не заполнен.'}
</p>
</div>
<div
className="row"
style={{ padding: '20px 0 10px 0', maxWidth: 600, margin: 'auto' }}
>
<div className="col-6 text-end">
<p className="mb-0">Friends</p>
<p><strong>{user?.friendsCount ?? 0}</strong></p>
</div>
<div className="col-6 text-start">
<p className="mb-0">Shares</p>
<p><strong>{user?.shareCount ?? 0}</strong></p>
</div>
</div>
</div>
{/* Groups Section */}
<section className="mb-8">
<div className="container">
<div className="row justify-content-center">
<div className="col-xl-10 col-xxl-9">
<div className="card shadow">
<div className="card-header d-flex flex-wrap justify-content-between align-items-center gap-3">
<h5 className="display-6 mb-0">Группы ссылок</h5>
<div className="input-group input-group-sm" style={{ maxWidth: 200 }}>
<input
className="form-control form-control-sm"
type="search"
placeholder="Поиск..."
/>
<button className="btn btn-outline-primary btn-sm" type="button">
🔍
</button>
</div>
</div>
<div className="card-body p-0">
<div className="table-responsive">
<table className="table table-striped table-hover mb-0">
<thead>
<tr>
<th>Название</th>
<th>Ссылок</th>
<th>Иконка</th>
<th className="text-center">Действия</th>
</tr>
</thead>
<tbody>
{groups.map(group => (
<Fragment key={group.id}>
<tr
style={{ cursor: 'pointer' }}
onClick={() =>
setExpandedGroup(
expandedGroup === group.id ? null : group.id
)
}
>
<td className="text-truncate" style={{ maxWidth: 200 }}>
{group.name}
</td>
<td>
{(group.links ?? []).length}
</td>
<td>{group.icon ?? '-'}</td>
<td className="text-center">
<button className="btn btn-sm btn-link p-0 me-2">
👁
</button>
<Link
href={`/dashboard/edit-group/${group.id}`}
className="btn btn-sm btn-link p-0 me-2"
>
</Link>
<button className="btn btn-sm btn-link p-0 text-danger">
🗑
</button>
</td>
</tr>
{expandedGroup === group.id && (
<tr>
<td colSpan={4} className="bg-light p-3">
<ul className="list-unstyled mb-0">
{(group.links ?? []).map(link => (
<li key={link.id} className="mb-2">
<a
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="text-primary"
>
{link.title}
</a>
</li>
))}
</ul>
</td>
</tr>
)}
</Fragment>
))}
</tbody>
</table>
</div>
</div>
<div className="card-footer">
<nav>
<ul className="pagination pagination-sm mb-0 justify-content-center">
<li className="page-item"><Link href="?page=prev" className="page-link">«</Link></li>
<li className="page-item"><Link href="?page=1" className="page-link">1</Link></li>
<li className="page-item"><Link href="?page=2" className="page-link">2</Link></li>
<li className="page-item"><Link href="?page=3" className="page-link">3</Link></li>
<li className="page-item"><Link href="?page=next" className="page-link">»</Link></li>
</ul>
</nav>
</div>
</div>
</div>
</div>
</div>
</section>
</div>
)
}

View File

@@ -0,0 +1,213 @@
// 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 Image from 'next/image'
interface User {
id: number
username: string
email: string
first_name?: string
last_name?: string
bio?: string
avatarUrl?: string
coverUrl?: string
friendsCount?: number
shareCount?: number
}
interface LinkItem {
id: number
title: string
url: string
}
interface Group {
id: number
name: string
icon?: string
links?: LinkItem[]
}
export default function DashboardPage() {
const router = useRouter()
const [user, setUser] = useState<User | 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 [avatarLoading, setAvatarLoading] = useState(true)
useEffect(() => {
const token = localStorage.getItem('token')
if (!token) {
router.push('/auth/login')
return
}
const API = process.env.NEXT_PUBLIC_API_URL ?? ''
Promise.all([
fetch(`${API}/api/auth/user/`, {
headers: { Authorization: `Bearer ${token}` },
}).then(res => {
if (!res.ok) throw new Error('Не удалось получить профиль')
return res.json() as Promise<User>
}),
fetch(`${API}/api/groups/`, {
headers: { Authorization: `Bearer ${token}` },
}).then(res => {
if (!res.ok) throw new Error('Не удалось загрузить группы')
return res.json() as Promise<Group[]>
}),
])
.then(([userData, groupsData]) => {
setUser(userData)
setGroups(groupsData)
setLoading(false)
})
.catch(err => {
console.error(err)
setError(err.message)
setLoading(false)
})
}, [router])
if (loading) {
return (
<div className="flex items-center justify-center h-screen">Загрузка...</div>
)
}
if (error) {
return (
<div className="max-w-xl mx-auto p-6">
<p className="text-red-600">{error}</p>
</div>
)
}
return (
<div className="pb-8">
{/* Центрированная карточка профиля */}
<div className="profile-card bg-white shadow rounded mx-auto mt-8" style={{ maxWidth: 600 }}>
{/* Обложка */}
<div
className="rounded-top"
style={{
backgroundImage: `url('${user?.coverUrl ?? '/assets/img/iceland.jpg'}')`,
height: 150,
backgroundSize: 'cover',
backgroundPosition: 'center',
}}
/>
{/* Аватар и данные */}
<div className="text-center position-relative" style={{ marginTop: -75 }}>
{/* Индикатор загрузки аватара */}
{avatarLoading && (
<div
className="spinner-border text-primary position-absolute"
style={{ top: 0, left: '50%', transform: 'translateX(-50%)' }}
role="status"
>
<span className="visually-hidden">Loading...</span>
</div>
)}
<Image
className="rounded-circle border border-white"
src={user?.avatarUrl ?? '/assets/img/avatar-dhg.png'}
alt="Avatar"
width={150}
height={150}
onLoadingComplete={() => setAvatarLoading(false)}
/>
</div>
<div className="px-4 pb-4 text-center">
<h3 className="mt-3">{user?.first_name || user?.username}</h3>
<p className="text-muted mb-2">{user?.email}</p>
<p className="mb-3">{user?.bio ?? 'Описание профиля отсутствует.'}</p>
<div className="d-flex justify-content-around">
<div>
<p className="mb-0 text-uppercase small">Friends</p>
<p className="fw-bold">{user?.friendsCount ?? 0}</p>
</div>
<div>
<p className="mb-0 text-uppercase small">Shares</p>
<p className="fw-bold">{user?.shareCount ?? 0}</p>
</div>
</div>
</div>
</div>
{/* Секция групп (аккордеон) */}
<section className="mt-5">
<div className="container">
<div className="row justify-content-center">
<div className="col-xl-10 col-xxl-9">
<div className="card shadow">
<div className="card-header d-flex justify-content-between align-items-center">
<h5 className="mb-0">Группы ссылок</h5>
<div className="input-group input-group-sm" style={{ maxWidth: 200 }}>
<input className="form-control" type="search" placeholder="Поиск..." />
<button className="btn btn-outline-primary" type="button">🔍</button>
</div>
</div>
<div className="list-group list-group-flush">
{groups.map(group => (
<Fragment key={group.id}>
<button
className="list-group-item list-group-item-action d-flex justify-content-between align-items-center"
onClick={() => setExpandedGroup(expandedGroup === group.id ? null : group.id)}
>
<span>{group.name}</span>
<span className="badge bg-secondary rounded-pill">
{(group.links ?? []).length}
</span>
</button>
{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="mb-2">
<a href={link.url} target="_blank" rel="noopener noreferrer">
{link.title}
</a>
</li>
))}
</ul>
</div>
)}
</Fragment>
))}
</div>
<div className="card-footer">
<nav>
<ul className="pagination pagination-sm justify-content-center mb-0">
<li className="page-item">
<Link href="?page=prev" className="page-link">«</Link>
</li>
<li className="page-item">
<Link href="?page=1" className="page-link">1</Link>
</li>
<li className="page-item">
<Link href="?page=2" className="page-link">2</Link>
</li>
<li className="page-item">
<Link href="?page=3" className="page-link">3</Link>
</li>
<li className="page-item">
<Link href="?page=next" className="page-link">»</Link>
</li>
</ul>
</nav>
</div>
</div>
</div>
</div>
</div>
</section>
</div>
)
}

View File

@@ -0,0 +1,276 @@
// 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 Image from 'next/image'
interface User {
id: number
username: string
email: string
first_name?: string
last_name?: string
bio?: string
avatarUrl?: string
coverUrl?: string
friendsCount?: number
shareCount?: number
}
interface LinkItem {
id: number
title: string
url: string
}
interface Group {
id: number
name: string
icon?: string
links?: LinkItem[]
}
export default function DashboardPage() {
const router = useRouter()
const [user, setUser] = useState<User | 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 ?? ''
Promise.all([
fetch(`${API}/api/auth/user/`, {
headers: { Authorization: `Bearer ${token}` },
}).then(res => {
if (!res.ok) throw new Error('Не удалось получить профиль')
return res.json() as Promise<User>
}),
fetch(`${API}/api/groups/`, {
headers: { Authorization: `Bearer ${token}` },
}).then(res => {
if (!res.ok) throw new Error('Не удалось загрузить группы')
return res.json() as Promise<Group[]>
}),
])
.then(([userData, groupsData]) => {
setUser(userData)
setGroups(groupsData)
setLoading(false)
})
.catch(err => {
console.error(err)
setError(err.message)
setLoading(false)
})
}, [router])
// CRUD handlers (stubbed)
const handleAddLink = (groupId: number) => {
router.push(`/dashboard/new-link?group=${groupId}`)
}
const handleEditGroup = (groupId: number) => {
router.push(`/dashboard/edit-group/${groupId}`)
}
const handleDeleteGroup = (groupId: number) => {
// TODO: вызвать DELETE /api/groups/{groupId}/ и обновить state
alert(`Delete group ${groupId}`)
}
const handleEditLink = (linkId: number) => {
router.push(`/dashboard/edit-link/${linkId}`)
}
const handleDeleteLink = (linkId: number) => {
// TODO: вызвать DELETE /api/links/{linkId}/ и обновить state
alert(`Delete link ${linkId}`)
}
if (loading) {
return (
<div className="flex items-center justify-center h-screen">
Загрузка...
</div>
)
}
if (error) {
return (
<div className="max-w-xl mx-auto p-6">
<p className="text-red-600">{error}</p>
</div>
)
}
return (
<div className="pb-8">
{/* Центрированная карточка профиля */}
<div
className="bg-white shadow rounded mx-auto mt-8"
style={{ maxWidth: 600 }}
>
<div
className="rounded-top"
style={{
backgroundImage: `url('${user?.coverUrl ?? '/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={user?.avatarUrl ?? '/assets/img/avatar-dhg.png'}
alt="Avatar"
width={150}
height={150}
/>
</div>
<div className="px-4 pb-4 text-center">
<h3 className="mt-3">{user?.first_name || user?.username}</h3>
<p className="text-muted mb-2">{user?.email}</p>
<p className="mb-3">{user?.bio ?? 'Описание профиля отсутствует.'}</p>
<div className="d-flex justify-content-around">
<div>
<p className="mb-0 text-uppercase small">Friends</p>
<p className="fw-bold">{user?.friendsCount ?? 0}</p>
</div>
<div>
<p className="mb-0 text-uppercase small">Shares</p>
<p className="fw-bold">{user?.shareCount ?? 0}</p>
</div>
</div>
</div>
</div>
{/* Аккордеон групп */}
<section className="mt-5">
<div className="container">
<div className="row justify-content-center">
<div className="col-xl-10 col-xxl-9">
<div className="card shadow">
<div className="card-header d-flex justify-content-between align-items-center">
<h5 className="mb-0">Группы ссылок</h5>
<div className="input-group input-group-sm" style={{ maxWidth: 200 }}>
<input className="form-control" type="search" placeholder="Поиск..." />
<button className="btn btn-outline-primary" type="button">🔍</button>
</div>
</div>
<div className="list-group list-group-flush">
{groups.map(group => (
<Fragment key={group.id}>
<div className="list-group-item">
<div className="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)
}
>
<span>{group.name}</span>
<span className="badge bg-secondary rounded-pill ms-2">
{(group.links ?? []).length}
</span>
</div>
<div>
<button
className="btn btn-sm btn-link p-0 me-2"
title="Добавить ссылку"
onClick={() => handleAddLink(group.id)}
>
<i className="bi bi-plus-circle" />
</button>
<button
className="btn btn-sm btn-link p-0 me-2"
title="Переименовать группу"
onClick={() => handleEditGroup(group.id)}
>
<i className="bi bi-pencil-square" />
</button>
<button
className="btn btn-sm btn-link p-0 text-danger"
title="Удалить группу"
onClick={() => handleDeleteGroup(group.id)}
>
<i className="bi bi-trash" />
</button>
</div>
</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>
<button
className="btn btn-sm btn-link p-0 me-2"
title="Переименовать ссылку"
onClick={() => handleEditLink(link.id)}
>
<i className="bi bi-pencil-fill" />
</button>
<button
className="btn btn-sm btn-link p-0 text-danger"
title="Удалить ссылку"
onClick={() => handleDeleteLink(link.id)}
>
<i className="bi bi-trash-fill" />
</button>
</div>
</li>
))}
</ul>
</div>
)}
</Fragment>
))}
</div>
<div className="card-footer">
<nav>
<ul className="pagination pagination-sm justify-content-center mb-0">
<li className="page-item">
<Link href="?page=prev" className="page-link">«</Link>
</li>
<li className="page-item">
<Link href="?page=1" className="page-link">1</Link>
</li>
<li className="page-item">
<Link href="?page=2" className="page-link">2</Link>
</li>
<li className="page-item">
<Link href="?page=3" className="page-link">3</Link>
</li>
<li className="page-item">
<Link href="?page=next" className="page-link">»</Link>
</li>
</ul>
</nav>
</div>
</div>
</div>
</div>
</div>
</section>
</div>
)
}

View File

@@ -0,0 +1,276 @@
// 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 Image from 'next/image'
interface User {
id: number
username: string
email: string
first_name?: string
last_name?: string
bio?: string
avatarUrl?: string
coverUrl?: string
friendsCount?: number
shareCount?: number
}
interface LinkItem {
id: number
title: string
url: string
}
interface Group {
id: number
name: string
icon?: string
links?: LinkItem[]
}
export default function DashboardPage() {
const router = useRouter()
const [user, setUser] = useState<User | 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 ?? ''
Promise.all([
fetch(`${API}/api/auth/user/`, {
headers: { Authorization: `Bearer ${token}` },
}).then(res => {
if (!res.ok) throw new Error('Не удалось получить профиль')
return res.json() as Promise<User>
}),
fetch(`${API}/api/groups/`, {
headers: { Authorization: `Bearer ${token}` },
}).then(res => {
if (!res.ok) throw new Error('Не удалось загрузить группы')
return res.json() as Promise<Group[]>
}),
])
.then(([userData, groupsData]) => {
setUser(userData)
setGroups(groupsData)
setLoading(false)
})
.catch(err => {
console.error(err)
setError(err.message)
setLoading(false)
})
}, [router])
// CRUD handlers (stubbed)
const handleAddLink = (groupId: number) => {
router.push(`/dashboard/new-link?group=${groupId}`)
}
const handleEditGroup = (groupId: number) => {
router.push(`/dashboard/edit-group/${groupId}`)
}
const handleDeleteGroup = (groupId: number) => {
// TODO: вызвать DELETE /api/groups/{groupId}/ и обновить state
alert(`Delete group ${groupId}`)
}
const handleEditLink = (linkId: number) => {
router.push(`/dashboard/edit-link/${linkId}`)
}
const handleDeleteLink = (linkId: number) => {
// TODO: вызвать DELETE /api/links/{linkId}/ и обновить state
alert(`Delete link ${linkId}`)
}
if (loading) {
return (
<div className="flex items-center justify-center h-screen">
Загрузка...
</div>
)
}
if (error) {
return (
<div className="max-w-xl mx-auto p-6">
<p className="text-red-600">{error}</p>
</div>
)
}
return (
<div className="pb-8">
{/* Центрированная карточка профиля */}
<div
className="bg-white shadow rounded mx-auto mt-8"
style={{ maxWidth: 600 }}
>
<div
className="rounded-top"
style={{
backgroundImage: `url('${user?.coverUrl ?? '/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={user?.avatarUrl ?? '/assets/img/avatar-dhg.png'}
alt="Avatar"
width={150}
height={150}
/>
</div>
<div className="px-4 pb-4 text-center">
<h3 className="mt-3">{user?.first_name || user?.username}</h3>
<p className="text-muted mb-2">{user?.email}</p>
<p className="mb-3">{user?.bio ?? 'Описание профиля отсутствует.'}</p>
<div className="d-flex justify-content-around">
<div>
<p className="mb-0 text-uppercase small">Friends</p>
<p className="fw-bold">{user?.friendsCount ?? 0}</p>
</div>
<div>
<p className="mb-0 text-uppercase small">Shares</p>
<p className="fw-bold">{user?.shareCount ?? 0}</p>
</div>
</div>
</div>
</div>
{/* Аккордеон групп */}
<section className="mt-5">
<div className="container">
<div className="row justify-content-center">
<div className="col-xl-10 col-xxl-9">
<div className="card shadow">
<div className="card-header d-flex justify-content-between align-items-center">
<h5 className="mb-0">Группы ссылок</h5>
<div className="input-group input-group-sm" style={{ maxWidth: 200 }}>
<input className="form-control" type="search" placeholder="Поиск..." />
<button className="btn btn-outline-primary" type="button">🔍</button>
</div>
</div>
<div className="list-group list-group-flush">
{groups.map(group => (
<Fragment key={group.id}>
<div className="list-group-item">
<div className="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)
}
>
<span>{group.name}</span>
<span className="badge bg-secondary rounded-pill ms-2">
{(group.links ?? []).length}
</span>
</div>
<div>
<button
className="btn btn-sm btn-link p-0 me-2"
title="Добавить ссылку"
onClick={() => handleAddLink(group.id)}
>
<i className="bi bi-plus-circle" />
</button>
<button
className="btn btn-sm btn-link p-0 me-2"
title="Переименовать группу"
onClick={() => handleEditGroup(group.id)}
>
<i className="bi bi-pencil-square" />
</button>
<button
className="btn btn-sm btn-link p-0 text-danger"
title="Удалить группу"
onClick={() => handleDeleteGroup(group.id)}
>
<i className="bi bi-trash" />
</button>
</div>
</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>
<button
className="btn btn-sm btn-link p-0 me-2"
title="Переименовать ссылку"
onClick={() => handleEditLink(link.id)}
>
<i className="bi bi-pencil-fill" />
</button>
<button
className="btn btn-sm btn-link p-0 text-danger"
title="Удалить ссылку"
onClick={() => handleDeleteLink(link.id)}
>
<i className="bi bi-trash-fill" />
</button>
</div>
</li>
))}
</ul>
</div>
)}
</Fragment>
))}
</div>
<div className="card-footer">
<nav>
<ul className="pagination pagination-sm justify-content-center mb-0">
<li className="page-item">
<Link href="?page=prev" className="page-link">«</Link>
</li>
<li className="page-item">
<Link href="?page=1" className="page-link">1</Link>
</li>
<li className="page-item">
<Link href="?page=2" className="page-link">2</Link>
</li>
<li className="page-item">
<Link href="?page=3" className="page-link">3</Link>
</li>
<li className="page-item">
<Link href="?page=next" className="page-link">»</Link>
</li>
</ul>
</nav>
</div>
</div>
</div>
</div>
</div>
</section>
</div>
)
}

View File

@@ -0,0 +1,514 @@
// 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 Image from 'next/image'
interface User {
id: number
username: string
email: string
first_name?: string
last_name?: string
bio?: string
avatarUrl?: string
coverUrl?: string
friendsCount?: number
shareCount?: number
}
interface LinkItem {
id: number
title: string
url: string
}
interface Group {
id: number
name: string
icon?: string
links?: LinkItem[]
}
export default function DashboardPage() {
const router = useRouter()
// данные
const [user, setUser] = useState<User | null>(null)
const [groups, setGroups] = useState<Group[]>([])
// какой аккордеон раскрыт
const [expandedGroup, setExpandedGroup] = useState<number | null>(null)
// для модалей
const [newGroupName, setNewGroupName] = useState('')
const [renameGroupData, setRenameGroupData] = useState<Group | null>(null)
const [deleteGroupData, setDeleteGroupData] = useState<Group | null>(null)
const [addLinkGroupId, setAddLinkGroupId] = useState<number | null>(null)
const [newLinkTitle, setNewLinkTitle] = useState('')
const [newLinkUrl, setNewLinkUrl] = useState('')
const [renameLinkData, setRenameLinkData] = useState<LinkItem | null>(null)
const [deleteLinkData, setDeleteLinkData] = useState<LinkItem | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const API = process.env.NEXT_PUBLIC_API_URL ?? ''
// загрузка данных
const fetchAll = async () => {
setLoading(true)
try {
const token = localStorage.getItem('token')
if (!token) throw new Error('no token')
const [userRes, groupsRes] = await Promise.all([
fetch(`${API}/api/auth/user/`, {
headers: { Authorization: `Bearer ${token}` },
}),
fetch(`${API}/api/groups/`, {
headers: { Authorization: `Bearer ${token}` },
}),
])
if (!userRes.ok) throw new Error('Не удалось получить профиль')
if (!groupsRes.ok) throw new Error('Не удалось загрузить группы')
const userData = await userRes.json() as User
const groupsData = await groupsRes.json() as Group[]
setUser(userData)
setGroups(groupsData)
setError(null)
} catch (e: any) {
setError(e.message)
} finally {
setLoading(false)
}
}
useEffect(() => {
const token = localStorage.getItem('token')
if (!token) {
router.push('/auth/login')
return
}
fetchAll()
}, [router])
// утилита скрыть модаль
const hideModal = (id: string) => {
// @ts-ignore
const modal = window.bootstrap.Modal.getInstance(document.getElementById(id))
modal?.hide()
}
// CRUD группы
const handleAddGroup = async (e: React.FormEvent) => {
e.preventDefault()
// TODO: POST /api/groups/ {name: newGroupName}
hideModal('addGroupModal')
setNewGroupName('')
await fetchAll()
}
const handleRenameGroup = async (e: React.FormEvent) => {
e.preventDefault()
if (!renameGroupData) return
// TODO: PATCH /api/groups/{id}/ {name: newGroupName}
hideModal('renameGroupModal')
setRenameGroupData(null)
setNewGroupName('')
await fetchAll()
}
const handleDeleteGroup = async () => {
if (!deleteGroupData) return
// TODO: DELETE /api/groups/{id}/
hideModal('deleteGroupModal')
setDeleteGroupData(null)
await fetchAll()
}
// CRUD ссылки
const handleAddLink = async (e: React.FormEvent) => {
e.preventDefault()
if (!addLinkGroupId) return
// TODO: POST /api/links/ {title, url, group: addLinkGroupId}
hideModal('addLinkModal')
setAddLinkGroupId(null)
setNewLinkTitle(''); setNewLinkUrl('')
await fetchAll()
}
const handleRenameLink = async (e: React.FormEvent) => {
e.preventDefault()
if (!renameLinkData) return
// TODO: PATCH /api/links/{id}/ {title: newLinkTitle, url: newLinkUrl}
hideModal('renameLinkModal')
setRenameLinkData(null)
await fetchAll()
}
const handleDeleteLink = async () => {
if (!deleteLinkData) return
// TODO: DELETE /api/links/{id}/
hideModal('deleteLinkModal')
setDeleteLinkData(null)
await fetchAll()
}
if (loading) {
return <div className="flex items-center justify-center h-screen">Загрузка...</div>
}
if (error) {
return (
<div className="max-w-xl mx-auto p-6">
<p className="text-red-600">{error}</p>
</div>
)
}
return (
<div className="pb-8">
{/* Profile Card */}
<div
className="bg-white shadow rounded mx-auto mt-8"
style={{ maxWidth: 600 }}
>
<div
className="rounded-top"
style={{
backgroundImage: `url('${user?.coverUrl ?? '/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={user?.avatarUrl ?? '/assets/img/avatar-dhg.png'}
alt="Avatar"
width={150}
height={150}
/>
</div>
<div className="px-4 pb-4 text-center">
<h3 className="mt-3">{user?.first_name || user?.username}</h3>
<p className="text-muted mb-2">{user?.email}</p>
<p className="mb-3">{user?.bio ?? 'Описание профиля отсутствует.'}</p>
<div className="d-flex justify-content-around">
<div>
<p className="mb-0 text-uppercase small">Friends</p>
<p className="fw-bold">{user?.friendsCount ?? 0}</p>
</div>
<div>
<p className="mb-0 text-uppercase small">Shares</p>
<p className="fw-bold">{user?.shareCount ?? 0}</p>
</div>
</div>
</div>
</div>
{/* Groups Accordion */}
<section className="mt-5">
<div className="container">
<div className="row justify-content-center">
<div className="col-xl-10 col-xxl-9">
<div className="card shadow">
<div className="card-header d-flex justify-content-between align-items-center">
<h5 className="mb-0">Группы ссылок</h5>
<button
type="button"
className="btn btn-sm btn-success"
data-bs-toggle="modal"
data-bs-target="#addGroupModal"
>
<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)
}
>
<span>{group.name}</span>
<span className="badge bg-secondary rounded-pill ms-2">
{(group.links ?? []).length}
</span>
</div>
<div className="btn-group btn-group-sm">
<button
className="btn btn-outline-primary"
title="Добавить ссылку"
data-bs-toggle="modal"
data-bs-target="#addLinkModal"
onClick={() => {
setAddLinkGroupId(group.id)
setNewLinkTitle('')
setNewLinkUrl('')
}}
>
<i className="bi bi-link-45deg"></i>
</button>
<button
className="btn btn-outline-secondary"
title="Переименовать группу"
data-bs-toggle="modal"
data-bs-target="#renameGroupModal"
onClick={() => {
setRenameGroupData(group)
setNewGroupName(group.name)
}}
>
<i className="bi bi-pencil"></i>
</button>
<button
className="btn btn-outline-danger"
title="Удалить группу"
data-bs-toggle="modal"
data-bs-target="#deleteGroupModal"
onClick={() => setDeleteGroupData(group)}
>
<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"
title="Переименовать ссылку"
data-bs-toggle="modal"
data-bs-target="#renameLinkModal"
onClick={() => {
setRenameLinkData(link)
setNewLinkTitle(link.title)
setNewLinkUrl(link.url)
}}
>
<i className="bi bi-pencil-fill"></i>
</button>
<button
className="btn btn-outline-danger"
title="Удалить ссылку"
data-bs-toggle="modal"
data-bs-target="#deleteLinkModal"
onClick={() => setDeleteLinkData(link)}
>
<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">
<li className="page-item">
<Link href="?page=prev" className="page-link">«</Link>
</li>
<li className="page-item">
<Link href="?page=1" className="page-link">1</Link>
</li>
<li className="page-item">
<Link href="?page=2" className="page-link">2</Link>
</li>
<li className="page-item">
<Link href="?page=3" className="page-link">3</Link>
</li>
<li className="page-item">
<Link href="?page=next" className="page-link">»</Link>
</li>
</ul>
</nav>
</div>
</div>
</div>
</div>
</div>
</section>
{/* Modals */}
{/* Add Group */}
<div className="modal fade" id="addGroupModal" tabIndex={-1} aria-labelledby="addGroupModalLabel" aria-hidden="true">
<div className="modal-dialog">
<form className="modal-content" onSubmit={handleAddGroup}>
<div className="modal-header">
<h5 className="modal-title" id="addGroupModalLabel">Добавить группу</h5>
<button type="button" className="btn-close" data-bs-dismiss="modal" />
</div>
<div className="modal-body">
<label className="form-label">Название группы</label>
<input
type="text"
className="form-control"
value={newGroupName}
onChange={e => setNewGroupName(e.target.value)}
required
/>
</div>
<div className="modal-footer">
<button type="button" className="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
<button type="submit" className="btn btn-primary">Сохранить</button>
</div>
</form>
</div>
</div>
{/* Rename Group */}
<div className="modal fade" id="renameGroupModal" tabIndex={-1} aria-labelledby="renameGroupModalLabel" aria-hidden="true">
<div className="modal-dialog">
<form className="modal-content" onSubmit={handleRenameGroup}>
<div className="modal-header">
<h5 className="modal-title" id="renameGroupModalLabel">Переименовать группу</h5>
<button type="button" className="btn-close" data-bs-dismiss="modal" />
</div>
<div className="modal-body">
<label className="form-label">Новое название</label>
<input
type="text"
className="form-control"
value={newGroupName}
onChange={e => setNewGroupName(e.target.value)}
required
/>
</div>
<div className="modal-footer">
<button type="button" className="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
<button type="submit" className="btn btn-primary">Переименовать</button>
</div>
</form>
</div>
</div>
{/* Delete Group */}
<div className="modal fade" id="deleteGroupModal" tabIndex={-1} aria-labelledby="deleteGroupModalLabel" aria-hidden="true">
<div className="modal-dialog">
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title" id="deleteGroupModalLabel">Удалить группу?</h5>
<button type="button" className="btn-close" data-bs-dismiss="modal" />
</div>
<div className="modal-body">
Вы действительно хотите удалить группу "{deleteGroupData?.name}"?
</div>
<div className="modal-footer">
<button type="button" className="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
<button type="button" className="btn btn-danger" onClick={handleDeleteGroup}>Удалить</button>
</div>
</div>
</div>
</div>
{/* Add Link */}
<div className="modal fade" id="addLinkModal" tabIndex={-1} aria-labelledby="addLinkModalLabel" aria-hidden="true">
<div className="modal-dialog">
<form className="modal-content" onSubmit={handleAddLink}>
<div className="modal-header">
<h5 className="modal-title" id="addLinkModalLabel">Добавить ссылку</h5>
<button type="button" className="btn-close" data-bs-dismiss="modal" />
</div>
<div className="modal-body">
<label className="form-label">Заголовок</label>
<input
type="text"
className="form-control mb-2"
value={newLinkTitle}
onChange={e => setNewLinkTitle(e.target.value)}
required
/>
<label className="form-label">URL</label>
<input
type="url"
className="form-control"
value={newLinkUrl}
onChange={e => setNewLinkUrl(e.target.value)}
required
/>
</div>
<div className="modal-footer">
<button type="button" className="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
<button type="submit" className="btn btn-primary">Добавить</button>
</div>
</form>
</div>
</div>
{/* Rename Link */}
<div className="modal fade" id="renameLinkModal" tabIndex={-1} aria-labelledby="renameLinkModalLabel" aria-hidden="true">
<div className="modal-dialog">
<form className="modal-content" onSubmit={handleRenameLink}>
<div className="modal-header">
<h5 className="modal-title" id="renameLinkModalLabel">Переименовать ссылку</h5>
<button type="button" className="btn-close" data-bs-dismiss="modal" />
</div>
<div className="modal-body">
<label className="form-label">Заголовок</label>
<input
type="text"
className="form-control mb-2"
value={newLinkTitle}
onChange={e => setNewLinkTitle(e.target.value)}
required
/>
<label className="form-label">URL</label>
<input
type="url"
className="form-control"
value={newLinkUrl}
onChange={e => setNewLinkUrl(e.target.value)}
required
/>
</div>
<div className="modal-footer">
<button type="button" className="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
<button type="submit" className="btn btn-primary">Сохранить</button>
</div>
</form>
</div>
</div>
{/* Delete Link */}
<div className="modal fade" id="deleteLinkModal" tabIndex={-1} aria-labelledby="deleteLinkModalLabel" aria-hidden="true">
<div className="modal-dialog">
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title" id="deleteLinkModalLabel">Удалить ссылку?</h5>
<button type="button" className="btn-close" data-bs-dismiss="modal" />
</div>
<div className="modal-body">
Вы действительно хотите удалить ссылку "{deleteLinkData?.title}"?
</div>
<div className="modal-footer">
<button type="button" className="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
<button type="button" className="btn btn-danger" onClick={handleDeleteLink}>Удалить</button>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,117 @@
// src/app/dashboard/page.tsx
'use client'
import { useEffect, useState, Fragment } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import Image from 'next/image'
interface User { /* … */ }
interface Group { id: number; name: string; links?: { id: number; title: string; url: string }[] }
export default function DashboardPage() {
const router = useRouter()
const [user, setUser] = useState<User | null>(null)
const [groups, setGroups] = useState<Group[]>([])
const [expanded, setExpanded] = useState<number|null>(null)
const API = process.env.NEXT_PUBLIC_API_URL!
useEffect(() => {
const token = localStorage.getItem('token')
if (!token) return router.push('/auth/login')
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 || !gRes.ok) throw new Error()
setUser(await uRes.json())
setGroups(await gRes.json())
}).catch(() => router.push('/auth/login'))
}, [router, API])
return (
<div className="container py-5">
{/* Profile */}
{user && (
<div className="card mx-auto mb-5" style={{ maxWidth:600 }}>
<div
className="card-img-top"
style={{
height:150,
background: `url('${user.coverUrl||'/assets/img/iceland.jpg'}') center/cover`
}}
/>
<div className="card-body text-center" style={{ marginTop:-75 }}>
<Image
src={user.avatarUrl||'/assets/img/avatar-dhg.png'}
alt="Avatar"
width={150} height={150}
className="rounded-circle border border-white"
/>
<h3 className="mt-3">{user.first_name||user.username}</h3>
<p className="text-muted mb-4">{user.email}</p>
<div className="d-flex justify-content-around">
<div>
<p className="mb-1 small text-uppercase">Friends</p>
<p className="fw-bold">{user.friendsCount||0}</p>
</div>
<div>
<p className="mb-1 small text-uppercase">Shares</p>
<p className="fw-bold">{user.shareCount||0}</p>
</div>
</div>
</div>
</div>
)}
{/* Groups Accordion */}
<div className="accordion" id="groupsAccordion">
{groups.map(group => (
<Fragment key={group.id}>
<div className="accordion-item">
<h2 className="accordion-header" id={`heading${group.id}`}>
<button
className={`accordion-button ${expanded===group.id?'' : 'collapsed'}`}
type="button"
onClick={()=>setExpanded(expanded===group.id?null:group.id)}
>
{group.name} <span className="badge bg-secondary ms-3">{(group.links||[]).length}</span>
</button>
</h2>
<div className={`accordion-collapse collapse ${expanded===group.id?'show':''}`}>
<div className="accordion-body">
<ul className="list-group mb-3">
{(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">
{link.title}
</a>
<div className="btn-group btn-group-sm">
<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>
</li>
))}
</ul>
<button className="btn btn-sm btn-success me-2">
<i className="bi bi-plus-lg"></i> Добавить ссылку
</button>
<button className="btn btn-sm btn-secondary me-2">
<i className="bi bi-pencil"></i> Переименовать группу
</button>
<button className="btn btn-sm btn-danger">
<i className="bi bi-trash"></i> Удалить группу
</button>
</div>
</div>
</div>
</Fragment>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,117 @@
// src/app/dashboard/page.tsx
'use client'
import { useEffect, useState, Fragment } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import Image from 'next/image'
interface User { /* … */ }
interface Group { id: number; name: string; links?: { id: number; title: string; url: string }[] }
export default function DashboardPage() {
const router = useRouter()
const [user, setUser] = useState<User | null>(null)
const [groups, setGroups] = useState<Group[]>([])
const [expanded, setExpanded] = useState<number|null>(null)
const API = process.env.NEXT_PUBLIC_API_URL!
useEffect(() => {
const token = localStorage.getItem('token')
if (!token) return router.push('/auth/login')
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 || !gRes.ok) throw new Error()
setUser(await uRes.json())
setGroups(await gRes.json())
}).catch(() => router.push('/auth/login'))
}, [router, API])
return (
<div className="container py-5">
{/* Profile */}
{user && (
<div className="card mx-auto mb-5" style={{ maxWidth:600 }}>
<div
className="card-img-top"
style={{
height:150,
background: `url('${user.coverUrl||'/assets/img/iceland.jpg'}') center/cover`
}}
/>
<div className="card-body text-center" style={{ marginTop:-75 }}>
<Image
src={user.avatarUrl||'/assets/img/avatar-dhg.png'}
alt="Avatar"
width={150} height={150}
className="rounded-circle border border-white"
/>
<h3 className="mt-3">{user.first_name||user.username}</h3>
<p className="text-muted mb-4">{user.email}</p>
<div className="d-flex justify-content-around">
<div>
<p className="mb-1 small text-uppercase">Friends</p>
<p className="fw-bold">{user.friendsCount||0}</p>
</div>
<div>
<p className="mb-1 small text-uppercase">Shares</p>
<p className="fw-bold">{user.shareCount||0}</p>
</div>
</div>
</div>
</div>
)}
{/* Groups Accordion */}
<div className="accordion" id="groupsAccordion">
{groups.map(group => (
<Fragment key={group.id}>
<div className="accordion-item">
<h2 className="accordion-header" id={`heading${group.id}`}>
<button
className={`accordion-button ${expanded===group.id?'' : 'collapsed'}`}
type="button"
onClick={()=>setExpanded(expanded===group.id?null:group.id)}
>
{group.name} <span className="badge bg-secondary ms-3">{(group.links||[]).length}</span>
</button>
</h2>
<div className={`accordion-collapse collapse ${expanded===group.id?'show':''}`}>
<div className="accordion-body">
<ul className="list-group mb-3">
{(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">
{link.title}
</a>
<div className="btn-group btn-group-sm">
<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>
</li>
))}
</ul>
<button className="btn btn-sm btn-success me-2">
<i className="bi bi-plus-lg"></i> Добавить ссылку
</button>
<button className="btn btn-sm btn-secondary me-2">
<i className="bi bi-pencil"></i> Переименовать группу
</button>
<button className="btn btn-sm btn-danger">
<i className="bi bi-trash"></i> Удалить группу
</button>
</div>
</div>
</div>
</Fragment>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,117 @@
// src/app/dashboard/page.tsx
'use client'
import { useEffect, useState, Fragment } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import Image from 'next/image'
interface User { /* … */ }
interface Group { id: number; name: string; links?: { id: number; title: string; url: string }[] }
export default function DashboardPage() {
const router = useRouter()
const [user, setUser] = useState<User | null>(null)
const [groups, setGroups] = useState<Group[]>([])
const [expanded, setExpanded] = useState<number|null>(null)
const API = process.env.NEXT_PUBLIC_API_URL!
useEffect(() => {
const token = localStorage.getItem('token')
if (!token) return router.push('/auth/login')
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 || !gRes.ok) throw new Error()
setUser(await uRes.json())
setGroups(await gRes.json())
}).catch(() => router.push('/auth/login'))
}, [router, API])
return (
<div className="container py-5">
{/* Profile */}
{user && (
<div className="card mx-auto mb-5" style={{ maxWidth:600 }}>
<div
className="card-img-top"
style={{
height:150,
background: `url('${user.coverUrl||'/assets/img/iceland.jpg'}') center/cover`
}}
/>
<div className="card-body text-center" style={{ marginTop:-75 }}>
<Image
src={user.avatarUrl||'/assets/img/avatar-dhg.png'}
alt="Avatar"
width={150} height={150}
className="rounded-circle border border-white"
/>
<h3 className="mt-3">{user.first_name||user.username}</h3>
<p className="text-muted mb-4">{user.email}</p>
<div className="d-flex justify-content-around">
<div>
<p className="mb-1 small text-uppercase">Friends</p>
<p className="fw-bold">{user.friendsCount||0}</p>
</div>
<div>
<p className="mb-1 small text-uppercase">Shares</p>
<p className="fw-bold">{user.shareCount||0}</p>
</div>
</div>
</div>
</div>
)}
{/* Groups Accordion */}
<div className="accordion" id="groupsAccordion">
{groups.map(group => (
<Fragment key={group.id}>
<div className="accordion-item">
<h2 className="accordion-header" id={`heading${group.id}`}>
<button
className={`accordion-button ${expanded===group.id?'' : 'collapsed'}`}
type="button"
onClick={()=>setExpanded(expanded===group.id?null:group.id)}
>
{group.name} <span className="badge bg-secondary ms-3">{(group.links||[]).length}</span>
</button>
</h2>
<div className={`accordion-collapse collapse ${expanded===group.id?'show':''}`}>
<div className="accordion-body">
<ul className="list-group mb-3">
{(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">
{link.title}
</a>
<div className="btn-group btn-group-sm">
<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>
</li>
))}
</ul>
<button className="btn btn-sm btn-success me-2">
<i className="bi bi-plus-lg"></i> Добавить ссылку
</button>
<button className="btn btn-sm btn-secondary me-2">
<i className="bi bi-pencil"></i> Переименовать группу
</button>
<button className="btn btn-sm btn-danger">
<i className="bi bi-trash"></i> Удалить группу
</button>
</div>
</div>
</div>
</Fragment>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,514 @@
// 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 Image from 'next/image'
interface User {
id: number
username: string
email: string
first_name?: string
last_name?: string
bio?: string
avatarUrl?: string
coverUrl?: string
friendsCount?: number
shareCount?: number
}
interface LinkItem {
id: number
title: string
url: string
}
interface Group {
id: number
name: string
icon?: string
links?: LinkItem[]
}
export default function DashboardPage() {
const router = useRouter()
// данные
const [user, setUser] = useState<User | null>(null)
const [groups, setGroups] = useState<Group[]>([])
// какой аккордеон раскрыт
const [expandedGroup, setExpandedGroup] = useState<number | null>(null)
// для модалей
const [newGroupName, setNewGroupName] = useState('')
const [renameGroupData, setRenameGroupData] = useState<Group | null>(null)
const [deleteGroupData, setDeleteGroupData] = useState<Group | null>(null)
const [addLinkGroupId, setAddLinkGroupId] = useState<number | null>(null)
const [newLinkTitle, setNewLinkTitle] = useState('')
const [newLinkUrl, setNewLinkUrl] = useState('')
const [renameLinkData, setRenameLinkData] = useState<LinkItem | null>(null)
const [deleteLinkData, setDeleteLinkData] = useState<LinkItem | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const API = process.env.NEXT_PUBLIC_API_URL ?? ''
// загрузка данных
const fetchAll = async () => {
setLoading(true)
try {
const token = localStorage.getItem('token')
if (!token) throw new Error('no token')
const [userRes, groupsRes] = await Promise.all([
fetch(`${API}/api/auth/user/`, {
headers: { Authorization: `Bearer ${token}` },
}),
fetch(`${API}/api/groups/`, {
headers: { Authorization: `Bearer ${token}` },
}),
])
if (!userRes.ok) throw new Error('Не удалось получить профиль')
if (!groupsRes.ok) throw new Error('Не удалось загрузить группы')
const userData = await userRes.json() as User
const groupsData = await groupsRes.json() as Group[]
setUser(userData)
setGroups(groupsData)
setError(null)
} catch (e: any) {
setError(e.message)
} finally {
setLoading(false)
}
}
useEffect(() => {
const token = localStorage.getItem('token')
if (!token) {
router.push('/auth/login')
return
}
fetchAll()
}, [router])
// утилита скрыть модаль
const hideModal = (id: string) => {
// @ts-ignore
const modal = window.bootstrap.Modal.getInstance(document.getElementById(id))
modal?.hide()
}
// CRUD группы
const handleAddGroup = async (e: React.FormEvent) => {
e.preventDefault()
// TODO: POST /api/groups/ {name: newGroupName}
hideModal('addGroupModal')
setNewGroupName('')
await fetchAll()
}
const handleRenameGroup = async (e: React.FormEvent) => {
e.preventDefault()
if (!renameGroupData) return
// TODO: PATCH /api/groups/{id}/ {name: newGroupName}
hideModal('renameGroupModal')
setRenameGroupData(null)
setNewGroupName('')
await fetchAll()
}
const handleDeleteGroup = async () => {
if (!deleteGroupData) return
// TODO: DELETE /api/groups/{id}/
hideModal('deleteGroupModal')
setDeleteGroupData(null)
await fetchAll()
}
// CRUD ссылки
const handleAddLink = async (e: React.FormEvent) => {
e.preventDefault()
if (!addLinkGroupId) return
// TODO: POST /api/links/ {title, url, group: addLinkGroupId}
hideModal('addLinkModal')
setAddLinkGroupId(null)
setNewLinkTitle(''); setNewLinkUrl('')
await fetchAll()
}
const handleRenameLink = async (e: React.FormEvent) => {
e.preventDefault()
if (!renameLinkData) return
// TODO: PATCH /api/links/{id}/ {title: newLinkTitle, url: newLinkUrl}
hideModal('renameLinkModal')
setRenameLinkData(null)
await fetchAll()
}
const handleDeleteLink = async () => {
if (!deleteLinkData) return
// TODO: DELETE /api/links/{id}/
hideModal('deleteLinkModal')
setDeleteLinkData(null)
await fetchAll()
}
if (loading) {
return <div className="flex items-center justify-center h-screen">Загрузка...</div>
}
if (error) {
return (
<div className="max-w-xl mx-auto p-6">
<p className="text-red-600">{error}</p>
</div>
)
}
return (
<div className="pb-8">
{/* Profile Card */}
<div
className="bg-white shadow rounded mx-auto mt-8"
style={{ maxWidth: 600 }}
>
<div
className="rounded-top"
style={{
backgroundImage: `url('${user?.coverUrl ?? '/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={user?.avatarUrl ?? '/assets/img/avatar-dhg.png'}
alt="Avatar"
width={150}
height={150}
/>
</div>
<div className="px-4 pb-4 text-center">
<h3 className="mt-3">{user?.first_name || user?.username}</h3>
<p className="text-muted mb-2">{user?.email}</p>
<p className="mb-3">{user?.bio ?? 'Описание профиля отсутствует.'}</p>
<div className="d-flex justify-content-around">
<div>
<p className="mb-0 text-uppercase small">Friends</p>
<p className="fw-bold">{user?.friendsCount ?? 0}</p>
</div>
<div>
<p className="mb-0 text-uppercase small">Shares</p>
<p className="fw-bold">{user?.shareCount ?? 0}</p>
</div>
</div>
</div>
</div>
{/* Groups Accordion */}
<section className="mt-5">
<div className="container">
<div className="row justify-content-center">
<div className="col-xl-10 col-xxl-9">
<div className="card shadow">
<div className="card-header d-flex justify-content-between align-items-center">
<h5 className="mb-0">Группы ссылок</h5>
<button
type="button"
className="btn btn-sm btn-success"
data-bs-toggle="modal"
data-bs-target="#addGroupModal"
>
<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)
}
>
<span>{group.name}</span>
<span className="badge bg-secondary rounded-pill ms-2">
{(group.links ?? []).length}
</span>
</div>
<div className="btn-group btn-group-sm">
<button
className="btn btn-outline-primary"
title="Добавить ссылку"
data-bs-toggle="modal"
data-bs-target="#addLinkModal"
onClick={() => {
setAddLinkGroupId(group.id)
setNewLinkTitle('')
setNewLinkUrl('')
}}
>
<i className="bi bi-link-45deg"></i>
</button>
<button
className="btn btn-outline-secondary"
title="Переименовать группу"
data-bs-toggle="modal"
data-bs-target="#renameGroupModal"
onClick={() => {
setRenameGroupData(group)
setNewGroupName(group.name)
}}
>
<i className="bi bi-pencil"></i>
</button>
<button
className="btn btn-outline-danger"
title="Удалить группу"
data-bs-toggle="modal"
data-bs-target="#deleteGroupModal"
onClick={() => setDeleteGroupData(group)}
>
<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"
title="Переименовать ссылку"
data-bs-toggle="modal"
data-bs-target="#renameLinkModal"
onClick={() => {
setRenameLinkData(link)
setNewLinkTitle(link.title)
setNewLinkUrl(link.url)
}}
>
<i className="bi bi-pencil-fill"></i>
</button>
<button
className="btn btn-outline-danger"
title="Удалить ссылку"
data-bs-toggle="modal"
data-bs-target="#deleteLinkModal"
onClick={() => setDeleteLinkData(link)}
>
<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">
<li className="page-item">
<Link href="?page=prev" className="page-link">«</Link>
</li>
<li className="page-item">
<Link href="?page=1" className="page-link">1</Link>
</li>
<li className="page-item">
<Link href="?page=2" className="page-link">2</Link>
</li>
<li className="page-item">
<Link href="?page=3" className="page-link">3</Link>
</li>
<li className="page-item">
<Link href="?page=next" className="page-link">»</Link>
</li>
</ul>
</nav>
</div>
</div>
</div>
</div>
</div>
</section>
{/* Modals */}
{/* Add Group */}
<div className="modal fade" id="addGroupModal" tabIndex={-1} aria-labelledby="addGroupModalLabel" aria-hidden="true">
<div className="modal-dialog">
<form className="modal-content" onSubmit={handleAddGroup}>
<div className="modal-header">
<h5 className="modal-title" id="addGroupModalLabel">Добавить группу</h5>
<button type="button" className="btn-close" data-bs-dismiss="modal" />
</div>
<div className="modal-body">
<label className="form-label">Название группы</label>
<input
type="text"
className="form-control"
value={newGroupName}
onChange={e => setNewGroupName(e.target.value)}
required
/>
</div>
<div className="modal-footer">
<button type="button" className="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
<button type="submit" className="btn btn-primary">Сохранить</button>
</div>
</form>
</div>
</div>
{/* Rename Group */}
<div className="modal fade" id="renameGroupModal" tabIndex={-1} aria-labelledby="renameGroupModalLabel" aria-hidden="true">
<div className="modal-dialog">
<form className="modal-content" onSubmit={handleRenameGroup}>
<div className="modal-header">
<h5 className="modal-title" id="renameGroupModalLabel">Переименовать группу</h5>
<button type="button" className="btn-close" data-bs-dismiss="modal" />
</div>
<div className="modal-body">
<label className="form-label">Новое название</label>
<input
type="text"
className="form-control"
value={newGroupName}
onChange={e => setNewGroupName(e.target.value)}
required
/>
</div>
<div className="modal-footer">
<button type="button" className="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
<button type="submit" className="btn btn-primary">Переименовать</button>
</div>
</form>
</div>
</div>
{/* Delete Group */}
<div className="modal fade" id="deleteGroupModal" tabIndex={-1} aria-labelledby="deleteGroupModalLabel" aria-hidden="true">
<div className="modal-dialog">
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title" id="deleteGroupModalLabel">Удалить группу?</h5>
<button type="button" className="btn-close" data-bs-dismiss="modal" />
</div>
<div className="modal-body">
Вы действительно хотите удалить группу "{deleteGroupData?.name}"?
</div>
<div className="modal-footer">
<button type="button" className="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
<button type="button" className="btn btn-danger" onClick={handleDeleteGroup}>Удалить</button>
</div>
</div>
</div>
</div>
{/* Add Link */}
<div className="modal fade" id="addLinkModal" tabIndex={-1} aria-labelledby="addLinkModalLabel" aria-hidden="true">
<div className="modal-dialog">
<form className="modal-content" onSubmit={handleAddLink}>
<div className="modal-header">
<h5 className="modal-title" id="addLinkModalLabel">Добавить ссылку</h5>
<button type="button" className="btn-close" data-bs-dismiss="modal" />
</div>
<div className="modal-body">
<label className="form-label">Заголовок</label>
<input
type="text"
className="form-control mb-2"
value={newLinkTitle}
onChange={e => setNewLinkTitle(e.target.value)}
required
/>
<label className="form-label">URL</label>
<input
type="url"
className="form-control"
value={newLinkUrl}
onChange={e => setNewLinkUrl(e.target.value)}
required
/>
</div>
<div className="modal-footer">
<button type="button" className="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
<button type="submit" className="btn btn-primary">Добавить</button>
</div>
</form>
</div>
</div>
{/* Rename Link */}
<div className="modal fade" id="renameLinkModal" tabIndex={-1} aria-labelledby="renameLinkModalLabel" aria-hidden="true">
<div className="modal-dialog">
<form className="modal-content" onSubmit={handleRenameLink}>
<div className="modal-header">
<h5 className="modal-title" id="renameLinkModalLabel">Переименовать ссылку</h5>
<button type="button" className="btn-close" data-bs-dismiss="modal" />
</div>
<div className="modal-body">
<label className="form-label">Заголовок</label>
<input
type="text"
className="form-control mb-2"
value={newLinkTitle}
onChange={e => setNewLinkTitle(e.target.value)}
required
/>
<label className="form-label">URL</label>
<input
type="url"
className="form-control"
value={newLinkUrl}
onChange={e => setNewLinkUrl(e.target.value)}
required
/>
</div>
<div className="modal-footer">
<button type="button" className="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
<button type="submit" className="btn btn-primary">Сохранить</button>
</div>
</form>
</div>
</div>
{/* Delete Link */}
<div className="modal fade" id="deleteLinkModal" tabIndex={-1} aria-labelledby="deleteLinkModalLabel" aria-hidden="true">
<div className="modal-dialog">
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title" id="deleteLinkModalLabel">Удалить ссылку?</h5>
<button type="button" className="btn-close" data-bs-dismiss="modal" />
</div>
<div className="modal-body">
Вы действительно хотите удалить ссылку "{deleteLinkData?.title}"?
</div>
<div className="modal-footer">
<button type="button" className="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
<button type="button" className="btn btn-danger" onClick={handleDeleteLink}>Удалить</button>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,276 @@
// 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 Image from 'next/image'
interface User {
id: number
username: string
email: string
first_name?: string
last_name?: string
bio?: string
avatarUrl?: string
coverUrl?: string
friendsCount?: number
shareCount?: number
}
interface LinkItem {
id: number
title: string
url: string
}
interface Group {
id: number
name: string
icon?: string
links?: LinkItem[]
}
export default function DashboardPage() {
const router = useRouter()
const [user, setUser] = useState<User | 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 ?? ''
Promise.all([
fetch(`${API}/api/auth/user/`, {
headers: { Authorization: `Bearer ${token}` },
}).then(res => {
if (!res.ok) throw new Error('Не удалось получить профиль')
return res.json() as Promise<User>
}),
fetch(`${API}/api/groups/`, {
headers: { Authorization: `Bearer ${token}` },
}).then(res => {
if (!res.ok) throw new Error('Не удалось загрузить группы')
return res.json() as Promise<Group[]>
}),
])
.then(([userData, groupsData]) => {
setUser(userData)
setGroups(groupsData)
setLoading(false)
})
.catch(err => {
console.error(err)
setError(err.message)
setLoading(false)
})
}, [router])
// CRUD handlers (stubbed)
const handleAddLink = (groupId: number) => {
router.push(`/dashboard/new-link?group=${groupId}`)
}
const handleEditGroup = (groupId: number) => {
router.push(`/dashboard/edit-group/${groupId}`)
}
const handleDeleteGroup = (groupId: number) => {
// TODO: вызвать DELETE /api/groups/{groupId}/ и обновить state
alert(`Delete group ${groupId}`)
}
const handleEditLink = (linkId: number) => {
router.push(`/dashboard/edit-link/${linkId}`)
}
const handleDeleteLink = (linkId: number) => {
// TODO: вызвать DELETE /api/links/{linkId}/ и обновить state
alert(`Delete link ${linkId}`)
}
if (loading) {
return (
<div className="flex items-center justify-center h-screen">
Загрузка...
</div>
)
}
if (error) {
return (
<div className="max-w-xl mx-auto p-6">
<p className="text-red-600">{error}</p>
</div>
)
}
return (
<div className="pb-8">
{/* Центрированная карточка профиля */}
<div
className="bg-white shadow rounded mx-auto mt-8"
style={{ maxWidth: 600 }}
>
<div
className="rounded-top"
style={{
backgroundImage: `url('${user?.coverUrl ?? '/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={user?.avatarUrl ?? '/assets/img/avatar-dhg.png'}
alt="Avatar"
width={150}
height={150}
/>
</div>
<div className="px-4 pb-4 text-center">
<h3 className="mt-3">{user?.first_name || user?.username}</h3>
<p className="text-muted mb-2">{user?.email}</p>
<p className="mb-3">{user?.bio ?? 'Описание профиля отсутствует.'}</p>
<div className="d-flex justify-content-around">
<div>
<p className="mb-0 text-uppercase small">Friends</p>
<p className="fw-bold">{user?.friendsCount ?? 0}</p>
</div>
<div>
<p className="mb-0 text-uppercase small">Shares</p>
<p className="fw-bold">{user?.shareCount ?? 0}</p>
</div>
</div>
</div>
</div>
{/* Аккордеон групп */}
<section className="mt-5">
<div className="container">
<div className="row justify-content-center">
<div className="col-xl-10 col-xxl-9">
<div className="card shadow">
<div className="card-header d-flex justify-content-between align-items-center">
<h5 className="mb-0">Группы ссылок</h5>
<div className="input-group input-group-sm" style={{ maxWidth: 200 }}>
<input className="form-control" type="search" placeholder="Поиск..." />
<button className="btn btn-outline-primary" type="button">🔍</button>
</div>
</div>
<div className="list-group list-group-flush">
{groups.map(group => (
<Fragment key={group.id}>
<div className="list-group-item">
<div className="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)
}
>
<span>{group.name}</span>
<span className="badge bg-secondary rounded-pill ms-2">
{(group.links ?? []).length}
</span>
</div>
<div>
<button
className="btn btn-sm btn-link p-0 me-2"
title="Добавить ссылку"
onClick={() => handleAddLink(group.id)}
>
<i className="bi bi-plus-circle" />
</button>
<button
className="btn btn-sm btn-link p-0 me-2"
title="Переименовать группу"
onClick={() => handleEditGroup(group.id)}
>
<i className="bi bi-pencil-square" />
</button>
<button
className="btn btn-sm btn-link p-0 text-danger"
title="Удалить группу"
onClick={() => handleDeleteGroup(group.id)}
>
<i className="bi bi-trash" />
</button>
</div>
</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>
<button
className="btn btn-sm btn-link p-0 me-2"
title="Переименовать ссылку"
onClick={() => handleEditLink(link.id)}
>
<i className="bi bi-pencil-fill" />
</button>
<button
className="btn btn-sm btn-link p-0 text-danger"
title="Удалить ссылку"
onClick={() => handleDeleteLink(link.id)}
>
<i className="bi bi-trash-fill" />
</button>
</div>
</li>
))}
</ul>
</div>
)}
</Fragment>
))}
</div>
<div className="card-footer">
<nav>
<ul className="pagination pagination-sm justify-content-center mb-0">
<li className="page-item">
<Link href="?page=prev" className="page-link">«</Link>
</li>
<li className="page-item">
<Link href="?page=1" className="page-link">1</Link>
</li>
<li className="page-item">
<Link href="?page=2" className="page-link">2</Link>
</li>
<li className="page-item">
<Link href="?page=3" className="page-link">3</Link>
</li>
<li className="page-item">
<Link href="?page=next" className="page-link">»</Link>
</li>
</ul>
</nav>
</div>
</div>
</div>
</div>
</div>
</section>
</div>
)
}

View File

@@ -0,0 +1,231 @@
// 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 Image from 'next/image'
interface User {
id: number
username: string
email: string
first_name?: string
last_name?: string
bio?: string
avatarUrl?: string
coverUrl?: string
friendsCount?: number
shareCount?: number
}
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<User | 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 ?? ''
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 userData = await uRes.json()
const groupsData = await gRes.json()
setUser(userData)
setGroups(groupsData)
setError(null)
})
.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 fullName = user
? [user.first_name, user.last_name].filter(Boolean).join(' ')
: ''
return (
<div className="pb-8">
{/* Центрированная карточка профиля */}
<div
className="bg-white shadow rounded mx-auto mt-8"
style={{ maxWidth: 600 }}
>
{/* обложка */}
<div
className="rounded-top"
style={{
backgroundImage: `url('${user?.coverUrl ?? '/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={user?.avatarUrl ?? '/assets/img/avatar-dhg.png'}
alt="Avatar"
width={150}
height={150}
/>
</div>
{/* данные */}
<div className="px-4 pb-4 text-center">
<h3 className="mt-3">
{fullName || user?.username}
</h3>
<p className="text-muted mb-2">{user?.email}</p>
<p className="mb-3">
{user?.bio ?? 'Описание профиля отсутствует.'}
</p>
<div className="d-flex justify-content-around">
<div>
<p className="mb-0 text-uppercase small">Friends</p>
<p className="fw-bold">{user?.friendsCount ?? 0}</p>
</div>
<div>
<p className="mb-0 text-uppercase small">Shares</p>
<p className="fw-bold">{user?.shareCount ?? 0}</p>
</div>
</div>
</div>
</div>
{/* Секция групп ссылок */}
<section className="mt-5">
<div className="container">
<div className="row justify-content-center">
<div className="col-xl-10 col-xxl-9">
<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={() => {
/* открыть модалку добавления группы */
}}
>
<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}
</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 mb-0 justify-content-center">
<li className="page-item"><Link href="?page=1" className="page-link">1</Link></li>
<li className="page-item"><Link href="?page=2" className="page-link">2</Link></li>
<li className="page-item"><Link href="?page=3" className="page-link">3</Link></li>
</ul>
</nav>
</div>
</div>
</div>
</div>
</div>
</section>
</div>
)
}

View File

@@ -0,0 +1,228 @@
'use client'
import React, { useEffect, useState, Fragment } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import Image from 'next/image'
interface User {
id: number
username: string
email: string
first_name?: string
last_name?: string
bio?: string
avatarUrl?: string
coverUrl?: 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<User | 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 ?? ''
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 userData = await uRes.json()
const groupsData = await gRes.json()
setUser(userData)
setGroups(groupsData)
})
.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), 0)
// Полное имя
const fullName = user
? [user.first_name, user.last_name].filter(Boolean).join(' ')
: ''
return (
<div className="pb-8">
{/* Карточка профиля */}
<div
className="bg-white shadow rounded mx-auto mt-8"
style={{ maxWidth: 600 }}
>
<div
className="rounded-top"
style={{
backgroundImage: `url('${user?.coverUrl ?? '/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={user?.avatarUrl ?? '/assets/img/avatar-dhg.png'}
alt="Avatar"
width={150}
height={150}
/>
</div>
<div className="px-4 pb-4 text-center">
<h3 className="mt-3">{fullName || user?.username}</h3>
<p className="text-muted mb-2">{user?.email}</p>
<p className="mb-3">{user?.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>
{/* Секция групп ссылок */}
<section className="mt-5">
<div className="container">
<div className="row justify-content-center">
<div className="col-xl-10 col-xxl-9">
<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={() => {
/* открыть модальное окно добавления группы */
}}
>
<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">
<li className="page-item">
<Link href="?page=1" className="page-link">1</Link>
</li>
<li className="page-item">
<Link href="?page=2" className="page-link">2</Link>
</li>
<li className="page-item">
<Link href="?page=3" className="page-link">3</Link>
</li>
</ul>
</nav>
</div>
</div>
</div>
</div>
</div>
</section>
</div>
)
}

View File

@@ -0,0 +1,229 @@
'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 User {
id: number
username: string
email: string
first_name?: string
last_name?: string
bio?: string
avatarUrl?: string
coverUrl?: 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<User | 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 ?? ''
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 userData = await uRes.json()
const groupsData = await gRes.json()
setUser(userData)
setGroups(groupsData)
})
.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), 0)
// Полное имя
const fullName = user
? [user.first_name, user.last_name].filter(Boolean).join(' ')
: ''
return (
<div className="pb-8">
{/* Карточка профиля */}
<div
className="bg-white shadow rounded mx-auto mt-8"
style={{ maxWidth: 600 }}
>
<div
className="rounded-top"
style={{
backgroundImage: `url('${user?.coverUrl ?? '/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={user?.avatarUrl ?? '/assets/img/avatar-dhg.png'}
alt="Avatar"
width={150}
height={150}
/>
</div>
<div className="px-4 pb-4 text-center">
<h3 className="mt-3">{fullName || user?.username}</h3>
<p className="text-muted mb-2">{user?.email}</p>
<p className="mb-3">{user?.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>
{/* Секция групп ссылок */}
<section className="mt-5">
<div className="container">
<div className="row justify-content-center">
<div className="col-xl-10 col-xxl-9">
<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={() => {
/* открыть модальное окно добавления группы */
}}
>
<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">
<li className="page-item">
<Link href="?page=1" className="page-link">1</Link>
</li>
<li className="page-item">
<Link href="?page=2" className="page-link">2</Link>
</li>
<li className="page-item">
<Link href="?page=3" className="page-link">3</Link>
</li>
</ul>
</nav>
</div>
</div>
</div>
</div>
</div>
</section>
</div>
)
}

View File

@@ -0,0 +1,191 @@
// 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 {
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
}
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)
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 ?? ''
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 userData = await uRes.json()
const groupsData = await gRes.json()
setUser(userData)
setGroups(groupsData)
})
.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), 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}
/>
)}
{/* Секция групп ссылок */}
<section className="mt-5">
<div className="container">
<div className="row justify-content-center">
<div className="col-xl-10 col-xxl-9">
<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={() => {
/* открыть модальное окно добавления группы */
}}
>
<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">
<li className="page-item">
<Link href="?page=1" className="page-link">1</Link>
</li>
<li className="page-item">
<Link href="?page=2" className="page-link">2</Link>
</li>
<li className="page-item">
<Link href="?page=3" className="page-link">3</Link>
</li>
</ul>
</nav>
</div>
</div>
</div>
</div>
</div>
</section>
</div>
)
}

View File

@@ -0,0 +1,191 @@
// 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)
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
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 userData: UserProfile = await uRes.json()
const groupsData: Group[] = await gRes.json()
setUser(userData)
setGroups(groupsData)
})
.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>
}
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={() => {
/* открыть модальное окно добавления группы */
}}
>
<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">
<li className="page-item">
<Link href="?page=1" className="page-link">
1
</Link>
</li>
<li className="page-item">
<Link href="?page=2" className="page-link">
2
</Link>
</li>
<li className="page-item">
<Link href="?page=3" className="page-link">
3
</Link>
</li>
</ul>
</nav>
</div>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,191 @@
// 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 {
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
}
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)
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 ?? ''
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 userData = await uRes.json()
const groupsData = await gRes.json()
setUser(userData)
setGroups(groupsData)
})
.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), 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}
/>
)}
{/* Секция групп ссылок */}
<section className="mt-5">
<div className="container">
<div className="row justify-content-center">
<div className="col-xl-10 col-xxl-9">
<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={() => {
/* открыть модальное окно добавления группы */
}}
>
<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">
<li className="page-item">
<Link href="?page=1" className="page-link">1</Link>
</li>
<li className="page-item">
<Link href="?page=2" className="page-link">2</Link>
</li>
<li className="page-item">
<Link href="?page=3" className="page-link">3</Link>
</li>
</ul>
</nav>
</div>
</div>
</div>
</div>
</div>
</section>
</div>
)
}

View File

@@ -0,0 +1,191 @@
// 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 {
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
}
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)
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 ?? ''
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 userData = await uRes.json()
const groupsData = await gRes.json()
setUser(userData)
setGroups(groupsData)
})
.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), 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}
/>
)}
{/* Секция групп ссылок */}
<section className="mt-5">
<div className="container">
<div className="row justify-content-center">
<div className="col-xl-10 col-xxl-9">
<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={() => {
/* открыть модальное окно добавления группы */
}}
>
<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">
<li className="page-item">
<Link href="?page=1" className="page-link">1</Link>
</li>
<li className="page-item">
<Link href="?page=2" className="page-link">2</Link>
</li>
<li className="page-item">
<Link href="?page=3" className="page-link">3</Link>
</li>
</ul>
</nav>
</div>
</div>
</div>
</div>
</div>
</section>
</div>
)
}

View File

@@ -0,0 +1,191 @@
// 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)
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
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 userData: UserProfile = await uRes.json()
const groupsData: Group[] = await gRes.json()
setUser(userData)
setGroups(groupsData)
})
.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>
}
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={() => {
/* открыть модальное окно добавления группы */
}}
>
<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">
<li className="page-item">
<Link href="?page=1" className="page-link">
1
</Link>
</li>
<li className="page-item">
<Link href="?page=2" className="page-link">
2
</Link>
</li>
<li className="page-item">
<Link href="?page=3" className="page-link">
3
</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,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,26 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}

View File

@@ -0,0 +1,32 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}
@tailwind base;
@tailwind components;
@tailwind utilities;
/* здесь можно добавить ваши дополнительные стили */

View File

@@ -0,0 +1,33 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}
/* src/app/globals.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
/* дальше — ваши кастом-стили */

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