init commit
This commit is contained in:
@@ -0,0 +1 @@
|
||||
NEXT_PUBLIC_API_URL=http://127.0.0.1:8000
|
||||
@@ -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("/assets/img/bg-showcase-1.jpg?h=717dfd74ae2c9ffe2373428a05a3f602");"><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("/assets/img/bg-showcase-2.jpg?h=82f59ff9dc7ce5bb277d6dfa737a6e45");"><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("/assets/img/bg-showcase-3.jpg?h=c7ec0329b8412e48f1b91e5c6a8cc7cf");"><span></span></div>
|
||||
<div class="col-lg-6 my-auto order-lg-1 showcase-text">
|
||||
<h2>Easy to Use & 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("/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">
|
||||
<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 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>
|
||||
@@ -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 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>
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -0,0 +1,9 @@
|
||||
// next.config.js
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
experimental: {
|
||||
allowedDevOrigins: ['http://192.168.219.114:3000'],
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
@@ -0,0 +1,9 @@
|
||||
// next.config.js
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
experimental: {
|
||||
allowedDevOrigins: ['http://192.168.219.114'],
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
@@ -0,0 +1,9 @@
|
||||
// next.config.js
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
experimental: {
|
||||
allowedDevOrigins: ['http://192.168.219.114:3000'],
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,10 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
// Если вы хотите явно указать, что используете App Router:
|
||||
experimental: { },
|
||||
|
||||
// Разрешённые origin для дев-сборки:
|
||||
allowedDevOrigins: ['http://192.168.219.114:3001'],
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
@@ -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'],
|
||||
}
|
||||
@@ -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'],
|
||||
}
|
||||
@@ -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'],
|
||||
}
|
||||
@@ -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'],
|
||||
}
|
||||
@@ -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: {},
|
||||
},
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
|
||||
},
|
||||
]
|
||||
|
||||
};
|
||||
@@ -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
|
||||
},
|
||||
]
|
||||
|
||||
};
|
||||
@@ -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*'
|
||||
}
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,5 @@
|
||||
const config = {
|
||||
plugins: ["@tailwindcss/postcss"],
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
// postcss.config.js
|
||||
module.exports = {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {}, // новый пакет-плагин для Tailwind v4+
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/* здесь можно добавить ваши дополнительные стили */
|
||||
@@ -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
Reference in New Issue
Block a user