refactor. pre-deploy
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
# Next.js Environment Variables
|
||||
# NOTE: For API calls in client components, use relative URLs like '/api/auth/login'
|
||||
# This variable is only used for server-side rendering and image URLs
|
||||
NEXT_PUBLIC_API_URL=http://localhost:8000
|
||||
@@ -0,0 +1,4 @@
|
||||
# Next.js Environment Variables
|
||||
# NOTE: For API calls in client components, use relative URLs like '/api/auth/login'
|
||||
# This variable is only used for server-side rendering and image URLs
|
||||
NEXT_PUBLIC_API_URL=http://localhost:8000
|
||||
@@ -0,0 +1,19 @@
|
||||
FROM node:18-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Копирование package.json и package-lock.json
|
||||
COPY package*.json ./
|
||||
|
||||
# Установка зависимостей
|
||||
RUN npm ci
|
||||
|
||||
# Копирование исходного кода
|
||||
COPY . .
|
||||
|
||||
# Сборка приложения
|
||||
RUN npm run build
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["npm", "start"]
|
||||
@@ -0,0 +1,19 @@
|
||||
FROM node:18-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Копирование package.json и package-lock.json
|
||||
COPY package*.json ./
|
||||
|
||||
# Установка зависимостей
|
||||
RUN npm ci
|
||||
|
||||
# Копирование исходного кода
|
||||
COPY . .
|
||||
|
||||
# Сборка приложения
|
||||
RUN npm run build
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["npm", "start"]
|
||||
@@ -0,0 +1,19 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Копирование package.json и package-lock.json
|
||||
COPY package*.json ./
|
||||
|
||||
# Установка зависимостей
|
||||
RUN npm install
|
||||
|
||||
# Копирование исходного кода
|
||||
COPY . .
|
||||
|
||||
# Сборка приложения
|
||||
RUN npm run build
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["npm", "start"]
|
||||
@@ -0,0 +1,19 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Копирование package.json и package-lock.json
|
||||
COPY package*.json ./
|
||||
|
||||
# Установка зависимостей
|
||||
RUN npm install
|
||||
|
||||
# Копирование исходного кода
|
||||
COPY . .
|
||||
|
||||
# Сборка приложения
|
||||
RUN npm run build
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["npm", "start"]
|
||||
@@ -0,0 +1,36 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: 'http',
|
||||
hostname: '127.0.0.1',
|
||||
port: '8000', // where Django serves media
|
||||
pathname: '/storage/**', // storage/avatars, images/link_groups, etc.
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// proxy all /api/* calls to Django
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
source: '/api/:path*',
|
||||
destination: 'http://127.0.0.1:8000/api/:path*',
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
eslint: {
|
||||
// Предупреждение: это позволяет продакшн сборки завершаться успешно даже если
|
||||
// ваш проект имеет ошибки ESLint.
|
||||
ignoreDuringBuilds: true,
|
||||
},
|
||||
typescript: {
|
||||
// Опасно: это позволяет продакшн сборки завершаться успешно даже если
|
||||
// ваш проект имеет ошибки TypeScript.
|
||||
ignoreBuildErrors: true,
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
@@ -0,0 +1,36 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: 'http',
|
||||
hostname: '127.0.0.1',
|
||||
port: '8000', // where Django serves media
|
||||
pathname: '/storage/**', // storage/avatars, images/link_groups, etc.
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// proxy all /api/* calls to Django
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
source: '/api/:path*',
|
||||
destination: 'http://127.0.0.1:8000/api/:path*',
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
eslint: {
|
||||
// Предупреждение: это позволяет продакшн сборки завершаться успешно даже если
|
||||
// ваш проект имеет ошибки ESLint.
|
||||
ignoreDuringBuilds: true,
|
||||
},
|
||||
typescript: {
|
||||
// Опасно: это позволяет продакшн сборки завершаться успешно даже если
|
||||
// ваш проект имеет ошибки TypeScript.
|
||||
ignoreBuildErrors: true,
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
@@ -0,0 +1,42 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: 'http',
|
||||
hostname: 'web',
|
||||
port: '8000', // where Django serves media
|
||||
pathname: '/storage/**', // storage/avatars, images/link_groups, etc.
|
||||
},
|
||||
{
|
||||
protocol: 'http',
|
||||
hostname: '127.0.0.1',
|
||||
port: '8000', // where Django serves media
|
||||
pathname: '/storage/**', // storage/avatars, images/link_groups, etc.
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// proxy all /api/* calls to Django
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
source: '/api/:path*',
|
||||
destination: 'http://web:8000/api/:path*',
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
eslint: {
|
||||
// Предупреждение: это позволяет продакшн сборки завершаться успешно даже если
|
||||
// ваш проект имеет ошибки ESLint.
|
||||
ignoreDuringBuilds: true,
|
||||
},
|
||||
typescript: {
|
||||
// Опасно: это позволяет продакшн сборки завершаться успешно даже если
|
||||
// ваш проект имеет ошибки TypeScript.
|
||||
ignoreBuildErrors: true,
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
@@ -0,0 +1,42 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: 'http',
|
||||
hostname: 'web',
|
||||
port: '8000', // where Django serves media
|
||||
pathname: '/storage/**', // storage/avatars, images/link_groups, etc.
|
||||
},
|
||||
{
|
||||
protocol: 'http',
|
||||
hostname: '127.0.0.1',
|
||||
port: '8000', // where Django serves media
|
||||
pathname: '/storage/**', // storage/avatars, images/link_groups, etc.
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// proxy all /api/* calls to Django
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
source: '/api/:path*',
|
||||
destination: 'http://web:8000/api/:path*',
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
eslint: {
|
||||
// Предупреждение: это позволяет продакшн сборки завершаться успешно даже если
|
||||
// ваш проект имеет ошибки ESLint.
|
||||
ignoreDuringBuilds: true,
|
||||
},
|
||||
typescript: {
|
||||
// Опасно: это позволяет продакшн сборки завершаться успешно даже если
|
||||
// ваш проект имеет ошибки TypeScript.
|
||||
ignoreBuildErrors: true,
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
@@ -0,0 +1,52 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: 'http',
|
||||
hostname: 'web',
|
||||
port: '8000', // where Django serves media
|
||||
pathname: '/storage/**', // storage/avatars, images/link_groups, etc.
|
||||
},
|
||||
{
|
||||
protocol: 'http',
|
||||
hostname: '127.0.0.1',
|
||||
port: '8000', // where Django serves media
|
||||
pathname: '/storage/**', // storage/avatars, images/link_groups, etc.
|
||||
},
|
||||
{
|
||||
protocol: 'http',
|
||||
hostname: 'localhost',
|
||||
port: '8000', // where Django serves media
|
||||
pathname: '/storage/**', // storage/avatars, images/link_groups, etc.
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// proxy all /api/* calls to Django
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
source: '/api/:path*',
|
||||
destination: 'http://web:8000/api/:path*',
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
eslint: {
|
||||
// Предупреждение: это позволяет продакшн сборки завершаться успешно даже если
|
||||
// ваш проект имеет ошибки ESLint.
|
||||
ignoreDuringBuilds: true,
|
||||
},
|
||||
typescript: {
|
||||
// Опасно: это позволяет продакшн сборки завершаться успешно даже если
|
||||
// ваш проект имеет ошибки TypeScript.
|
||||
ignoreBuildErrors: true,
|
||||
},
|
||||
|
||||
// Добавляем настройки для правильной работы в development
|
||||
trailingSlash: false,
|
||||
skipTrailingSlashRedirect: true,
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
@@ -0,0 +1,59 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: 'http',
|
||||
hostname: 'web',
|
||||
port: '8000',
|
||||
pathname: '/storage/**',
|
||||
},
|
||||
{
|
||||
protocol: 'http',
|
||||
hostname: '127.0.0.1',
|
||||
port: '8000',
|
||||
pathname: '/storage/**',
|
||||
},
|
||||
{
|
||||
protocol: 'http',
|
||||
hostname: 'localhost',
|
||||
port: '8000',
|
||||
pathname: '/storage/**',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
source: '/api/:path*',
|
||||
destination: 'http://web:8000/api/:path*',
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
eslint: {
|
||||
ignoreDuringBuilds: true,
|
||||
},
|
||||
typescript: {
|
||||
ignoreBuildErrors: true,
|
||||
},
|
||||
|
||||
trailingSlash: false,
|
||||
skipTrailingSlashRedirect: true,
|
||||
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: '/api/:path*',
|
||||
headers: [
|
||||
{ key: 'Access-Control-Allow-Origin', value: '*' },
|
||||
{ key: 'Access-Control-Allow-Methods', value: 'GET, POST, PUT, DELETE, OPTIONS' },
|
||||
{ key: 'Access-Control-Allow-Headers', value: 'Content-Type, Authorization' },
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -0,0 +1,59 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: 'http',
|
||||
hostname: 'web',
|
||||
port: '8000',
|
||||
pathname: '/storage/**',
|
||||
},
|
||||
{
|
||||
protocol: 'http',
|
||||
hostname: '127.0.0.1',
|
||||
port: '8000',
|
||||
pathname: '/storage/**',
|
||||
},
|
||||
{
|
||||
protocol: 'http',
|
||||
hostname: 'localhost',
|
||||
port: '8000',
|
||||
pathname: '/storage/**',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
source: '/api/:path*',
|
||||
destination: 'http://web:8000/api/:path*',
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
eslint: {
|
||||
ignoreDuringBuilds: true,
|
||||
},
|
||||
typescript: {
|
||||
ignoreBuildErrors: true,
|
||||
},
|
||||
|
||||
trailingSlash: false,
|
||||
skipTrailingSlashRedirect: true,
|
||||
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: '/api/:path*',
|
||||
headers: [
|
||||
{ key: 'Access-Control-Allow-Origin', value: '*' },
|
||||
{ key: 'Access-Control-Allow-Methods', value: 'GET, POST, PUT, DELETE, OPTIONS' },
|
||||
{ key: 'Access-Control-Allow-Headers', value: 'Content-Type, Authorization' },
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -0,0 +1,52 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: 'http',
|
||||
hostname: 'web',
|
||||
port: '8000', // where Django serves media
|
||||
pathname: '/storage/**', // storage/avatars, images/link_groups, etc.
|
||||
},
|
||||
{
|
||||
protocol: 'http',
|
||||
hostname: '127.0.0.1',
|
||||
port: '8000', // where Django serves media
|
||||
pathname: '/storage/**', // storage/avatars, images/link_groups, etc.
|
||||
},
|
||||
{
|
||||
protocol: 'http',
|
||||
hostname: 'localhost',
|
||||
port: '8000', // where Django serves media
|
||||
pathname: '/storage/**', // storage/avatars, images/link_groups, etc.
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// proxy all /api/* calls to Django
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
source: '/api/:path*',
|
||||
destination: 'http://web:8000/api/:path*',
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
eslint: {
|
||||
// Предупреждение: это позволяет продакшн сборки завершаться успешно даже если
|
||||
// ваш проект имеет ошибки ESLint.
|
||||
ignoreDuringBuilds: true,
|
||||
},
|
||||
typescript: {
|
||||
// Опасно: это позволяет продакшн сборки завершаться успешно даже если
|
||||
// ваш проект имеет ошибки TypeScript.
|
||||
ignoreBuildErrors: true,
|
||||
},
|
||||
|
||||
// Добавляем настройки для правильной работы в development
|
||||
trailingSlash: false,
|
||||
skipTrailingSlashRedirect: true,
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
@@ -0,0 +1,56 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: 'http',
|
||||
hostname: 'web',
|
||||
port: '8000', // where Django serves media
|
||||
pathname: '/storage/**', // storage/avatars, images/link_groups, etc.
|
||||
},
|
||||
{
|
||||
protocol: 'http',
|
||||
hostname: '127.0.0.1',
|
||||
port: '8000', // where Django serves media
|
||||
pathname: '/storage/**', // storage/avatars, images/link_groups, etc.
|
||||
},
|
||||
{
|
||||
protocol: 'http',
|
||||
hostname: 'localhost',
|
||||
port: '8000', // where Django serves media
|
||||
pathname: '/storage/**', // storage/avatars, images/link_groups, etc.
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// proxy all /api/* calls to Django
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
source: '/api/:path*/',
|
||||
destination: 'http://web:8000/api/:path*/',
|
||||
},
|
||||
{
|
||||
source: '/api/:path*',
|
||||
destination: 'http://web:8000/api/:path*/',
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
eslint: {
|
||||
// Предупреждение: это позволяет продакшн сборки завершаться успешно даже если
|
||||
// ваш проект имеет ошибки ESLint.
|
||||
ignoreDuringBuilds: true,
|
||||
},
|
||||
typescript: {
|
||||
// Опасно: это позволяет продакшн сборки завершаться успешно даже если
|
||||
// ваш проект имеет ошибки TypeScript.
|
||||
ignoreBuildErrors: true,
|
||||
},
|
||||
|
||||
// Добавляем настройки для правильной работы в development
|
||||
trailingSlash: false,
|
||||
skipTrailingSlashRedirect: true,
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
@@ -0,0 +1,56 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: 'http',
|
||||
hostname: 'web',
|
||||
port: '8000', // where Django serves media
|
||||
pathname: '/storage/**', // storage/avatars, images/link_groups, etc.
|
||||
},
|
||||
{
|
||||
protocol: 'http',
|
||||
hostname: '127.0.0.1',
|
||||
port: '8000', // where Django serves media
|
||||
pathname: '/storage/**', // storage/avatars, images/link_groups, etc.
|
||||
},
|
||||
{
|
||||
protocol: 'http',
|
||||
hostname: 'localhost',
|
||||
port: '8000', // where Django serves media
|
||||
pathname: '/storage/**', // storage/avatars, images/link_groups, etc.
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// proxy all /api/* calls to Django
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
source: '/api/:path*/',
|
||||
destination: 'http://web:8000/api/:path*/',
|
||||
},
|
||||
{
|
||||
source: '/api/:path*',
|
||||
destination: 'http://web:8000/api/:path*/',
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
eslint: {
|
||||
// Предупреждение: это позволяет продакшн сборки завершаться успешно даже если
|
||||
// ваш проект имеет ошибки ESLint.
|
||||
ignoreDuringBuilds: true,
|
||||
},
|
||||
typescript: {
|
||||
// Опасно: это позволяет продакшн сборки завершаться успешно даже если
|
||||
// ваш проект имеет ошибки TypeScript.
|
||||
ignoreBuildErrors: true,
|
||||
},
|
||||
|
||||
// Добавляем настройки для правильной работы в development
|
||||
trailingSlash: false,
|
||||
skipTrailingSlashRedirect: true,
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "linktree-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.9.0",
|
||||
"next": "15.3.1",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hook-form": "^7.56.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
"@tailwindcss/postcss": "^4.1.5",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.3.1",
|
||||
"postcss": "^8.5.3",
|
||||
"tailwindcss": "^4.1.5",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "linktree-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.9.0",
|
||||
"next": "15.3.1",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hook-form": "^7.56.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
"@tailwindcss/postcss": "^4.1.5",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.3.1",
|
||||
"postcss": "^8.5.3",
|
||||
"tailwindcss": "^4.1.5",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "linktree-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.9.0",
|
||||
"next": "15.3.1",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hook-form": "^7.56.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
"@tailwindcss/postcss": "^4.1.5",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.3.1",
|
||||
"postcss": "^8.5.3",
|
||||
"tailwindcss": "^4.1.5",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "linktree-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.9.0",
|
||||
"next": "15.3.1",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hook-form": "^7.56.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
"@tailwindcss/postcss": "^4.1.5",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.3.1",
|
||||
"postcss": "^8.5.3",
|
||||
"tailwindcss": "^4.1.5",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
'/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,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(
|
||||
'/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,390 @@
|
||||
'use client'
|
||||
|
||||
import React, { useEffect, useState, Fragment } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import Image from 'next/image'
|
||||
import { ProfileCard } from '../../components/ProfileCard'
|
||||
|
||||
interface UserProfile {
|
||||
id: number
|
||||
username: string
|
||||
email: string
|
||||
full_name: string
|
||||
bio?: string
|
||||
avatar: string
|
||||
last_login: string
|
||||
date_joined: string
|
||||
}
|
||||
|
||||
interface LinkItem {
|
||||
id: number
|
||||
title: string
|
||||
url: string
|
||||
icon?: string
|
||||
group: number
|
||||
}
|
||||
|
||||
interface Group {
|
||||
id: number
|
||||
name: string
|
||||
icon?: string
|
||||
links: LinkItem[]
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const router = useRouter()
|
||||
const [user, setUser] = useState<UserProfile | null>(null)
|
||||
const [groups, setGroups] = useState<Group[]>([])
|
||||
const [expandedGroup, setExpandedGroup] = useState<number | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// === Для модалок групп ===
|
||||
const [showGroupModal, setShowGroupModal] = useState(false)
|
||||
const [groupModalMode, setGroupModalMode] = useState<'add' | 'edit'>('add')
|
||||
const [editingGroup, setEditingGroup] = useState<Group | null>(null)
|
||||
const [groupForm, setGroupForm] = useState<{ name: string; iconFile: File | null }>({
|
||||
name: '',
|
||||
iconFile: null,
|
||||
})
|
||||
|
||||
// === Для модалок ссылок ===
|
||||
const [showLinkModal, setShowLinkModal] = useState(false)
|
||||
const [linkModalMode, setLinkModalMode] = useState<'add' | 'edit'>('add')
|
||||
const [editingLink, setEditingLink] = useState<LinkItem | null>(null)
|
||||
const [currentGroupIdForLink, setCurrentGroupIdForLink] = useState<number | null>(null)
|
||||
const [linkForm, setLinkForm] = useState<{ title: string; url: string; iconFile: File | null }>({
|
||||
title: '',
|
||||
url: '',
|
||||
iconFile: null,
|
||||
})
|
||||
|
||||
const API = ''
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (!token) {
|
||||
router.push('/auth/login')
|
||||
return
|
||||
}
|
||||
// загружаем профиль, группы и ссылки
|
||||
Promise.all([
|
||||
fetch('/api/auth/user', { headers: { Authorization: `Bearer ${token}` } }),
|
||||
fetch('/api/groups', { headers: { Authorization: `Bearer ${token}` } }),
|
||||
fetch('/api/links', { headers: { Authorization: `Bearer ${token}` } }),
|
||||
])
|
||||
.then(async ([uRes, gRes, lRes]) => {
|
||||
if (!uRes.ok) throw new Error('Не удалось получить профиль')
|
||||
if (!gRes.ok) throw new Error('Не удалось загрузить группы')
|
||||
if (!lRes.ok) throw new Error('Не удалось загрузить ссылки')
|
||||
const userData = await uRes.json()
|
||||
const groupsData = await gRes.json()
|
||||
const linksData = await lRes.json()
|
||||
|
||||
// «привязываем» ссылки к группам
|
||||
const enrichedGroups: Group[] = groupsData.map((grp: any) => ({
|
||||
...grp,
|
||||
links: linksData.filter((link: LinkItem) => link.group === grp.id),
|
||||
}))
|
||||
setUser(userData)
|
||||
setGroups(enrichedGroups)
|
||||
})
|
||||
.catch(err => setError((err as Error).message))
|
||||
.finally(() => setLoading(false))
|
||||
}, [router])
|
||||
|
||||
// Перезагрузка списка групп и ссылок
|
||||
async function reloadData() {
|
||||
const token = localStorage.getItem('token')!
|
||||
const [gRes, lRes] = await Promise.all([
|
||||
fetch(`${API}/api/groups/`, { headers: { Authorization: `Bearer ${token}` } }),
|
||||
fetch(`${API}/api/links/`, { headers: { Authorization: `Bearer ${token}` } }),
|
||||
])
|
||||
const groupsData = await gRes.json()
|
||||
const linksData = await lRes.json()
|
||||
const enrichedGroups: Group[] = groupsData.map((grp: any) => ({
|
||||
...grp,
|
||||
links: linksData.filter((link: LinkItem) => link.group === grp.id),
|
||||
}))
|
||||
setGroups(enrichedGroups)
|
||||
}
|
||||
|
||||
// === Обработчики групп ===
|
||||
function openAddGroup() {
|
||||
setGroupModalMode('add')
|
||||
setGroupForm({ name: '', iconFile: null })
|
||||
setShowGroupModal(true)
|
||||
}
|
||||
function openEditGroup(grp: Group) {
|
||||
setGroupModalMode('edit')
|
||||
setEditingGroup(grp)
|
||||
setGroupForm({ name: grp.name, iconFile: null })
|
||||
setShowGroupModal(true)
|
||||
}
|
||||
async function handleGroupSubmit() {
|
||||
const token = localStorage.getItem('token')!
|
||||
const fd = new FormData()
|
||||
fd.append('name', groupForm.name)
|
||||
if (groupForm.iconFile) fd.append('icon', groupForm.iconFile)
|
||||
const url = groupModalMode === 'add'
|
||||
? `${API}/api/groups/`
|
||||
: `${API}/api/groups/${editingGroup?.id}/`
|
||||
const method = groupModalMode === 'add' ? 'POST' : 'PATCH'
|
||||
await fetch(url, {
|
||||
method,
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: fd,
|
||||
})
|
||||
setShowGroupModal(false)
|
||||
await reloadData()
|
||||
}
|
||||
async function handleDeleteGroup(grp: Group) {
|
||||
if (!confirm(`Удалить группу "${grp.name}"?`)) return
|
||||
const token = localStorage.getItem('token')!
|
||||
await fetch(`${API}/api/groups/${grp.id}/`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
await reloadData()
|
||||
}
|
||||
|
||||
// === Обработчики ссылок ===
|
||||
function openAddLink(grp: Group) {
|
||||
setLinkModalMode('add')
|
||||
setCurrentGroupIdForLink(grp.id)
|
||||
setLinkForm({ title: '', url: '', iconFile: null })
|
||||
setShowLinkModal(true)
|
||||
}
|
||||
function openEditLink(link: LinkItem) {
|
||||
setLinkModalMode('edit')
|
||||
setEditingLink(link)
|
||||
setCurrentGroupIdForLink(link.group)
|
||||
setLinkForm({ title: link.title, url: link.url, iconFile: null })
|
||||
setShowLinkModal(true)
|
||||
}
|
||||
async function handleLinkSubmit() {
|
||||
const token = localStorage.getItem('token')!
|
||||
const fd = new FormData()
|
||||
fd.append('title', linkForm.title)
|
||||
fd.append('url', linkForm.url)
|
||||
if (linkForm.iconFile) fd.append('icon', linkForm.iconFile)
|
||||
fd.append('group', String(currentGroupIdForLink))
|
||||
const url = linkModalMode === 'add'
|
||||
? `${API}/api/links/`
|
||||
: `${API}/api/links/${editingLink?.id}/`
|
||||
const method = linkModalMode === 'add' ? 'POST' : 'PATCH'
|
||||
await fetch(url, {
|
||||
method,
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: fd,
|
||||
})
|
||||
setShowLinkModal(false)
|
||||
await reloadData()
|
||||
}
|
||||
async function handleDeleteLink(link: LinkItem) {
|
||||
if (!confirm(`Удалить ссылку "${link.title}"?`)) return
|
||||
const token = localStorage.getItem('token')!
|
||||
await fetch(`${API}/api/links/${link.id}/`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
await reloadData()
|
||||
}
|
||||
|
||||
if (loading) return <div className="flex items-center justify-center h-screen">Загрузка...</div>
|
||||
if (error) return <div className="p-6 text-red-600 max-w-xl mx-auto">{error}</div>
|
||||
|
||||
const totalGroups = groups.length
|
||||
const totalLinks = groups.reduce((sum, grp) => sum + grp.links.length, 0)
|
||||
|
||||
return (
|
||||
<div className="pb-8">
|
||||
{user && (
|
||||
<ProfileCard
|
||||
avatar={user.avatar}
|
||||
full_name={user.full_name}
|
||||
email={user.email}
|
||||
bio={user.bio}
|
||||
last_login={user.last_login}
|
||||
date_joined={user.date_joined}
|
||||
totalGroups={totalGroups}
|
||||
totalLinks={totalLinks}
|
||||
/>
|
||||
)}
|
||||
|
||||
<section className="mt-5 container">
|
||||
<div className="card shadow">
|
||||
<div className="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 className="mb-0">Группы ссылок</h5>
|
||||
<button className="btn btn-sm btn-success" onClick={openAddGroup}>
|
||||
<i className="bi bi-plus-lg"></i> Добавить группу
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="list-group list-group-flush">
|
||||
{groups.map(group => (
|
||||
<Fragment key={group.id}>
|
||||
<div className="list-group-item d-flex justify-content-between align-items-center">
|
||||
<div
|
||||
className="d-flex align-items-center"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() =>
|
||||
setExpandedGroup(expandedGroup === group.id ? null : group.id)
|
||||
}
|
||||
>
|
||||
{group.icon && (
|
||||
<img
|
||||
src={`${API}${group.icon}`}
|
||||
width={32}
|
||||
height={32}
|
||||
className="me-2 rounded"
|
||||
alt={group.name}
|
||||
/>
|
||||
)}
|
||||
<strong className="me-2">{group.name}</strong>
|
||||
<span className="badge bg-secondary rounded-pill">
|
||||
{group.links.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="btn-group btn-group-sm">
|
||||
<button onClick={() => openAddLink(group)} className="btn btn-outline-primary">
|
||||
<i className="bi bi-link-45deg"></i>
|
||||
</button>
|
||||
<button onClick={() => openEditGroup(group)} className="btn btn-outline-secondary">
|
||||
<i className="bi bi-pencil"></i>
|
||||
</button>
|
||||
<button onClick={() => handleDeleteGroup(group)} className="btn btn-outline-danger">
|
||||
<i className="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expandedGroup === group.id && (
|
||||
<div className="list-group-item bg-light">
|
||||
<ul className="mb-0 ps-3">
|
||||
{group.links.map(link => (
|
||||
<li
|
||||
key={link.id}
|
||||
className="d-flex justify-content-between align-items-center mb-2"
|
||||
>
|
||||
<div className="d-flex align-items-center">
|
||||
{link.icon && (
|
||||
<img
|
||||
src={link.icon.startsWith('http')
|
||||
? link.icon
|
||||
: `${API}${link.icon}`}
|
||||
width={24}
|
||||
height={24}
|
||||
className="me-2"
|
||||
alt={link.title}
|
||||
/>
|
||||
)}
|
||||
<a href={link.url} target="_blank" rel="noopener noreferrer">
|
||||
{link.title}
|
||||
</a>
|
||||
</div>
|
||||
<div className="btn-group btn-group-sm">
|
||||
<button onClick={() => openEditLink(link)} className="btn btn-outline-secondary">
|
||||
<i className="bi bi-pencil-fill"></i>
|
||||
</button>
|
||||
<button onClick={() => handleDeleteLink(link)} className="btn btn-outline-danger">
|
||||
<i className="bi bi-trash-fill"></i>
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Модалка добавления/редактирования группы */}
|
||||
<div className={`modal ${showGroupModal ? 'd-block' : 'd-none'}`} tabIndex={-1}>
|
||||
<div className="modal-dialog">
|
||||
<div className="modal-content">
|
||||
<div className="modal-header">
|
||||
<h5 className="modal-title">{groupModalMode === 'add' ? 'Добавить группу' : 'Редактировать группу'}</h5>
|
||||
<button type="button" className="btn-close" onClick={() => setShowGroupModal(false)} />
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Название</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
value={groupForm.name}
|
||||
onChange={e => setGroupForm(f => ({ ...f, name: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Иконка (опционально)</label>
|
||||
<input
|
||||
type="file"
|
||||
className="form-control"
|
||||
onChange={e => setGroupForm(f => ({ ...f, iconFile: e.target.files?.[0] || null }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button className="btn btn-secondary" onClick={() => setShowGroupModal(false)}>Отмена</button>
|
||||
<button className="btn btn-primary" onClick={handleGroupSubmit}>
|
||||
Сохранить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Модалка добавления/редактирования ссылки */}
|
||||
<div className={`modal ${showLinkModal ? 'd-block' : 'd-none'}`} tabIndex={-1}>
|
||||
<div className="modal-dialog">
|
||||
<div className="modal-content">
|
||||
<div className="modal-header">
|
||||
<h5 className="modal-title">{linkModalMode === 'add' ? 'Добавить ссылку' : 'Редактировать ссылку'}</h5>
|
||||
<button type="button" className="btn-close" onClick={() => setShowLinkModal(false)} />
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Заголовок</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
value={linkForm.title}
|
||||
onChange={e => setLinkForm(f => ({ ...f, title: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label className="form-label">URL</label>
|
||||
<input
|
||||
type="url"
|
||||
className="form-control"
|
||||
value={linkForm.url}
|
||||
onChange={e => setLinkForm(f => ({ ...f, url: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Иконка (опционально)</label>
|
||||
<input
|
||||
type="file"
|
||||
className="form-control"
|
||||
onChange={e => setLinkForm(f => ({ ...f, iconFile: e.target.files?.[0] || null }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button className="btn btn-secondary" onClick={() => setShowLinkModal(false)}>Отмена</button>
|
||||
<button className="btn btn-primary" onClick={handleLinkSubmit}>
|
||||
Сохранить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
'use client'
|
||||
|
||||
import React, { useEffect, useState, Fragment } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import Image from 'next/image'
|
||||
import { ProfileCard } from '../../components/ProfileCard'
|
||||
|
||||
interface UserProfile {
|
||||
id: number
|
||||
username: string
|
||||
email: string
|
||||
full_name: string
|
||||
bio?: string
|
||||
avatar: string
|
||||
last_login: string
|
||||
date_joined: string
|
||||
}
|
||||
|
||||
interface LinkItem {
|
||||
id: number
|
||||
title: string
|
||||
url: string
|
||||
icon?: string
|
||||
group: number
|
||||
}
|
||||
|
||||
interface Group {
|
||||
id: number
|
||||
name: string
|
||||
icon?: string
|
||||
links: LinkItem[]
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const router = useRouter()
|
||||
const [user, setUser] = useState<UserProfile | null>(null)
|
||||
const [groups, setGroups] = useState<Group[]>([])
|
||||
const [expandedGroup, setExpandedGroup] = useState<number | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// === Для модалок групп ===
|
||||
const [showGroupModal, setShowGroupModal] = useState(false)
|
||||
const [groupModalMode, setGroupModalMode] = useState<'add' | 'edit'>('add')
|
||||
const [editingGroup, setEditingGroup] = useState<Group | null>(null)
|
||||
const [groupForm, setGroupForm] = useState<{ name: string; iconFile: File | null }>({
|
||||
name: '',
|
||||
iconFile: null,
|
||||
})
|
||||
|
||||
// === Для модалок ссылок ===
|
||||
const [showLinkModal, setShowLinkModal] = useState(false)
|
||||
const [linkModalMode, setLinkModalMode] = useState<'add' | 'edit'>('add')
|
||||
const [editingLink, setEditingLink] = useState<LinkItem | null>(null)
|
||||
const [currentGroupIdForLink, setCurrentGroupIdForLink] = useState<number | null>(null)
|
||||
const [linkForm, setLinkForm] = useState<{ title: string; url: string; iconFile: File | null }>({
|
||||
title: '',
|
||||
url: '',
|
||||
iconFile: null,
|
||||
})
|
||||
|
||||
const API = ''
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (!token) {
|
||||
router.push('/auth/login')
|
||||
return
|
||||
}
|
||||
// загружаем профиль, группы и ссылки
|
||||
Promise.all([
|
||||
fetch('/api/auth/user', { headers: { Authorization: `Bearer ${token}` } }),
|
||||
fetch('/api/groups', { headers: { Authorization: `Bearer ${token}` } }),
|
||||
fetch('/api/links', { headers: { Authorization: `Bearer ${token}` } }),
|
||||
])
|
||||
.then(async ([uRes, gRes, lRes]) => {
|
||||
if (!uRes.ok) throw new Error('Не удалось получить профиль')
|
||||
if (!gRes.ok) throw new Error('Не удалось загрузить группы')
|
||||
if (!lRes.ok) throw new Error('Не удалось загрузить ссылки')
|
||||
const userData = await uRes.json()
|
||||
const groupsData = await gRes.json()
|
||||
const linksData = await lRes.json()
|
||||
|
||||
// «привязываем» ссылки к группам
|
||||
const enrichedGroups: Group[] = groupsData.map((grp: any) => ({
|
||||
...grp,
|
||||
links: linksData.filter((link: LinkItem) => link.group === grp.id),
|
||||
}))
|
||||
setUser(userData)
|
||||
setGroups(enrichedGroups)
|
||||
})
|
||||
.catch(err => setError((err as Error).message))
|
||||
.finally(() => setLoading(false))
|
||||
}, [router])
|
||||
|
||||
// Перезагрузка списка групп и ссылок
|
||||
async function reloadData() {
|
||||
const token = localStorage.getItem('token')!
|
||||
const [gRes, lRes] = await Promise.all([
|
||||
fetch('/api/groups', { headers: { Authorization: `Bearer ${token}` } }),
|
||||
fetch('/api/links', { headers: { Authorization: `Bearer ${token}` } }),
|
||||
])
|
||||
const groupsData = await gRes.json()
|
||||
const linksData = await lRes.json()
|
||||
const enrichedGroups: Group[] = groupsData.map((grp: any) => ({
|
||||
...grp,
|
||||
links: linksData.filter((link: LinkItem) => link.group === grp.id),
|
||||
}))
|
||||
setGroups(enrichedGroups)
|
||||
}
|
||||
|
||||
// === Обработчики групп ===
|
||||
function openAddGroup() {
|
||||
setGroupModalMode('add')
|
||||
setGroupForm({ name: '', iconFile: null })
|
||||
setShowGroupModal(true)
|
||||
}
|
||||
function openEditGroup(grp: Group) {
|
||||
setGroupModalMode('edit')
|
||||
setEditingGroup(grp)
|
||||
setGroupForm({ name: grp.name, iconFile: null })
|
||||
setShowGroupModal(true)
|
||||
}
|
||||
async function handleGroupSubmit() {
|
||||
const token = localStorage.getItem('token')!
|
||||
const fd = new FormData()
|
||||
fd.append('name', groupForm.name)
|
||||
if (groupForm.iconFile) fd.append('icon', groupForm.iconFile)
|
||||
const url = groupModalMode === 'add'
|
||||
? `${API}/api/groups/`
|
||||
: `${API}/api/groups/${editingGroup?.id}/`
|
||||
const method = groupModalMode === 'add' ? 'POST' : 'PATCH'
|
||||
await fetch(url, {
|
||||
method,
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: fd,
|
||||
})
|
||||
setShowGroupModal(false)
|
||||
await reloadData()
|
||||
}
|
||||
async function handleDeleteGroup(grp: Group) {
|
||||
if (!confirm(`Удалить группу "${grp.name}"?`)) return
|
||||
const token = localStorage.getItem('token')!
|
||||
await fetch(`${API}/api/groups/${grp.id}/`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
await reloadData()
|
||||
}
|
||||
|
||||
// === Обработчики ссылок ===
|
||||
function openAddLink(grp: Group) {
|
||||
setLinkModalMode('add')
|
||||
setCurrentGroupIdForLink(grp.id)
|
||||
setLinkForm({ title: '', url: '', iconFile: null })
|
||||
setShowLinkModal(true)
|
||||
}
|
||||
function openEditLink(link: LinkItem) {
|
||||
setLinkModalMode('edit')
|
||||
setEditingLink(link)
|
||||
setCurrentGroupIdForLink(link.group)
|
||||
setLinkForm({ title: link.title, url: link.url, iconFile: null })
|
||||
setShowLinkModal(true)
|
||||
}
|
||||
async function handleLinkSubmit() {
|
||||
const token = localStorage.getItem('token')!
|
||||
const fd = new FormData()
|
||||
fd.append('title', linkForm.title)
|
||||
fd.append('url', linkForm.url)
|
||||
if (linkForm.iconFile) fd.append('icon', linkForm.iconFile)
|
||||
fd.append('group', String(currentGroupIdForLink))
|
||||
const url = linkModalMode === 'add'
|
||||
? `${API}/api/links/`
|
||||
: `${API}/api/links/${editingLink?.id}/`
|
||||
const method = linkModalMode === 'add' ? 'POST' : 'PATCH'
|
||||
await fetch(url, {
|
||||
method,
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: fd,
|
||||
})
|
||||
setShowLinkModal(false)
|
||||
await reloadData()
|
||||
}
|
||||
async function handleDeleteLink(link: LinkItem) {
|
||||
if (!confirm(`Удалить ссылку "${link.title}"?`)) return
|
||||
const token = localStorage.getItem('token')!
|
||||
await fetch(`${API}/api/links/${link.id}/`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
await reloadData()
|
||||
}
|
||||
|
||||
if (loading) return <div className="flex items-center justify-center h-screen">Загрузка...</div>
|
||||
if (error) return <div className="p-6 text-red-600 max-w-xl mx-auto">{error}</div>
|
||||
|
||||
const totalGroups = groups.length
|
||||
const totalLinks = groups.reduce((sum, grp) => sum + grp.links.length, 0)
|
||||
|
||||
return (
|
||||
<div className="pb-8">
|
||||
{user && (
|
||||
<ProfileCard
|
||||
avatar={user.avatar}
|
||||
full_name={user.full_name}
|
||||
email={user.email}
|
||||
bio={user.bio}
|
||||
last_login={user.last_login}
|
||||
date_joined={user.date_joined}
|
||||
totalGroups={totalGroups}
|
||||
totalLinks={totalLinks}
|
||||
/>
|
||||
)}
|
||||
|
||||
<section className="mt-5 container">
|
||||
<div className="card shadow">
|
||||
<div className="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 className="mb-0">Группы ссылок</h5>
|
||||
<button className="btn btn-sm btn-success" onClick={openAddGroup}>
|
||||
<i className="bi bi-plus-lg"></i> Добавить группу
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="list-group list-group-flush">
|
||||
{groups.map(group => (
|
||||
<Fragment key={group.id}>
|
||||
<div className="list-group-item d-flex justify-content-between align-items-center">
|
||||
<div
|
||||
className="d-flex align-items-center"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() =>
|
||||
setExpandedGroup(expandedGroup === group.id ? null : group.id)
|
||||
}
|
||||
>
|
||||
{group.icon && (
|
||||
<img
|
||||
src={`${API}${group.icon}`}
|
||||
width={32}
|
||||
height={32}
|
||||
className="me-2 rounded"
|
||||
alt={group.name}
|
||||
/>
|
||||
)}
|
||||
<strong className="me-2">{group.name}</strong>
|
||||
<span className="badge bg-secondary rounded-pill">
|
||||
{group.links.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="btn-group btn-group-sm">
|
||||
<button onClick={() => openAddLink(group)} className="btn btn-outline-primary">
|
||||
<i className="bi bi-link-45deg"></i>
|
||||
</button>
|
||||
<button onClick={() => openEditGroup(group)} className="btn btn-outline-secondary">
|
||||
<i className="bi bi-pencil"></i>
|
||||
</button>
|
||||
<button onClick={() => handleDeleteGroup(group)} className="btn btn-outline-danger">
|
||||
<i className="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expandedGroup === group.id && (
|
||||
<div className="list-group-item bg-light">
|
||||
<ul className="mb-0 ps-3">
|
||||
{group.links.map(link => (
|
||||
<li
|
||||
key={link.id}
|
||||
className="d-flex justify-content-between align-items-center mb-2"
|
||||
>
|
||||
<div className="d-flex align-items-center">
|
||||
{link.icon && (
|
||||
<img
|
||||
src={link.icon.startsWith('http')
|
||||
? link.icon
|
||||
: `${API}${link.icon}`}
|
||||
width={24}
|
||||
height={24}
|
||||
className="me-2"
|
||||
alt={link.title}
|
||||
/>
|
||||
)}
|
||||
<a href={link.url} target="_blank" rel="noopener noreferrer">
|
||||
{link.title}
|
||||
</a>
|
||||
</div>
|
||||
<div className="btn-group btn-group-sm">
|
||||
<button onClick={() => openEditLink(link)} className="btn btn-outline-secondary">
|
||||
<i className="bi bi-pencil-fill"></i>
|
||||
</button>
|
||||
<button onClick={() => handleDeleteLink(link)} className="btn btn-outline-danger">
|
||||
<i className="bi bi-trash-fill"></i>
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Модалка добавления/редактирования группы */}
|
||||
<div className={`modal ${showGroupModal ? 'd-block' : 'd-none'}`} tabIndex={-1}>
|
||||
<div className="modal-dialog">
|
||||
<div className="modal-content">
|
||||
<div className="modal-header">
|
||||
<h5 className="modal-title">{groupModalMode === 'add' ? 'Добавить группу' : 'Редактировать группу'}</h5>
|
||||
<button type="button" className="btn-close" onClick={() => setShowGroupModal(false)} />
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Название</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
value={groupForm.name}
|
||||
onChange={e => setGroupForm(f => ({ ...f, name: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Иконка (опционально)</label>
|
||||
<input
|
||||
type="file"
|
||||
className="form-control"
|
||||
onChange={e => setGroupForm(f => ({ ...f, iconFile: e.target.files?.[0] || null }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button className="btn btn-secondary" onClick={() => setShowGroupModal(false)}>Отмена</button>
|
||||
<button className="btn btn-primary" onClick={handleGroupSubmit}>
|
||||
Сохранить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Модалка добавления/редактирования ссылки */}
|
||||
<div className={`modal ${showLinkModal ? 'd-block' : 'd-none'}`} tabIndex={-1}>
|
||||
<div className="modal-dialog">
|
||||
<div className="modal-content">
|
||||
<div className="modal-header">
|
||||
<h5 className="modal-title">{linkModalMode === 'add' ? 'Добавить ссылку' : 'Редактировать ссылку'}</h5>
|
||||
<button type="button" className="btn-close" onClick={() => setShowLinkModal(false)} />
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Заголовок</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
value={linkForm.title}
|
||||
onChange={e => setLinkForm(f => ({ ...f, title: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label className="form-label">URL</label>
|
||||
<input
|
||||
type="url"
|
||||
className="form-control"
|
||||
value={linkForm.url}
|
||||
onChange={e => setLinkForm(f => ({ ...f, url: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Иконка (опционально)</label>
|
||||
<input
|
||||
type="file"
|
||||
className="form-control"
|
||||
onChange={e => setLinkForm(f => ({ ...f, iconFile: e.target.files?.[0] || null }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button className="btn btn-secondary" onClick={() => setShowLinkModal(false)}>Отмена</button>
|
||||
<button className="btn btn-primary" onClick={handleLinkSubmit}>
|
||||
Сохранить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
'use client'
|
||||
|
||||
import React, { useEffect, useState, Fragment } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import Image from 'next/image'
|
||||
import { ProfileCard } from '../../components/ProfileCard'
|
||||
|
||||
interface UserProfile {
|
||||
id: number
|
||||
username: string
|
||||
email: string
|
||||
full_name: string
|
||||
bio?: string
|
||||
avatar: string
|
||||
last_login: string
|
||||
date_joined: string
|
||||
}
|
||||
|
||||
interface LinkItem {
|
||||
id: number
|
||||
title: string
|
||||
url: string
|
||||
icon?: string
|
||||
group: number
|
||||
}
|
||||
|
||||
interface Group {
|
||||
id: number
|
||||
name: string
|
||||
icon?: string
|
||||
links: LinkItem[]
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const router = useRouter()
|
||||
const [user, setUser] = useState<UserProfile | null>(null)
|
||||
const [groups, setGroups] = useState<Group[]>([])
|
||||
const [expandedGroup, setExpandedGroup] = useState<number | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// === Для модалок групп ===
|
||||
const [showGroupModal, setShowGroupModal] = useState(false)
|
||||
const [groupModalMode, setGroupModalMode] = useState<'add' | 'edit'>('add')
|
||||
const [editingGroup, setEditingGroup] = useState<Group | null>(null)
|
||||
const [groupForm, setGroupForm] = useState<{ name: string; iconFile: File | null }>({
|
||||
name: '',
|
||||
iconFile: null,
|
||||
})
|
||||
|
||||
// === Для модалок ссылок ===
|
||||
const [showLinkModal, setShowLinkModal] = useState(false)
|
||||
const [linkModalMode, setLinkModalMode] = useState<'add' | 'edit'>('add')
|
||||
const [editingLink, setEditingLink] = useState<LinkItem | null>(null)
|
||||
const [currentGroupIdForLink, setCurrentGroupIdForLink] = useState<number | null>(null)
|
||||
const [linkForm, setLinkForm] = useState<{ title: string; url: string; iconFile: File | null }>({
|
||||
title: '',
|
||||
url: '',
|
||||
iconFile: null,
|
||||
})
|
||||
|
||||
const API = ''
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (!token) {
|
||||
router.push('/auth/login')
|
||||
return
|
||||
}
|
||||
// загружаем профиль, группы и ссылки
|
||||
Promise.all([
|
||||
fetch('/api/auth/user', { headers: { Authorization: `Bearer ${token}` } }),
|
||||
fetch('/api/groups', { headers: { Authorization: `Bearer ${token}` } }),
|
||||
fetch('/api/links', { headers: { Authorization: `Bearer ${token}` } }),
|
||||
])
|
||||
.then(async ([uRes, gRes, lRes]) => {
|
||||
if (!uRes.ok) throw new Error('Не удалось получить профиль')
|
||||
if (!gRes.ok) throw new Error('Не удалось загрузить группы')
|
||||
if (!lRes.ok) throw new Error('Не удалось загрузить ссылки')
|
||||
const userData = await uRes.json()
|
||||
const groupsData = await gRes.json()
|
||||
const linksData = await lRes.json()
|
||||
|
||||
// «привязываем» ссылки к группам
|
||||
const enrichedGroups: Group[] = groupsData.map((grp: any) => ({
|
||||
...grp,
|
||||
links: linksData.filter((link: LinkItem) => link.group === grp.id),
|
||||
}))
|
||||
setUser(userData)
|
||||
setGroups(enrichedGroups)
|
||||
})
|
||||
.catch(err => setError((err as Error).message))
|
||||
.finally(() => setLoading(false))
|
||||
}, [router])
|
||||
|
||||
// Перезагрузка списка групп и ссылок
|
||||
async function reloadData() {
|
||||
const token = localStorage.getItem('token')!
|
||||
const [gRes, lRes] = await Promise.all([
|
||||
fetch('/api/groups', { headers: { Authorization: `Bearer ${token}` } }),
|
||||
fetch('/api/links', { headers: { Authorization: `Bearer ${token}` } }),
|
||||
])
|
||||
const groupsData = await gRes.json()
|
||||
const linksData = await lRes.json()
|
||||
const enrichedGroups: Group[] = groupsData.map((grp: any) => ({
|
||||
...grp,
|
||||
links: linksData.filter((link: LinkItem) => link.group === grp.id),
|
||||
}))
|
||||
setGroups(enrichedGroups)
|
||||
}
|
||||
|
||||
// === Обработчики групп ===
|
||||
function openAddGroup() {
|
||||
setGroupModalMode('add')
|
||||
setGroupForm({ name: '', iconFile: null })
|
||||
setShowGroupModal(true)
|
||||
}
|
||||
function openEditGroup(grp: Group) {
|
||||
setGroupModalMode('edit')
|
||||
setEditingGroup(grp)
|
||||
setGroupForm({ name: grp.name, iconFile: null })
|
||||
setShowGroupModal(true)
|
||||
}
|
||||
async function handleGroupSubmit() {
|
||||
const token = localStorage.getItem('token')!
|
||||
const fd = new FormData()
|
||||
fd.append('name', groupForm.name)
|
||||
if (groupForm.iconFile) fd.append('icon', groupForm.iconFile)
|
||||
const url = groupModalMode === 'add'
|
||||
? '/api/groups'
|
||||
: `/api/groups/${editingGroup?.id}`
|
||||
const method = groupModalMode === 'add' ? 'POST' : 'PATCH'
|
||||
await fetch(url, {
|
||||
method,
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: fd,
|
||||
})
|
||||
setShowGroupModal(false)
|
||||
await reloadData()
|
||||
}
|
||||
async function handleDeleteGroup(grp: Group) {
|
||||
if (!confirm(`Удалить группу "${grp.name}"?`)) return
|
||||
const token = localStorage.getItem('token')!
|
||||
await fetch(`/api/groups/${grp.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
await reloadData()
|
||||
}
|
||||
|
||||
// === Обработчики ссылок ===
|
||||
function openAddLink(grp: Group) {
|
||||
setLinkModalMode('add')
|
||||
setCurrentGroupIdForLink(grp.id)
|
||||
setLinkForm({ title: '', url: '', iconFile: null })
|
||||
setShowLinkModal(true)
|
||||
}
|
||||
function openEditLink(link: LinkItem) {
|
||||
setLinkModalMode('edit')
|
||||
setEditingLink(link)
|
||||
setCurrentGroupIdForLink(link.group)
|
||||
setLinkForm({ title: link.title, url: link.url, iconFile: null })
|
||||
setShowLinkModal(true)
|
||||
}
|
||||
async function handleLinkSubmit() {
|
||||
const token = localStorage.getItem('token')!
|
||||
const fd = new FormData()
|
||||
fd.append('title', linkForm.title)
|
||||
fd.append('url', linkForm.url)
|
||||
if (linkForm.iconFile) fd.append('icon', linkForm.iconFile)
|
||||
fd.append('group', String(currentGroupIdForLink))
|
||||
const url = linkModalMode === 'add'
|
||||
? `${API}/api/links/`
|
||||
: `${API}/api/links/${editingLink?.id}/`
|
||||
const method = linkModalMode === 'add' ? 'POST' : 'PATCH'
|
||||
await fetch(url, {
|
||||
method,
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: fd,
|
||||
})
|
||||
setShowLinkModal(false)
|
||||
await reloadData()
|
||||
}
|
||||
async function handleDeleteLink(link: LinkItem) {
|
||||
if (!confirm(`Удалить ссылку "${link.title}"?`)) return
|
||||
const token = localStorage.getItem('token')!
|
||||
await fetch(`${API}/api/links/${link.id}/`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
await reloadData()
|
||||
}
|
||||
|
||||
if (loading) return <div className="flex items-center justify-center h-screen">Загрузка...</div>
|
||||
if (error) return <div className="p-6 text-red-600 max-w-xl mx-auto">{error}</div>
|
||||
|
||||
const totalGroups = groups.length
|
||||
const totalLinks = groups.reduce((sum, grp) => sum + grp.links.length, 0)
|
||||
|
||||
return (
|
||||
<div className="pb-8">
|
||||
{user && (
|
||||
<ProfileCard
|
||||
avatar={user.avatar}
|
||||
full_name={user.full_name}
|
||||
email={user.email}
|
||||
bio={user.bio}
|
||||
last_login={user.last_login}
|
||||
date_joined={user.date_joined}
|
||||
totalGroups={totalGroups}
|
||||
totalLinks={totalLinks}
|
||||
/>
|
||||
)}
|
||||
|
||||
<section className="mt-5 container">
|
||||
<div className="card shadow">
|
||||
<div className="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 className="mb-0">Группы ссылок</h5>
|
||||
<button className="btn btn-sm btn-success" onClick={openAddGroup}>
|
||||
<i className="bi bi-plus-lg"></i> Добавить группу
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="list-group list-group-flush">
|
||||
{groups.map(group => (
|
||||
<Fragment key={group.id}>
|
||||
<div className="list-group-item d-flex justify-content-between align-items-center">
|
||||
<div
|
||||
className="d-flex align-items-center"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() =>
|
||||
setExpandedGroup(expandedGroup === group.id ? null : group.id)
|
||||
}
|
||||
>
|
||||
{group.icon && (
|
||||
<img
|
||||
src={`${API}${group.icon}`}
|
||||
width={32}
|
||||
height={32}
|
||||
className="me-2 rounded"
|
||||
alt={group.name}
|
||||
/>
|
||||
)}
|
||||
<strong className="me-2">{group.name}</strong>
|
||||
<span className="badge bg-secondary rounded-pill">
|
||||
{group.links.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="btn-group btn-group-sm">
|
||||
<button onClick={() => openAddLink(group)} className="btn btn-outline-primary">
|
||||
<i className="bi bi-link-45deg"></i>
|
||||
</button>
|
||||
<button onClick={() => openEditGroup(group)} className="btn btn-outline-secondary">
|
||||
<i className="bi bi-pencil"></i>
|
||||
</button>
|
||||
<button onClick={() => handleDeleteGroup(group)} className="btn btn-outline-danger">
|
||||
<i className="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expandedGroup === group.id && (
|
||||
<div className="list-group-item bg-light">
|
||||
<ul className="mb-0 ps-3">
|
||||
{group.links.map(link => (
|
||||
<li
|
||||
key={link.id}
|
||||
className="d-flex justify-content-between align-items-center mb-2"
|
||||
>
|
||||
<div className="d-flex align-items-center">
|
||||
{link.icon && (
|
||||
<img
|
||||
src={link.icon.startsWith('http')
|
||||
? link.icon
|
||||
: `${API}${link.icon}`}
|
||||
width={24}
|
||||
height={24}
|
||||
className="me-2"
|
||||
alt={link.title}
|
||||
/>
|
||||
)}
|
||||
<a href={link.url} target="_blank" rel="noopener noreferrer">
|
||||
{link.title}
|
||||
</a>
|
||||
</div>
|
||||
<div className="btn-group btn-group-sm">
|
||||
<button onClick={() => openEditLink(link)} className="btn btn-outline-secondary">
|
||||
<i className="bi bi-pencil-fill"></i>
|
||||
</button>
|
||||
<button onClick={() => handleDeleteLink(link)} className="btn btn-outline-danger">
|
||||
<i className="bi bi-trash-fill"></i>
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Модалка добавления/редактирования группы */}
|
||||
<div className={`modal ${showGroupModal ? 'd-block' : 'd-none'}`} tabIndex={-1}>
|
||||
<div className="modal-dialog">
|
||||
<div className="modal-content">
|
||||
<div className="modal-header">
|
||||
<h5 className="modal-title">{groupModalMode === 'add' ? 'Добавить группу' : 'Редактировать группу'}</h5>
|
||||
<button type="button" className="btn-close" onClick={() => setShowGroupModal(false)} />
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Название</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
value={groupForm.name}
|
||||
onChange={e => setGroupForm(f => ({ ...f, name: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Иконка (опционально)</label>
|
||||
<input
|
||||
type="file"
|
||||
className="form-control"
|
||||
onChange={e => setGroupForm(f => ({ ...f, iconFile: e.target.files?.[0] || null }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button className="btn btn-secondary" onClick={() => setShowGroupModal(false)}>Отмена</button>
|
||||
<button className="btn btn-primary" onClick={handleGroupSubmit}>
|
||||
Сохранить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Модалка добавления/редактирования ссылки */}
|
||||
<div className={`modal ${showLinkModal ? 'd-block' : 'd-none'}`} tabIndex={-1}>
|
||||
<div className="modal-dialog">
|
||||
<div className="modal-content">
|
||||
<div className="modal-header">
|
||||
<h5 className="modal-title">{linkModalMode === 'add' ? 'Добавить ссылку' : 'Редактировать ссылку'}</h5>
|
||||
<button type="button" className="btn-close" onClick={() => setShowLinkModal(false)} />
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Заголовок</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
value={linkForm.title}
|
||||
onChange={e => setLinkForm(f => ({ ...f, title: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label className="form-label">URL</label>
|
||||
<input
|
||||
type="url"
|
||||
className="form-control"
|
||||
value={linkForm.url}
|
||||
onChange={e => setLinkForm(f => ({ ...f, url: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Иконка (опционально)</label>
|
||||
<input
|
||||
type="file"
|
||||
className="form-control"
|
||||
onChange={e => setLinkForm(f => ({ ...f, iconFile: e.target.files?.[0] || null }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button className="btn btn-secondary" onClick={() => setShowLinkModal(false)}>Отмена</button>
|
||||
<button className="btn btn-primary" onClick={handleLinkSubmit}>
|
||||
Сохранить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
'use client'
|
||||
|
||||
import React, { useEffect, useState, Fragment } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import Image from 'next/image'
|
||||
import { ProfileCard } from '../../components/ProfileCard'
|
||||
|
||||
interface UserProfile {
|
||||
id: number
|
||||
username: string
|
||||
email: string
|
||||
full_name: string
|
||||
bio?: string
|
||||
avatar: string
|
||||
last_login: string
|
||||
date_joined: string
|
||||
}
|
||||
|
||||
interface LinkItem {
|
||||
id: number
|
||||
title: string
|
||||
url: string
|
||||
icon?: string
|
||||
group: number
|
||||
}
|
||||
|
||||
interface Group {
|
||||
id: number
|
||||
name: string
|
||||
icon?: string
|
||||
links: LinkItem[]
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const router = useRouter()
|
||||
const [user, setUser] = useState<UserProfile | null>(null)
|
||||
const [groups, setGroups] = useState<Group[]>([])
|
||||
const [expandedGroup, setExpandedGroup] = useState<number | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// === Для модалок групп ===
|
||||
const [showGroupModal, setShowGroupModal] = useState(false)
|
||||
const [groupModalMode, setGroupModalMode] = useState<'add' | 'edit'>('add')
|
||||
const [editingGroup, setEditingGroup] = useState<Group | null>(null)
|
||||
const [groupForm, setGroupForm] = useState<{ name: string; iconFile: File | null }>({
|
||||
name: '',
|
||||
iconFile: null,
|
||||
})
|
||||
|
||||
// === Для модалок ссылок ===
|
||||
const [showLinkModal, setShowLinkModal] = useState(false)
|
||||
const [linkModalMode, setLinkModalMode] = useState<'add' | 'edit'>('add')
|
||||
const [editingLink, setEditingLink] = useState<LinkItem | null>(null)
|
||||
const [currentGroupIdForLink, setCurrentGroupIdForLink] = useState<number | null>(null)
|
||||
const [linkForm, setLinkForm] = useState<{ title: string; url: string; iconFile: File | null }>({
|
||||
title: '',
|
||||
url: '',
|
||||
iconFile: null,
|
||||
})
|
||||
|
||||
const API = ''
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (!token) {
|
||||
router.push('/auth/login')
|
||||
return
|
||||
}
|
||||
// загружаем профиль, группы и ссылки
|
||||
Promise.all([
|
||||
fetch('/api/auth/user', { headers: { Authorization: `Bearer ${token}` } }),
|
||||
fetch('/api/groups', { headers: { Authorization: `Bearer ${token}` } }),
|
||||
fetch('/api/links', { headers: { Authorization: `Bearer ${token}` } }),
|
||||
])
|
||||
.then(async ([uRes, gRes, lRes]) => {
|
||||
if (!uRes.ok) throw new Error('Не удалось получить профиль')
|
||||
if (!gRes.ok) throw new Error('Не удалось загрузить группы')
|
||||
if (!lRes.ok) throw new Error('Не удалось загрузить ссылки')
|
||||
const userData = await uRes.json()
|
||||
const groupsData = await gRes.json()
|
||||
const linksData = await lRes.json()
|
||||
|
||||
// «привязываем» ссылки к группам
|
||||
const enrichedGroups: Group[] = groupsData.map((grp: any) => ({
|
||||
...grp,
|
||||
links: linksData.filter((link: LinkItem) => link.group === grp.id),
|
||||
}))
|
||||
setUser(userData)
|
||||
setGroups(enrichedGroups)
|
||||
})
|
||||
.catch(err => setError((err as Error).message))
|
||||
.finally(() => setLoading(false))
|
||||
}, [router])
|
||||
|
||||
// Перезагрузка списка групп и ссылок
|
||||
async function reloadData() {
|
||||
const token = localStorage.getItem('token')!
|
||||
const [gRes, lRes] = await Promise.all([
|
||||
fetch('/api/groups', { headers: { Authorization: `Bearer ${token}` } }),
|
||||
fetch('/api/links', { headers: { Authorization: `Bearer ${token}` } }),
|
||||
])
|
||||
const groupsData = await gRes.json()
|
||||
const linksData = await lRes.json()
|
||||
const enrichedGroups: Group[] = groupsData.map((grp: any) => ({
|
||||
...grp,
|
||||
links: linksData.filter((link: LinkItem) => link.group === grp.id),
|
||||
}))
|
||||
setGroups(enrichedGroups)
|
||||
}
|
||||
|
||||
// === Обработчики групп ===
|
||||
function openAddGroup() {
|
||||
setGroupModalMode('add')
|
||||
setGroupForm({ name: '', iconFile: null })
|
||||
setShowGroupModal(true)
|
||||
}
|
||||
function openEditGroup(grp: Group) {
|
||||
setGroupModalMode('edit')
|
||||
setEditingGroup(grp)
|
||||
setGroupForm({ name: grp.name, iconFile: null })
|
||||
setShowGroupModal(true)
|
||||
}
|
||||
async function handleGroupSubmit() {
|
||||
const token = localStorage.getItem('token')!
|
||||
const fd = new FormData()
|
||||
fd.append('name', groupForm.name)
|
||||
if (groupForm.iconFile) fd.append('icon', groupForm.iconFile)
|
||||
const url = groupModalMode === 'add'
|
||||
? '/api/groups'
|
||||
: `/api/groups/${editingGroup?.id}`
|
||||
const method = groupModalMode === 'add' ? 'POST' : 'PATCH'
|
||||
await fetch(url, {
|
||||
method,
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: fd,
|
||||
})
|
||||
setShowGroupModal(false)
|
||||
await reloadData()
|
||||
}
|
||||
async function handleDeleteGroup(grp: Group) {
|
||||
if (!confirm(`Удалить группу "${grp.name}"?`)) return
|
||||
const token = localStorage.getItem('token')!
|
||||
await fetch(`/api/groups/${grp.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
await reloadData()
|
||||
}
|
||||
|
||||
// === Обработчики ссылок ===
|
||||
function openAddLink(grp: Group) {
|
||||
setLinkModalMode('add')
|
||||
setCurrentGroupIdForLink(grp.id)
|
||||
setLinkForm({ title: '', url: '', iconFile: null })
|
||||
setShowLinkModal(true)
|
||||
}
|
||||
function openEditLink(link: LinkItem) {
|
||||
setLinkModalMode('edit')
|
||||
setEditingLink(link)
|
||||
setCurrentGroupIdForLink(link.group)
|
||||
setLinkForm({ title: link.title, url: link.url, iconFile: null })
|
||||
setShowLinkModal(true)
|
||||
}
|
||||
async function handleLinkSubmit() {
|
||||
const token = localStorage.getItem('token')!
|
||||
const fd = new FormData()
|
||||
fd.append('title', linkForm.title)
|
||||
fd.append('url', linkForm.url)
|
||||
if (linkForm.iconFile) fd.append('icon', linkForm.iconFile)
|
||||
fd.append('group', String(currentGroupIdForLink))
|
||||
const url = linkModalMode === 'add'
|
||||
? '/api/links'
|
||||
: `/api/links/${editingLink?.id}`
|
||||
const method = linkModalMode === 'add' ? 'POST' : 'PATCH'
|
||||
await fetch(url, {
|
||||
method,
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: fd,
|
||||
})
|
||||
setShowLinkModal(false)
|
||||
await reloadData()
|
||||
}
|
||||
async function handleDeleteLink(link: LinkItem) {
|
||||
if (!confirm(`Удалить ссылку "${link.title}"?`)) return
|
||||
const token = localStorage.getItem('token')!
|
||||
await fetch(`${API}/api/links/${link.id}/`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
await reloadData()
|
||||
}
|
||||
|
||||
if (loading) return <div className="flex items-center justify-center h-screen">Загрузка...</div>
|
||||
if (error) return <div className="p-6 text-red-600 max-w-xl mx-auto">{error}</div>
|
||||
|
||||
const totalGroups = groups.length
|
||||
const totalLinks = groups.reduce((sum, grp) => sum + grp.links.length, 0)
|
||||
|
||||
return (
|
||||
<div className="pb-8">
|
||||
{user && (
|
||||
<ProfileCard
|
||||
avatar={user.avatar}
|
||||
full_name={user.full_name}
|
||||
email={user.email}
|
||||
bio={user.bio}
|
||||
last_login={user.last_login}
|
||||
date_joined={user.date_joined}
|
||||
totalGroups={totalGroups}
|
||||
totalLinks={totalLinks}
|
||||
/>
|
||||
)}
|
||||
|
||||
<section className="mt-5 container">
|
||||
<div className="card shadow">
|
||||
<div className="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 className="mb-0">Группы ссылок</h5>
|
||||
<button className="btn btn-sm btn-success" onClick={openAddGroup}>
|
||||
<i className="bi bi-plus-lg"></i> Добавить группу
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="list-group list-group-flush">
|
||||
{groups.map(group => (
|
||||
<Fragment key={group.id}>
|
||||
<div className="list-group-item d-flex justify-content-between align-items-center">
|
||||
<div
|
||||
className="d-flex align-items-center"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() =>
|
||||
setExpandedGroup(expandedGroup === group.id ? null : group.id)
|
||||
}
|
||||
>
|
||||
{group.icon && (
|
||||
<img
|
||||
src={`${API}${group.icon}`}
|
||||
width={32}
|
||||
height={32}
|
||||
className="me-2 rounded"
|
||||
alt={group.name}
|
||||
/>
|
||||
)}
|
||||
<strong className="me-2">{group.name}</strong>
|
||||
<span className="badge bg-secondary rounded-pill">
|
||||
{group.links.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="btn-group btn-group-sm">
|
||||
<button onClick={() => openAddLink(group)} className="btn btn-outline-primary">
|
||||
<i className="bi bi-link-45deg"></i>
|
||||
</button>
|
||||
<button onClick={() => openEditGroup(group)} className="btn btn-outline-secondary">
|
||||
<i className="bi bi-pencil"></i>
|
||||
</button>
|
||||
<button onClick={() => handleDeleteGroup(group)} className="btn btn-outline-danger">
|
||||
<i className="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expandedGroup === group.id && (
|
||||
<div className="list-group-item bg-light">
|
||||
<ul className="mb-0 ps-3">
|
||||
{group.links.map(link => (
|
||||
<li
|
||||
key={link.id}
|
||||
className="d-flex justify-content-between align-items-center mb-2"
|
||||
>
|
||||
<div className="d-flex align-items-center">
|
||||
{link.icon && (
|
||||
<img
|
||||
src={link.icon.startsWith('http')
|
||||
? link.icon
|
||||
: `${API}${link.icon}`}
|
||||
width={24}
|
||||
height={24}
|
||||
className="me-2"
|
||||
alt={link.title}
|
||||
/>
|
||||
)}
|
||||
<a href={link.url} target="_blank" rel="noopener noreferrer">
|
||||
{link.title}
|
||||
</a>
|
||||
</div>
|
||||
<div className="btn-group btn-group-sm">
|
||||
<button onClick={() => openEditLink(link)} className="btn btn-outline-secondary">
|
||||
<i className="bi bi-pencil-fill"></i>
|
||||
</button>
|
||||
<button onClick={() => handleDeleteLink(link)} className="btn btn-outline-danger">
|
||||
<i className="bi bi-trash-fill"></i>
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Модалка добавления/редактирования группы */}
|
||||
<div className={`modal ${showGroupModal ? 'd-block' : 'd-none'}`} tabIndex={-1}>
|
||||
<div className="modal-dialog">
|
||||
<div className="modal-content">
|
||||
<div className="modal-header">
|
||||
<h5 className="modal-title">{groupModalMode === 'add' ? 'Добавить группу' : 'Редактировать группу'}</h5>
|
||||
<button type="button" className="btn-close" onClick={() => setShowGroupModal(false)} />
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Название</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
value={groupForm.name}
|
||||
onChange={e => setGroupForm(f => ({ ...f, name: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Иконка (опционально)</label>
|
||||
<input
|
||||
type="file"
|
||||
className="form-control"
|
||||
onChange={e => setGroupForm(f => ({ ...f, iconFile: e.target.files?.[0] || null }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button className="btn btn-secondary" onClick={() => setShowGroupModal(false)}>Отмена</button>
|
||||
<button className="btn btn-primary" onClick={handleGroupSubmit}>
|
||||
Сохранить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Модалка добавления/редактирования ссылки */}
|
||||
<div className={`modal ${showLinkModal ? 'd-block' : 'd-none'}`} tabIndex={-1}>
|
||||
<div className="modal-dialog">
|
||||
<div className="modal-content">
|
||||
<div className="modal-header">
|
||||
<h5 className="modal-title">{linkModalMode === 'add' ? 'Добавить ссылку' : 'Редактировать ссылку'}</h5>
|
||||
<button type="button" className="btn-close" onClick={() => setShowLinkModal(false)} />
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Заголовок</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
value={linkForm.title}
|
||||
onChange={e => setLinkForm(f => ({ ...f, title: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label className="form-label">URL</label>
|
||||
<input
|
||||
type="url"
|
||||
className="form-control"
|
||||
value={linkForm.url}
|
||||
onChange={e => setLinkForm(f => ({ ...f, url: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Иконка (опционально)</label>
|
||||
<input
|
||||
type="file"
|
||||
className="form-control"
|
||||
onChange={e => setLinkForm(f => ({ ...f, iconFile: e.target.files?.[0] || null }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button className="btn btn-secondary" onClick={() => setShowLinkModal(false)}>Отмена</button>
|
||||
<button className="btn btn-primary" onClick={handleLinkSubmit}>
|
||||
Сохранить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
'use client'
|
||||
|
||||
import React, { useEffect, useState, Fragment } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import Image from 'next/image'
|
||||
import { ProfileCard } from '../../components/ProfileCard'
|
||||
|
||||
interface UserProfile {
|
||||
id: number
|
||||
username: string
|
||||
email: string
|
||||
full_name: string
|
||||
bio?: string
|
||||
avatar: string
|
||||
last_login: string
|
||||
date_joined: string
|
||||
}
|
||||
|
||||
interface LinkItem {
|
||||
id: number
|
||||
title: string
|
||||
url: string
|
||||
icon?: string
|
||||
group: number
|
||||
}
|
||||
|
||||
interface Group {
|
||||
id: number
|
||||
name: string
|
||||
icon?: string
|
||||
links: LinkItem[]
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const router = useRouter()
|
||||
const [user, setUser] = useState<UserProfile | null>(null)
|
||||
const [groups, setGroups] = useState<Group[]>([])
|
||||
const [expandedGroup, setExpandedGroup] = useState<number | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// === Для модалок групп ===
|
||||
const [showGroupModal, setShowGroupModal] = useState(false)
|
||||
const [groupModalMode, setGroupModalMode] = useState<'add' | 'edit'>('add')
|
||||
const [editingGroup, setEditingGroup] = useState<Group | null>(null)
|
||||
const [groupForm, setGroupForm] = useState<{ name: string; iconFile: File | null }>({
|
||||
name: '',
|
||||
iconFile: null,
|
||||
})
|
||||
|
||||
// === Для модалок ссылок ===
|
||||
const [showLinkModal, setShowLinkModal] = useState(false)
|
||||
const [linkModalMode, setLinkModalMode] = useState<'add' | 'edit'>('add')
|
||||
const [editingLink, setEditingLink] = useState<LinkItem | null>(null)
|
||||
const [currentGroupIdForLink, setCurrentGroupIdForLink] = useState<number | null>(null)
|
||||
const [linkForm, setLinkForm] = useState<{ title: string; url: string; iconFile: File | null }>({
|
||||
title: '',
|
||||
url: '',
|
||||
iconFile: null,
|
||||
})
|
||||
|
||||
const API = ''
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (!token) {
|
||||
router.push('/auth/login')
|
||||
return
|
||||
}
|
||||
// загружаем профиль, группы и ссылки
|
||||
Promise.all([
|
||||
fetch('/api/auth/user', { headers: { Authorization: `Bearer ${token}` } }),
|
||||
fetch('/api/groups', { headers: { Authorization: `Bearer ${token}` } }),
|
||||
fetch('/api/links', { headers: { Authorization: `Bearer ${token}` } }),
|
||||
])
|
||||
.then(async ([uRes, gRes, lRes]) => {
|
||||
if (!uRes.ok) throw new Error('Не удалось получить профиль')
|
||||
if (!gRes.ok) throw new Error('Не удалось загрузить группы')
|
||||
if (!lRes.ok) throw new Error('Не удалось загрузить ссылки')
|
||||
const userData = await uRes.json()
|
||||
const groupsData = await gRes.json()
|
||||
const linksData = await lRes.json()
|
||||
|
||||
// «привязываем» ссылки к группам
|
||||
const enrichedGroups: Group[] = groupsData.map((grp: any) => ({
|
||||
...grp,
|
||||
links: linksData.filter((link: LinkItem) => link.group === grp.id),
|
||||
}))
|
||||
setUser(userData)
|
||||
setGroups(enrichedGroups)
|
||||
})
|
||||
.catch(err => setError((err as Error).message))
|
||||
.finally(() => setLoading(false))
|
||||
}, [router])
|
||||
|
||||
// Перезагрузка списка групп и ссылок
|
||||
async function reloadData() {
|
||||
const token = localStorage.getItem('token')!
|
||||
const [gRes, lRes] = await Promise.all([
|
||||
fetch('/api/groups', { headers: { Authorization: `Bearer ${token}` } }),
|
||||
fetch('/api/links', { headers: { Authorization: `Bearer ${token}` } }),
|
||||
])
|
||||
const groupsData = await gRes.json()
|
||||
const linksData = await lRes.json()
|
||||
const enrichedGroups: Group[] = groupsData.map((grp: any) => ({
|
||||
...grp,
|
||||
links: linksData.filter((link: LinkItem) => link.group === grp.id),
|
||||
}))
|
||||
setGroups(enrichedGroups)
|
||||
}
|
||||
|
||||
// === Обработчики групп ===
|
||||
function openAddGroup() {
|
||||
setGroupModalMode('add')
|
||||
setGroupForm({ name: '', iconFile: null })
|
||||
setShowGroupModal(true)
|
||||
}
|
||||
function openEditGroup(grp: Group) {
|
||||
setGroupModalMode('edit')
|
||||
setEditingGroup(grp)
|
||||
setGroupForm({ name: grp.name, iconFile: null })
|
||||
setShowGroupModal(true)
|
||||
}
|
||||
async function handleGroupSubmit() {
|
||||
const token = localStorage.getItem('token')!
|
||||
const fd = new FormData()
|
||||
fd.append('name', groupForm.name)
|
||||
if (groupForm.iconFile) fd.append('icon', groupForm.iconFile)
|
||||
const url = groupModalMode === 'add'
|
||||
? '/api/groups'
|
||||
: `/api/groups/${editingGroup?.id}`
|
||||
const method = groupModalMode === 'add' ? 'POST' : 'PATCH'
|
||||
await fetch(url, {
|
||||
method,
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: fd,
|
||||
})
|
||||
setShowGroupModal(false)
|
||||
await reloadData()
|
||||
}
|
||||
async function handleDeleteGroup(grp: Group) {
|
||||
if (!confirm(`Удалить группу "${grp.name}"?`)) return
|
||||
const token = localStorage.getItem('token')!
|
||||
await fetch(`/api/groups/${grp.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
await reloadData()
|
||||
}
|
||||
|
||||
// === Обработчики ссылок ===
|
||||
function openAddLink(grp: Group) {
|
||||
setLinkModalMode('add')
|
||||
setCurrentGroupIdForLink(grp.id)
|
||||
setLinkForm({ title: '', url: '', iconFile: null })
|
||||
setShowLinkModal(true)
|
||||
}
|
||||
function openEditLink(link: LinkItem) {
|
||||
setLinkModalMode('edit')
|
||||
setEditingLink(link)
|
||||
setCurrentGroupIdForLink(link.group)
|
||||
setLinkForm({ title: link.title, url: link.url, iconFile: null })
|
||||
setShowLinkModal(true)
|
||||
}
|
||||
async function handleLinkSubmit() {
|
||||
const token = localStorage.getItem('token')!
|
||||
const fd = new FormData()
|
||||
fd.append('title', linkForm.title)
|
||||
fd.append('url', linkForm.url)
|
||||
if (linkForm.iconFile) fd.append('icon', linkForm.iconFile)
|
||||
fd.append('group', String(currentGroupIdForLink))
|
||||
const url = linkModalMode === 'add'
|
||||
? '/api/links'
|
||||
: `/api/links/${editingLink?.id}`
|
||||
const method = linkModalMode === 'add' ? 'POST' : 'PATCH'
|
||||
await fetch(url, {
|
||||
method,
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: fd,
|
||||
})
|
||||
setShowLinkModal(false)
|
||||
await reloadData()
|
||||
}
|
||||
async function handleDeleteLink(link: LinkItem) {
|
||||
if (!confirm(`Удалить ссылку "${link.title}"?`)) return
|
||||
const token = localStorage.getItem('token')!
|
||||
await fetch(`/api/links/${link.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
await reloadData()
|
||||
}
|
||||
|
||||
if (loading) return <div className="flex items-center justify-center h-screen">Загрузка...</div>
|
||||
if (error) return <div className="p-6 text-red-600 max-w-xl mx-auto">{error}</div>
|
||||
|
||||
const totalGroups = groups.length
|
||||
const totalLinks = groups.reduce((sum, grp) => sum + grp.links.length, 0)
|
||||
|
||||
return (
|
||||
<div className="pb-8">
|
||||
{user && (
|
||||
<ProfileCard
|
||||
avatar={user.avatar}
|
||||
full_name={user.full_name}
|
||||
email={user.email}
|
||||
bio={user.bio}
|
||||
last_login={user.last_login}
|
||||
date_joined={user.date_joined}
|
||||
totalGroups={totalGroups}
|
||||
totalLinks={totalLinks}
|
||||
/>
|
||||
)}
|
||||
|
||||
<section className="mt-5 container">
|
||||
<div className="card shadow">
|
||||
<div className="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 className="mb-0">Группы ссылок</h5>
|
||||
<button className="btn btn-sm btn-success" onClick={openAddGroup}>
|
||||
<i className="bi bi-plus-lg"></i> Добавить группу
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="list-group list-group-flush">
|
||||
{groups.map(group => (
|
||||
<Fragment key={group.id}>
|
||||
<div className="list-group-item d-flex justify-content-between align-items-center">
|
||||
<div
|
||||
className="d-flex align-items-center"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() =>
|
||||
setExpandedGroup(expandedGroup === group.id ? null : group.id)
|
||||
}
|
||||
>
|
||||
{group.icon && (
|
||||
<img
|
||||
src={`${API}${group.icon}`}
|
||||
width={32}
|
||||
height={32}
|
||||
className="me-2 rounded"
|
||||
alt={group.name}
|
||||
/>
|
||||
)}
|
||||
<strong className="me-2">{group.name}</strong>
|
||||
<span className="badge bg-secondary rounded-pill">
|
||||
{group.links.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="btn-group btn-group-sm">
|
||||
<button onClick={() => openAddLink(group)} className="btn btn-outline-primary">
|
||||
<i className="bi bi-link-45deg"></i>
|
||||
</button>
|
||||
<button onClick={() => openEditGroup(group)} className="btn btn-outline-secondary">
|
||||
<i className="bi bi-pencil"></i>
|
||||
</button>
|
||||
<button onClick={() => handleDeleteGroup(group)} className="btn btn-outline-danger">
|
||||
<i className="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expandedGroup === group.id && (
|
||||
<div className="list-group-item bg-light">
|
||||
<ul className="mb-0 ps-3">
|
||||
{group.links.map(link => (
|
||||
<li
|
||||
key={link.id}
|
||||
className="d-flex justify-content-between align-items-center mb-2"
|
||||
>
|
||||
<div className="d-flex align-items-center">
|
||||
{link.icon && (
|
||||
<img
|
||||
src={link.icon.startsWith('http')
|
||||
? link.icon
|
||||
: `${API}${link.icon}`}
|
||||
width={24}
|
||||
height={24}
|
||||
className="me-2"
|
||||
alt={link.title}
|
||||
/>
|
||||
)}
|
||||
<a href={link.url} target="_blank" rel="noopener noreferrer">
|
||||
{link.title}
|
||||
</a>
|
||||
</div>
|
||||
<div className="btn-group btn-group-sm">
|
||||
<button onClick={() => openEditLink(link)} className="btn btn-outline-secondary">
|
||||
<i className="bi bi-pencil-fill"></i>
|
||||
</button>
|
||||
<button onClick={() => handleDeleteLink(link)} className="btn btn-outline-danger">
|
||||
<i className="bi bi-trash-fill"></i>
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Модалка добавления/редактирования группы */}
|
||||
<div className={`modal ${showGroupModal ? 'd-block' : 'd-none'}`} tabIndex={-1}>
|
||||
<div className="modal-dialog">
|
||||
<div className="modal-content">
|
||||
<div className="modal-header">
|
||||
<h5 className="modal-title">{groupModalMode === 'add' ? 'Добавить группу' : 'Редактировать группу'}</h5>
|
||||
<button type="button" className="btn-close" onClick={() => setShowGroupModal(false)} />
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Название</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
value={groupForm.name}
|
||||
onChange={e => setGroupForm(f => ({ ...f, name: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Иконка (опционально)</label>
|
||||
<input
|
||||
type="file"
|
||||
className="form-control"
|
||||
onChange={e => setGroupForm(f => ({ ...f, iconFile: e.target.files?.[0] || null }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button className="btn btn-secondary" onClick={() => setShowGroupModal(false)}>Отмена</button>
|
||||
<button className="btn btn-primary" onClick={handleGroupSubmit}>
|
||||
Сохранить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Модалка добавления/редактирования ссылки */}
|
||||
<div className={`modal ${showLinkModal ? 'd-block' : 'd-none'}`} tabIndex={-1}>
|
||||
<div className="modal-dialog">
|
||||
<div className="modal-content">
|
||||
<div className="modal-header">
|
||||
<h5 className="modal-title">{linkModalMode === 'add' ? 'Добавить ссылку' : 'Редактировать ссылку'}</h5>
|
||||
<button type="button" className="btn-close" onClick={() => setShowLinkModal(false)} />
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Заголовок</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
value={linkForm.title}
|
||||
onChange={e => setLinkForm(f => ({ ...f, title: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label className="form-label">URL</label>
|
||||
<input
|
||||
type="url"
|
||||
className="form-control"
|
||||
value={linkForm.url}
|
||||
onChange={e => setLinkForm(f => ({ ...f, url: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Иконка (опционально)</label>
|
||||
<input
|
||||
type="file"
|
||||
className="form-control"
|
||||
onChange={e => setLinkForm(f => ({ ...f, iconFile: e.target.files?.[0] || null }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button className="btn btn-secondary" onClick={() => setShowLinkModal(false)}>Отмена</button>
|
||||
<button className="btn btn-primary" onClick={handleLinkSubmit}>
|
||||
Сохранить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
'use client'
|
||||
|
||||
import React, { useEffect, useState, Fragment } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import Image from 'next/image'
|
||||
import { ProfileCard } from '../../components/ProfileCard'
|
||||
|
||||
interface UserProfile {
|
||||
id: number
|
||||
username: string
|
||||
email: string
|
||||
full_name: string
|
||||
bio?: string
|
||||
avatar: string
|
||||
last_login: string
|
||||
date_joined: string
|
||||
}
|
||||
|
||||
interface LinkItem {
|
||||
id: number
|
||||
title: string
|
||||
url: string
|
||||
icon?: string
|
||||
group: number
|
||||
}
|
||||
|
||||
interface Group {
|
||||
id: number
|
||||
name: string
|
||||
icon?: string
|
||||
links: LinkItem[]
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const router = useRouter()
|
||||
const [user, setUser] = useState<UserProfile | null>(null)
|
||||
const [groups, setGroups] = useState<Group[]>([])
|
||||
const [expandedGroup, setExpandedGroup] = useState<number | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// === Для модалок групп ===
|
||||
const [showGroupModal, setShowGroupModal] = useState(false)
|
||||
const [groupModalMode, setGroupModalMode] = useState<'add' | 'edit'>('add')
|
||||
const [editingGroup, setEditingGroup] = useState<Group | null>(null)
|
||||
const [groupForm, setGroupForm] = useState<{ name: string; iconFile: File | null }>({
|
||||
name: '',
|
||||
iconFile: null,
|
||||
})
|
||||
|
||||
// === Для модалок ссылок ===
|
||||
const [showLinkModal, setShowLinkModal] = useState(false)
|
||||
const [linkModalMode, setLinkModalMode] = useState<'add' | 'edit'>('add')
|
||||
const [editingLink, setEditingLink] = useState<LinkItem | null>(null)
|
||||
const [currentGroupIdForLink, setCurrentGroupIdForLink] = useState<number | null>(null)
|
||||
const [linkForm, setLinkForm] = useState<{ title: string; url: string; iconFile: File | null }>({
|
||||
title: '',
|
||||
url: '',
|
||||
iconFile: null,
|
||||
})
|
||||
|
||||
const API = ''
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (!token) {
|
||||
router.push('/auth/login')
|
||||
return
|
||||
}
|
||||
// загружаем профиль, группы и ссылки
|
||||
Promise.all([
|
||||
fetch('/api/auth/user', { headers: { Authorization: `Bearer ${token}` } }),
|
||||
fetch('/api/groups', { headers: { Authorization: `Bearer ${token}` } }),
|
||||
fetch('/api/links', { headers: { Authorization: `Bearer ${token}` } }),
|
||||
])
|
||||
.then(async ([uRes, gRes, lRes]) => {
|
||||
if (!uRes.ok) throw new Error('Не удалось получить профиль')
|
||||
if (!gRes.ok) throw new Error('Не удалось загрузить группы')
|
||||
if (!lRes.ok) throw new Error('Не удалось загрузить ссылки')
|
||||
const userData = await uRes.json()
|
||||
const groupsData = await gRes.json()
|
||||
const linksData = await lRes.json()
|
||||
|
||||
// «привязываем» ссылки к группам
|
||||
const enrichedGroups: Group[] = groupsData.map((grp: any) => ({
|
||||
...grp,
|
||||
links: linksData.filter((link: LinkItem) => link.group === grp.id),
|
||||
}))
|
||||
setUser(userData)
|
||||
setGroups(enrichedGroups)
|
||||
})
|
||||
.catch(err => setError((err as Error).message))
|
||||
.finally(() => setLoading(false))
|
||||
}, [router])
|
||||
|
||||
// Перезагрузка списка групп и ссылок
|
||||
async function reloadData() {
|
||||
const token = localStorage.getItem('token')!
|
||||
const [gRes, lRes] = await Promise.all([
|
||||
fetch('/api/groups', { headers: { Authorization: `Bearer ${token}` } }),
|
||||
fetch('/api/links', { headers: { Authorization: `Bearer ${token}` } }),
|
||||
])
|
||||
const groupsData = await gRes.json()
|
||||
const linksData = await lRes.json()
|
||||
const enrichedGroups: Group[] = groupsData.map((grp: any) => ({
|
||||
...grp,
|
||||
links: linksData.filter((link: LinkItem) => link.group === grp.id),
|
||||
}))
|
||||
setGroups(enrichedGroups)
|
||||
}
|
||||
|
||||
// === Обработчики групп ===
|
||||
function openAddGroup() {
|
||||
setGroupModalMode('add')
|
||||
setGroupForm({ name: '', iconFile: null })
|
||||
setShowGroupModal(true)
|
||||
}
|
||||
function openEditGroup(grp: Group) {
|
||||
setGroupModalMode('edit')
|
||||
setEditingGroup(grp)
|
||||
setGroupForm({ name: grp.name, iconFile: null })
|
||||
setShowGroupModal(true)
|
||||
}
|
||||
async function handleGroupSubmit() {
|
||||
const token = localStorage.getItem('token')!
|
||||
const fd = new FormData()
|
||||
fd.append('name', groupForm.name)
|
||||
if (groupForm.iconFile) fd.append('icon', groupForm.iconFile)
|
||||
const url = groupModalMode === 'add'
|
||||
? '/api/groups'
|
||||
: `/api/groups/${editingGroup?.id}`
|
||||
const method = groupModalMode === 'add' ? 'POST' : 'PATCH'
|
||||
await fetch(url, {
|
||||
method,
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: fd,
|
||||
})
|
||||
setShowGroupModal(false)
|
||||
await reloadData()
|
||||
}
|
||||
async function handleDeleteGroup(grp: Group) {
|
||||
if (!confirm(`Удалить группу "${grp.name}"?`)) return
|
||||
const token = localStorage.getItem('token')!
|
||||
await fetch(`/api/groups/${grp.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
await reloadData()
|
||||
}
|
||||
|
||||
// === Обработчики ссылок ===
|
||||
function openAddLink(grp: Group) {
|
||||
setLinkModalMode('add')
|
||||
setCurrentGroupIdForLink(grp.id)
|
||||
setLinkForm({ title: '', url: '', iconFile: null })
|
||||
setShowLinkModal(true)
|
||||
}
|
||||
function openEditLink(link: LinkItem) {
|
||||
setLinkModalMode('edit')
|
||||
setEditingLink(link)
|
||||
setCurrentGroupIdForLink(link.group)
|
||||
setLinkForm({ title: link.title, url: link.url, iconFile: null })
|
||||
setShowLinkModal(true)
|
||||
}
|
||||
async function handleLinkSubmit() {
|
||||
const token = localStorage.getItem('token')!
|
||||
const fd = new FormData()
|
||||
fd.append('title', linkForm.title)
|
||||
fd.append('url', linkForm.url)
|
||||
if (linkForm.iconFile) fd.append('icon', linkForm.iconFile)
|
||||
fd.append('group', String(currentGroupIdForLink))
|
||||
const url = linkModalMode === 'add'
|
||||
? '/api/links'
|
||||
: `/api/links/${editingLink?.id}`
|
||||
const method = linkModalMode === 'add' ? 'POST' : 'PATCH'
|
||||
await fetch(url, {
|
||||
method,
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: fd,
|
||||
})
|
||||
setShowLinkModal(false)
|
||||
await reloadData()
|
||||
}
|
||||
async function handleDeleteLink(link: LinkItem) {
|
||||
if (!confirm(`Удалить ссылку "${link.title}"?`)) return
|
||||
const token = localStorage.getItem('token')!
|
||||
await fetch(`/api/links/${link.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
await reloadData()
|
||||
}
|
||||
|
||||
if (loading) return <div className="flex items-center justify-center h-screen">Загрузка...</div>
|
||||
if (error) return <div className="p-6 text-red-600 max-w-xl mx-auto">{error}</div>
|
||||
|
||||
const totalGroups = groups.length
|
||||
const totalLinks = groups.reduce((sum, grp) => sum + grp.links.length, 0)
|
||||
|
||||
return (
|
||||
<div className="pb-8">
|
||||
{user && (
|
||||
<ProfileCard
|
||||
avatar={user.avatar}
|
||||
full_name={user.full_name}
|
||||
email={user.email}
|
||||
bio={user.bio}
|
||||
last_login={user.last_login}
|
||||
date_joined={user.date_joined}
|
||||
totalGroups={totalGroups}
|
||||
totalLinks={totalLinks}
|
||||
/>
|
||||
)}
|
||||
|
||||
<section className="mt-5 container">
|
||||
<div className="card shadow">
|
||||
<div className="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 className="mb-0">Группы ссылок</h5>
|
||||
<button className="btn btn-sm btn-success" onClick={openAddGroup}>
|
||||
<i className="bi bi-plus-lg"></i> Добавить группу
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="list-group list-group-flush">
|
||||
{groups.map(group => (
|
||||
<Fragment key={group.id}>
|
||||
<div className="list-group-item d-flex justify-content-between align-items-center">
|
||||
<div
|
||||
className="d-flex align-items-center"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() =>
|
||||
setExpandedGroup(expandedGroup === group.id ? null : group.id)
|
||||
}
|
||||
>
|
||||
{group.icon && (
|
||||
<img
|
||||
src={`${API}${group.icon}`}
|
||||
width={32}
|
||||
height={32}
|
||||
className="me-2 rounded"
|
||||
alt={group.name}
|
||||
/>
|
||||
)}
|
||||
<strong className="me-2">{group.name}</strong>
|
||||
<span className="badge bg-secondary rounded-pill">
|
||||
{group.links.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="btn-group btn-group-sm">
|
||||
<button onClick={() => openAddLink(group)} className="btn btn-outline-primary">
|
||||
<i className="bi bi-link-45deg"></i>
|
||||
</button>
|
||||
<button onClick={() => openEditGroup(group)} className="btn btn-outline-secondary">
|
||||
<i className="bi bi-pencil"></i>
|
||||
</button>
|
||||
<button onClick={() => handleDeleteGroup(group)} className="btn btn-outline-danger">
|
||||
<i className="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expandedGroup === group.id && (
|
||||
<div className="list-group-item bg-light">
|
||||
<ul className="mb-0 ps-3">
|
||||
{group.links.map(link => (
|
||||
<li
|
||||
key={link.id}
|
||||
className="d-flex justify-content-between align-items-center mb-2"
|
||||
>
|
||||
<div className="d-flex align-items-center">
|
||||
{link.icon && (
|
||||
<img
|
||||
src={link.icon.startsWith('http')
|
||||
? link.icon
|
||||
: `${API}${link.icon}`}
|
||||
width={24}
|
||||
height={24}
|
||||
className="me-2"
|
||||
alt={link.title}
|
||||
/>
|
||||
)}
|
||||
<a href={link.url} target="_blank" rel="noopener noreferrer">
|
||||
{link.title}
|
||||
</a>
|
||||
</div>
|
||||
<div className="btn-group btn-group-sm">
|
||||
<button onClick={() => openEditLink(link)} className="btn btn-outline-secondary">
|
||||
<i className="bi bi-pencil-fill"></i>
|
||||
</button>
|
||||
<button onClick={() => handleDeleteLink(link)} className="btn btn-outline-danger">
|
||||
<i className="bi bi-trash-fill"></i>
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Модалка добавления/редактирования группы */}
|
||||
<div className={`modal ${showGroupModal ? 'd-block' : 'd-none'}`} tabIndex={-1}>
|
||||
<div className="modal-dialog">
|
||||
<div className="modal-content">
|
||||
<div className="modal-header">
|
||||
<h5 className="modal-title">{groupModalMode === 'add' ? 'Добавить группу' : 'Редактировать группу'}</h5>
|
||||
<button type="button" className="btn-close" onClick={() => setShowGroupModal(false)} />
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Название</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
value={groupForm.name}
|
||||
onChange={e => setGroupForm(f => ({ ...f, name: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Иконка (опционально)</label>
|
||||
<input
|
||||
type="file"
|
||||
className="form-control"
|
||||
onChange={e => setGroupForm(f => ({ ...f, iconFile: e.target.files?.[0] || null }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button className="btn btn-secondary" onClick={() => setShowGroupModal(false)}>Отмена</button>
|
||||
<button className="btn btn-primary" onClick={handleGroupSubmit}>
|
||||
Сохранить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Модалка добавления/редактирования ссылки */}
|
||||
<div className={`modal ${showLinkModal ? 'd-block' : 'd-none'}`} tabIndex={-1}>
|
||||
<div className="modal-dialog">
|
||||
<div className="modal-content">
|
||||
<div className="modal-header">
|
||||
<h5 className="modal-title">{linkModalMode === 'add' ? 'Добавить ссылку' : 'Редактировать ссылку'}</h5>
|
||||
<button type="button" className="btn-close" onClick={() => setShowLinkModal(false)} />
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Заголовок</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
value={linkForm.title}
|
||||
onChange={e => setLinkForm(f => ({ ...f, title: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label className="form-label">URL</label>
|
||||
<input
|
||||
type="url"
|
||||
className="form-control"
|
||||
value={linkForm.url}
|
||||
onChange={e => setLinkForm(f => ({ ...f, url: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Иконка (опционально)</label>
|
||||
<input
|
||||
type="file"
|
||||
className="form-control"
|
||||
onChange={e => setLinkForm(f => ({ ...f, iconFile: e.target.files?.[0] || null }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button className="btn btn-secondary" onClick={() => setShowLinkModal(false)}>Отмена</button>
|
||||
<button className="btn btn-primary" onClick={handleLinkSubmit}>
|
||||
Сохранить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
// src/app/[username]/page.tsx
|
||||
import { notFound } from 'next/navigation'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
|
||||
interface LinkItem {
|
||||
id: number
|
||||
title: string
|
||||
url: string
|
||||
image?: string
|
||||
}
|
||||
|
||||
interface Group {
|
||||
id: number
|
||||
name: string
|
||||
image?: string
|
||||
links: LinkItem[]
|
||||
}
|
||||
|
||||
interface UserGroupsData {
|
||||
username: string
|
||||
groups: Group[]
|
||||
}
|
||||
|
||||
export default async function UserPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ username: string }>
|
||||
}) {
|
||||
const { username } = await params
|
||||
const API = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'
|
||||
|
||||
const res = await fetch(`${API}/api/users/${username}/public`, {
|
||||
cache: 'no-store',
|
||||
})
|
||||
if (res.status === 404) return notFound()
|
||||
if (!res.ok) throw new Error('Ошибка загрузки публичных данных')
|
||||
|
||||
const data: UserGroupsData = await res.json()
|
||||
|
||||
return (
|
||||
<main className="pb-8">
|
||||
<div className="container">
|
||||
<h2 className="text-center mb-4">{data.username}</h2>
|
||||
|
||||
<div className="accordion" id="groupsAccordion">
|
||||
{data.groups.map((group) => {
|
||||
const groupId = `group-${group.id}`
|
||||
|
||||
return (
|
||||
<div
|
||||
key={group.id}
|
||||
className="accordion-item mb-3"
|
||||
style={{ border: '1px solid #dee2e6', borderRadius: 4 }}
|
||||
>
|
||||
<h2 className="accordion-header" id={`${groupId}-header`}>
|
||||
<button
|
||||
className="accordion-button collapsed d-flex align-items-center"
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target={`#${groupId}-collapse`}
|
||||
aria-expanded="false"
|
||||
aria-controls={`${groupId}-collapse`}
|
||||
>
|
||||
{group.image && (
|
||||
<Image
|
||||
src={
|
||||
group.image.startsWith('http')
|
||||
? group.image
|
||||
: `${API}${group.image}`
|
||||
}
|
||||
alt={group.name}
|
||||
width={32}
|
||||
height={32}
|
||||
className="me-2 rounded"
|
||||
priority
|
||||
/>
|
||||
)}
|
||||
<span className="me-2">{group.name}</span>
|
||||
<span className="badge bg-secondary rounded-pill">
|
||||
{group.links.length}
|
||||
</span>
|
||||
</button>
|
||||
</h2>
|
||||
<div
|
||||
id={`${groupId}-collapse`}
|
||||
className="accordion-collapse collapse"
|
||||
aria-labelledby={`${groupId}-header`}
|
||||
data-bs-parent="#groupsAccordion"
|
||||
>
|
||||
<div className="accordion-body">
|
||||
{group.links.length > 0 ? (
|
||||
<ul className="list-unstyled">
|
||||
{group.links.map((link) => (
|
||||
<li
|
||||
key={link.id}
|
||||
className="mb-2 p-2 bg-white rounded shadow-sm"
|
||||
style={{ marginBottom: 5 }}
|
||||
>
|
||||
<div className="d-flex align-items-center">
|
||||
{link.image && (
|
||||
<Image
|
||||
src={
|
||||
link.image.startsWith('http')
|
||||
? link.image
|
||||
: `${API}${link.image}`
|
||||
}
|
||||
alt={link.title}
|
||||
width={24}
|
||||
height={24}
|
||||
className="me-2"
|
||||
priority
|
||||
/>
|
||||
)}
|
||||
<Link
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex-grow-1"
|
||||
>
|
||||
{link.title}
|
||||
</Link>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-muted mb-0">
|
||||
В этой группе пока нет ссылок.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
// src/app/[username]/page.tsx
|
||||
import { notFound } from 'next/navigation'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
|
||||
interface LinkItem {
|
||||
id: number
|
||||
title: string
|
||||
url: string
|
||||
image?: string
|
||||
}
|
||||
|
||||
interface Group {
|
||||
id: number
|
||||
name: string
|
||||
image?: string
|
||||
links: LinkItem[]
|
||||
}
|
||||
|
||||
interface UserGroupsData {
|
||||
username: string
|
||||
groups: Group[]
|
||||
}
|
||||
|
||||
export default async function UserPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ username: string }>
|
||||
}) {
|
||||
const { username } = await params
|
||||
const API = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'
|
||||
|
||||
const res = await fetch(`${API}/api/users/${username}/public`, {
|
||||
cache: 'no-store',
|
||||
})
|
||||
if (res.status === 404) return notFound()
|
||||
if (!res.ok) throw new Error('Ошибка загрузки публичных данных')
|
||||
|
||||
const data: UserGroupsData = await res.json()
|
||||
|
||||
return (
|
||||
<main className="pb-8">
|
||||
<div className="container">
|
||||
<h2 className="text-center mb-4">{data.username}</h2>
|
||||
|
||||
<div className="accordion" id="groupsAccordion">
|
||||
{data.groups.map((group) => {
|
||||
const groupId = `group-${group.id}`
|
||||
|
||||
return (
|
||||
<div
|
||||
key={group.id}
|
||||
className="accordion-item mb-3"
|
||||
style={{ border: '1px solid #dee2e6', borderRadius: 4 }}
|
||||
>
|
||||
<h2 className="accordion-header" id={`${groupId}-header`}>
|
||||
<button
|
||||
className="accordion-button collapsed d-flex align-items-center"
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target={`#${groupId}-collapse`}
|
||||
aria-expanded="false"
|
||||
aria-controls={`${groupId}-collapse`}
|
||||
>
|
||||
{group.image && (
|
||||
<Image
|
||||
src={
|
||||
group.image.startsWith('http')
|
||||
? group.image
|
||||
: `${API}${group.image}`
|
||||
}
|
||||
alt={group.name}
|
||||
width={32}
|
||||
height={32}
|
||||
className="me-2 rounded"
|
||||
priority
|
||||
/>
|
||||
)}
|
||||
<span className="me-2">{group.name}</span>
|
||||
<span className="badge bg-secondary rounded-pill">
|
||||
{group.links.length}
|
||||
</span>
|
||||
</button>
|
||||
</h2>
|
||||
<div
|
||||
id={`${groupId}-collapse`}
|
||||
className="accordion-collapse collapse"
|
||||
aria-labelledby={`${groupId}-header`}
|
||||
data-bs-parent="#groupsAccordion"
|
||||
>
|
||||
<div className="accordion-body">
|
||||
{group.links.length > 0 ? (
|
||||
<ul className="list-unstyled">
|
||||
{group.links.map((link) => (
|
||||
<li
|
||||
key={link.id}
|
||||
className="mb-2 p-2 bg-white rounded shadow-sm"
|
||||
style={{ marginBottom: 5 }}
|
||||
>
|
||||
<div className="d-flex align-items-center">
|
||||
{link.image && (
|
||||
<Image
|
||||
src={
|
||||
link.image.startsWith('http')
|
||||
? link.image
|
||||
: `${API}${link.image}`
|
||||
}
|
||||
alt={link.title}
|
||||
width={24}
|
||||
height={24}
|
||||
className="me-2"
|
||||
priority
|
||||
/>
|
||||
)}
|
||||
<Link
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex-grow-1"
|
||||
>
|
||||
{link.title}
|
||||
</Link>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-muted mb-0">
|
||||
В этой группе пока нет ссылок.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
// src/app/[username]/page.tsx
|
||||
import { notFound } from 'next/navigation'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
|
||||
interface LinkItem {
|
||||
id: number
|
||||
title: string
|
||||
url: string
|
||||
image?: string
|
||||
}
|
||||
|
||||
interface Group {
|
||||
id: number
|
||||
name: string
|
||||
image?: string
|
||||
links: LinkItem[]
|
||||
}
|
||||
|
||||
interface UserGroupsData {
|
||||
username: string
|
||||
groups: Group[]
|
||||
}
|
||||
|
||||
export default async function UserPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ username: string }>
|
||||
}) {
|
||||
const { username } = await params
|
||||
const API = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'
|
||||
|
||||
const res = await fetch(`${API}/api/users/${username}/public`, {
|
||||
cache: 'no-store',
|
||||
})
|
||||
if (res.status === 404) return notFound()
|
||||
if (!res.ok) throw new Error('Ошибка загрузки публичных данных')
|
||||
|
||||
const data: UserGroupsData = await res.json()
|
||||
|
||||
return (
|
||||
<main className="pb-8">
|
||||
<div className="container">
|
||||
<h2 className="text-center mb-4">{data.username}</h2>
|
||||
|
||||
<div className="accordion" id="groupsAccordion">
|
||||
{data.groups.map((group) => {
|
||||
const groupId = `group-${group.id}`
|
||||
|
||||
return (
|
||||
<div
|
||||
key={group.id}
|
||||
className="accordion-item mb-3"
|
||||
style={{ border: '1px solid #dee2e6', borderRadius: 4 }}
|
||||
>
|
||||
<h2 className="accordion-header" id={`${groupId}-header`}>
|
||||
<button
|
||||
className="accordion-button collapsed d-flex align-items-center"
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target={`#${groupId}-collapse`}
|
||||
aria-expanded="false"
|
||||
aria-controls={`${groupId}-collapse`}
|
||||
>
|
||||
{group.image && (
|
||||
<Image
|
||||
src={
|
||||
group.image.startsWith('http')
|
||||
? group.image
|
||||
: `${API}${group.image}`
|
||||
}
|
||||
alt={group.name}
|
||||
width={32}
|
||||
height={32}
|
||||
className="me-2 rounded"
|
||||
priority
|
||||
/>
|
||||
)}
|
||||
<span className="me-2">{group.name}</span>
|
||||
<span className="badge bg-secondary rounded-pill">
|
||||
{group.links.length}
|
||||
</span>
|
||||
</button>
|
||||
</h2>
|
||||
<div
|
||||
id={`${groupId}-collapse`}
|
||||
className="accordion-collapse collapse"
|
||||
aria-labelledby={`${groupId}-header`}
|
||||
data-bs-parent="#groupsAccordion"
|
||||
>
|
||||
<div className="accordion-body">
|
||||
{group.links.length > 0 ? (
|
||||
<ul className="list-unstyled">
|
||||
{group.links.map((link) => (
|
||||
<li
|
||||
key={link.id}
|
||||
className="mb-2 p-2 bg-white rounded shadow-sm"
|
||||
style={{ marginBottom: 5 }}
|
||||
>
|
||||
<div className="d-flex align-items-center">
|
||||
{link.image && (
|
||||
<Image
|
||||
src={
|
||||
link.image.startsWith('http')
|
||||
? link.image
|
||||
: `${API}${link.image}`
|
||||
}
|
||||
alt={link.title}
|
||||
width={24}
|
||||
height={24}
|
||||
className="me-2"
|
||||
priority
|
||||
/>
|
||||
)}
|
||||
<Link
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex-grow-1"
|
||||
>
|
||||
{link.title}
|
||||
</Link>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-muted mb-0">
|
||||
В этой группе пока нет ссылок.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
// src/components/LayoutWrapper.tsx
|
||||
'use client'
|
||||
|
||||
import React, { ReactNode, useEffect, useState } from 'react'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import Image from 'next/image'
|
||||
import Script from 'next/script'
|
||||
|
||||
interface User {
|
||||
username: string
|
||||
avatar: string
|
||||
}
|
||||
|
||||
export function LayoutWrapper({ children }: { children: ReactNode }) {
|
||||
const pathname = usePathname() || ''
|
||||
const isPublicUserPage = /^\/[^\/]+$/.test(pathname)
|
||||
const isDashboard = pathname === '/dashboard'
|
||||
const [user, setUser] = useState<User | null>(null)
|
||||
const router = useRouter()
|
||||
|
||||
// При монтировании пробуем загрузить профиль
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (token) {
|
||||
fetch('/api/auth/user', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
.then(res => {
|
||||
if (!res.ok) throw new Error()
|
||||
return res.json()
|
||||
})
|
||||
.then(data => {
|
||||
// fullname или username
|
||||
const name = data.full_name?.trim() || data.username
|
||||
setUser({ username: name, avatar: data.avatar })
|
||||
})
|
||||
.catch(() => {
|
||||
// сбросить некорректный токен
|
||||
localStorage.removeItem('token')
|
||||
setUser(null)
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('token')
|
||||
router.push('/')
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Шапка не выводим на публичных страницах /[username] */}
|
||||
{!isPublicUserPage && (
|
||||
<nav className="navbar navbar-expand bg-light fixed-top shadow-sm">
|
||||
<div className="container">
|
||||
<Link href="/" className="navbar-brand d-flex align-items-center">
|
||||
<Image
|
||||
src="/assets/img/CAT.png"
|
||||
alt="CatLink"
|
||||
width={89}
|
||||
height={89}
|
||||
/>
|
||||
<span className="ms-2">CatLink</span>
|
||||
</Link>
|
||||
<button
|
||||
className="navbar-toggler"
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#navcol-1"
|
||||
/>
|
||||
<div className="collapse navbar-collapse" id="navcol-1">
|
||||
{!user && (
|
||||
<Link href="/auth/login" className="btn btn-primary ms-auto">
|
||||
<i className="fa fa-user"></i>
|
||||
<span className="d-none d-sm-inline"> Вход</span>
|
||||
</Link>
|
||||
)}
|
||||
{user && (
|
||||
<div className="ms-auto d-flex align-items-center gap-3">
|
||||
<Image
|
||||
src={
|
||||
user.avatar.startsWith('http')
|
||||
? user.avatar
|
||||
: `${process.env.NEXT_PUBLIC_API_URL}${user.avatar}`
|
||||
}
|
||||
alt="Avatar"
|
||||
width={32}
|
||||
height={32}
|
||||
className="rounded-circle"
|
||||
/>
|
||||
<span>{user.username}</span>
|
||||
{!isDashboard && (
|
||||
<Link href="/dashboard" className="btn btn-outline-secondary btn-sm">
|
||||
Дашборд
|
||||
</Link>
|
||||
)}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="btn btn-outline-danger btn-sm"
|
||||
>
|
||||
Выход
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
)}
|
||||
|
||||
{/* отступ, чтобы контент не прятался под фиксированным хедером */}
|
||||
{!isPublicUserPage && <div style={{ height: 70 }} />}
|
||||
|
||||
{children}
|
||||
|
||||
{/* Подвал не выводим на публичных страницах */}
|
||||
{!isPublicUserPage && (
|
||||
<footer className="bg-light footer fixed-bottom border-top">
|
||||
<div className="container py-2">
|
||||
<div className="row">
|
||||
<div className="col-lg-6 text-center text-lg-start mb-2 mb-lg-0">
|
||||
<ul className="list-inline mb-1">
|
||||
<li className="list-inline-item"><Link href="#">About</Link></li>
|
||||
<li className="list-inline-item">⋅</li>
|
||||
<li className="list-inline-item"><Link href="#">Contact</Link></li>
|
||||
<li className="list-inline-item">⋅</li>
|
||||
<li className="list-inline-item"><Link href="#">Terms of Use</Link></li>
|
||||
<li className="list-inline-item">⋅</li>
|
||||
<li className="list-inline-item"><Link href="#">Privacy Policy</Link></li>
|
||||
</ul>
|
||||
<p className="text-muted small mb-0">© CatLink 2025</p>
|
||||
</div>
|
||||
<div className="col-lg-6 text-center text-lg-end">
|
||||
<ul className="list-inline mb-0">
|
||||
<li className="list-inline-item"><Link href="#"><i className="fa fa-facebook fa-lg fa-fw"></i></Link></li>
|
||||
<li className="list-inline-item"><Link href="#"><i className="fa fa-twitter fa-lg fa-fw"></i></Link></li>
|
||||
<li className="list-inline-item"><Link href="#"><i className="fa fa-instagram fa-lg fa-fw"></i></Link></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
)}
|
||||
|
||||
{/* Bootstrap JS */}
|
||||
<Script
|
||||
src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.0/jquery.min.js"
|
||||
strategy="beforeInteractive"
|
||||
/>
|
||||
<Script
|
||||
src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
|
||||
strategy="beforeInteractive"
|
||||
/>
|
||||
<Script src="/assets/js/bs-init.js" strategy="lazyOnload" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
// src/components/LayoutWrapper.tsx
|
||||
'use client'
|
||||
|
||||
import React, { ReactNode, useEffect, useState } from 'react'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import Image from 'next/image'
|
||||
import Script from 'next/script'
|
||||
|
||||
interface User {
|
||||
username: string
|
||||
avatar: string
|
||||
}
|
||||
|
||||
export function LayoutWrapper({ children }: { children: ReactNode }) {
|
||||
const pathname = usePathname() || ''
|
||||
const isPublicUserPage = /^\/[^\/]+$/.test(pathname)
|
||||
const isDashboard = pathname === '/dashboard'
|
||||
const [user, setUser] = useState<User | null>(null)
|
||||
const router = useRouter()
|
||||
|
||||
// При монтировании пробуем загрузить профиль
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (token) {
|
||||
fetch('/api/auth/user', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
.then(res => {
|
||||
if (!res.ok) throw new Error()
|
||||
return res.json()
|
||||
})
|
||||
.then(data => {
|
||||
// fullname или username
|
||||
const name = data.full_name?.trim() || data.username
|
||||
setUser({ username: name, avatar: data.avatar })
|
||||
})
|
||||
.catch(() => {
|
||||
// сбросить некорректный токен
|
||||
localStorage.removeItem('token')
|
||||
setUser(null)
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('token')
|
||||
router.push('/')
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Шапка не выводим на публичных страницах /[username] */}
|
||||
{!isPublicUserPage && (
|
||||
<nav className="navbar navbar-expand bg-light fixed-top shadow-sm">
|
||||
<div className="container">
|
||||
<Link href="/" className="navbar-brand d-flex align-items-center">
|
||||
<Image
|
||||
src="/assets/img/CAT.png"
|
||||
alt="CatLink"
|
||||
width={89}
|
||||
height={89}
|
||||
/>
|
||||
<span className="ms-2">CatLink</span>
|
||||
</Link>
|
||||
<button
|
||||
className="navbar-toggler"
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#navcol-1"
|
||||
/>
|
||||
<div className="collapse navbar-collapse" id="navcol-1">
|
||||
{!user && (
|
||||
<Link href="/auth/login" className="btn btn-primary ms-auto">
|
||||
<i className="fa fa-user"></i>
|
||||
<span className="d-none d-sm-inline"> Вход</span>
|
||||
</Link>
|
||||
)}
|
||||
{user && (
|
||||
<div className="ms-auto d-flex align-items-center gap-3">
|
||||
<Image
|
||||
src={
|
||||
user.avatar.startsWith('http')
|
||||
? user.avatar
|
||||
: `http://localhost:8000${user.avatar}`
|
||||
}
|
||||
alt="Avatar"
|
||||
width={32}
|
||||
height={32}
|
||||
className="rounded-circle"
|
||||
/>
|
||||
<span>{user.username}</span>
|
||||
{!isDashboard && (
|
||||
<Link href="/dashboard" className="btn btn-outline-secondary btn-sm">
|
||||
Дашборд
|
||||
</Link>
|
||||
)}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="btn btn-outline-danger btn-sm"
|
||||
>
|
||||
Выход
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
)}
|
||||
|
||||
{/* отступ, чтобы контент не прятался под фиксированным хедером */}
|
||||
{!isPublicUserPage && <div style={{ height: 70 }} />}
|
||||
|
||||
{children}
|
||||
|
||||
{/* Подвал не выводим на публичных страницах */}
|
||||
{!isPublicUserPage && (
|
||||
<footer className="bg-light footer fixed-bottom border-top">
|
||||
<div className="container py-2">
|
||||
<div className="row">
|
||||
<div className="col-lg-6 text-center text-lg-start mb-2 mb-lg-0">
|
||||
<ul className="list-inline mb-1">
|
||||
<li className="list-inline-item"><Link href="#">About</Link></li>
|
||||
<li className="list-inline-item">⋅</li>
|
||||
<li className="list-inline-item"><Link href="#">Contact</Link></li>
|
||||
<li className="list-inline-item">⋅</li>
|
||||
<li className="list-inline-item"><Link href="#">Terms of Use</Link></li>
|
||||
<li className="list-inline-item">⋅</li>
|
||||
<li className="list-inline-item"><Link href="#">Privacy Policy</Link></li>
|
||||
</ul>
|
||||
<p className="text-muted small mb-0">© CatLink 2025</p>
|
||||
</div>
|
||||
<div className="col-lg-6 text-center text-lg-end">
|
||||
<ul className="list-inline mb-0">
|
||||
<li className="list-inline-item"><Link href="#"><i className="fa fa-facebook fa-lg fa-fw"></i></Link></li>
|
||||
<li className="list-inline-item"><Link href="#"><i className="fa fa-twitter fa-lg fa-fw"></i></Link></li>
|
||||
<li className="list-inline-item"><Link href="#"><i className="fa fa-instagram fa-lg fa-fw"></i></Link></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
)}
|
||||
|
||||
{/* Bootstrap JS */}
|
||||
<Script
|
||||
src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.0/jquery.min.js"
|
||||
strategy="beforeInteractive"
|
||||
/>
|
||||
<Script
|
||||
src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
|
||||
strategy="beforeInteractive"
|
||||
/>
|
||||
<Script src="/assets/js/bs-init.js" strategy="lazyOnload" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
// src/components/LayoutWrapper.tsx
|
||||
'use client'
|
||||
|
||||
import React, { ReactNode, useEffect, useState } from 'react'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import Image from 'next/image'
|
||||
import Script from 'next/script'
|
||||
|
||||
interface User {
|
||||
username: string
|
||||
avatar: string
|
||||
}
|
||||
|
||||
export function LayoutWrapper({ children }: { children: ReactNode }) {
|
||||
const pathname = usePathname() || ''
|
||||
const isPublicUserPage = /^\/[^\/]+$/.test(pathname)
|
||||
const isDashboard = pathname === '/dashboard'
|
||||
const [user, setUser] = useState<User | null>(null)
|
||||
const router = useRouter()
|
||||
|
||||
// При монтировании пробуем загрузить профиль
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (token) {
|
||||
fetch('/api/auth/user', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
.then(res => {
|
||||
if (!res.ok) throw new Error()
|
||||
return res.json()
|
||||
})
|
||||
.then(data => {
|
||||
// fullname или username
|
||||
const name = data.full_name?.trim() || data.username
|
||||
setUser({ username: name, avatar: data.avatar })
|
||||
})
|
||||
.catch(() => {
|
||||
// сбросить некорректный токен
|
||||
localStorage.removeItem('token')
|
||||
setUser(null)
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('token')
|
||||
router.push('/')
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Шапка не выводим на публичных страницах /[username] */}
|
||||
{!isPublicUserPage && (
|
||||
<nav className="navbar navbar-expand bg-light fixed-top shadow-sm">
|
||||
<div className="container">
|
||||
<Link href="/" className="navbar-brand d-flex align-items-center">
|
||||
<Image
|
||||
src="/assets/img/CAT.png"
|
||||
alt="CatLink"
|
||||
width={89}
|
||||
height={89}
|
||||
/>
|
||||
<span className="ms-2">CatLink</span>
|
||||
</Link>
|
||||
<button
|
||||
className="navbar-toggler"
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#navcol-1"
|
||||
/>
|
||||
<div className="collapse navbar-collapse" id="navcol-1">
|
||||
{!user && (
|
||||
<Link href="/auth/login" className="btn btn-primary ms-auto">
|
||||
<i className="fa fa-user"></i>
|
||||
<span className="d-none d-sm-inline"> Вход</span>
|
||||
</Link>
|
||||
)}
|
||||
{user && (
|
||||
<div className="ms-auto d-flex align-items-center gap-3">
|
||||
<Image
|
||||
src={
|
||||
user.avatar.startsWith('http')
|
||||
? user.avatar
|
||||
: `http://localhost:8000${user.avatar}`
|
||||
}
|
||||
alt="Avatar"
|
||||
width={32}
|
||||
height={32}
|
||||
className="rounded-circle"
|
||||
/>
|
||||
<span>{user.username}</span>
|
||||
{!isDashboard && (
|
||||
<Link href="/dashboard" className="btn btn-outline-secondary btn-sm">
|
||||
Дашборд
|
||||
</Link>
|
||||
)}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="btn btn-outline-danger btn-sm"
|
||||
>
|
||||
Выход
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
)}
|
||||
|
||||
{/* отступ, чтобы контент не прятался под фиксированным хедером */}
|
||||
{!isPublicUserPage && <div style={{ height: 70 }} />}
|
||||
|
||||
{children}
|
||||
|
||||
{/* Подвал не выводим на публичных страницах */}
|
||||
{!isPublicUserPage && (
|
||||
<footer className="bg-light footer fixed-bottom border-top">
|
||||
<div className="container py-2">
|
||||
<div className="row">
|
||||
<div className="col-lg-6 text-center text-lg-start mb-2 mb-lg-0">
|
||||
<ul className="list-inline mb-1">
|
||||
<li className="list-inline-item"><Link href="#">About</Link></li>
|
||||
<li className="list-inline-item">⋅</li>
|
||||
<li className="list-inline-item"><Link href="#">Contact</Link></li>
|
||||
<li className="list-inline-item">⋅</li>
|
||||
<li className="list-inline-item"><Link href="#">Terms of Use</Link></li>
|
||||
<li className="list-inline-item">⋅</li>
|
||||
<li className="list-inline-item"><Link href="#">Privacy Policy</Link></li>
|
||||
</ul>
|
||||
<p className="text-muted small mb-0">© CatLink 2025</p>
|
||||
</div>
|
||||
<div className="col-lg-6 text-center text-lg-end">
|
||||
<ul className="list-inline mb-0">
|
||||
<li className="list-inline-item"><Link href="#"><i className="fa fa-facebook fa-lg fa-fw"></i></Link></li>
|
||||
<li className="list-inline-item"><Link href="#"><i className="fa fa-twitter fa-lg fa-fw"></i></Link></li>
|
||||
<li className="list-inline-item"><Link href="#"><i className="fa fa-instagram fa-lg fa-fw"></i></Link></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
)}
|
||||
|
||||
{/* Bootstrap JS */}
|
||||
<Script
|
||||
src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.0/jquery.min.js"
|
||||
strategy="beforeInteractive"
|
||||
/>
|
||||
<Script
|
||||
src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
|
||||
strategy="beforeInteractive"
|
||||
/>
|
||||
<Script src="/assets/js/bs-init.js" strategy="lazyOnload" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user