new models, frontend functions, public pages
This commit is contained in:
35
.history/backend/api/models_20250507135815.py
Normal file
35
.history/backend/api/models_20250507135815.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
from django.db import models
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
class LinkGroup(models.Model):
|
||||||
|
owner = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='api_link_groups' # уникальное имя, чтобы не конфликтовать с links app
|
||||||
|
)
|
||||||
|
name = models.CharField(max_length=100)
|
||||||
|
order = models.PositiveIntegerField(default=0)
|
||||||
|
description = models.TextField(blank=True, null=True)
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.owner.username} - {self.name}"
|
||||||
|
|
||||||
|
class Link(models.Model):
|
||||||
|
owner = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='links'
|
||||||
|
)
|
||||||
|
group = models.ForeignKey(
|
||||||
|
LinkGroup,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name='links'
|
||||||
|
)
|
||||||
|
title = models.CharField(max_length=200)
|
||||||
|
url = models.URLField()
|
||||||
|
icon = models.URLField(blank=True, null=True)
|
||||||
|
order = models.PositiveIntegerField(default=0)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.title
|
||||||
41
.history/backend/api/models_20250507135936.py
Normal file
41
.history/backend/api/models_20250507135936.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
from django.db import models
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
class LinkGroup(models.Model):
|
||||||
|
owner = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='api_link_groups' # уникальное имя, чтобы не конфликтовать с links app
|
||||||
|
)
|
||||||
|
name = models.CharField(max_length=100)
|
||||||
|
order = models.PositiveIntegerField(default=0)
|
||||||
|
description = models.TextField(blank=True, null=True)
|
||||||
|
icon = models.ImageField(
|
||||||
|
upload_to='link_groups/',
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text='Иконка группы ссылок'
|
||||||
|
)
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.owner.username} - {self.name}"
|
||||||
|
|
||||||
|
class Link(models.Model):
|
||||||
|
owner = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='links'
|
||||||
|
)
|
||||||
|
group = models.ForeignKey(
|
||||||
|
LinkGroup,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name='links'
|
||||||
|
)
|
||||||
|
title = models.CharField(max_length=200)
|
||||||
|
url = models.URLField()
|
||||||
|
icon = models.URLField(blank=True, null=True)
|
||||||
|
order = models.PositiveIntegerField(default=0)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.title
|
||||||
45
.history/backend/api/models_20250507140013.py
Normal file
45
.history/backend/api/models_20250507140013.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
from django.db import models
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
class LinkGroup(models.Model):
|
||||||
|
owner = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='api_link_groups' # уникальное имя, чтобы не конфликтовать с links app
|
||||||
|
)
|
||||||
|
name = models.CharField(max_length=100)
|
||||||
|
order = models.PositiveIntegerField(default=0)
|
||||||
|
description = models.TextField(blank=True, null=True)
|
||||||
|
icon = models.ImageField(
|
||||||
|
upload_to='link_groups/',
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text='Иконка группы ссылок'
|
||||||
|
)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
is_public = models.BooleanField(default=False, help_text='Публичная группа ссылок')
|
||||||
|
is_favorite = models.BooleanField(default=False, help_text='Избранная группа ссылок')
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.owner.username} - {self.name}"
|
||||||
|
|
||||||
|
class Link(models.Model):
|
||||||
|
owner = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='links'
|
||||||
|
)
|
||||||
|
group = models.ForeignKey(
|
||||||
|
LinkGroup,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name='links'
|
||||||
|
)
|
||||||
|
title = models.CharField(max_length=200)
|
||||||
|
url = models.URLField()
|
||||||
|
icon = models.URLField(blank=True, null=True)
|
||||||
|
order = models.PositiveIntegerField(default=0)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.title
|
||||||
54
.history/backend/api/models_20250507145448.py
Normal file
54
.history/backend/api/models_20250507145448.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# backend/api/models.py
|
||||||
|
|
||||||
|
from django.db import models
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
class LinkGroup(models.Model):
|
||||||
|
owner = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='api_link_groups'
|
||||||
|
)
|
||||||
|
name = models.CharField(max_length=100)
|
||||||
|
order = models.PositiveIntegerField(default=0)
|
||||||
|
description = models.TextField(blank=True, null=True)
|
||||||
|
icon = models.ImageField(
|
||||||
|
upload_to='link_groups/',
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text='Иконка группы ссылок'
|
||||||
|
)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
is_public = models.BooleanField(default=False)
|
||||||
|
is_favorite = models.BooleanField(default=False)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.owner.username} — {self.name}"
|
||||||
|
|
||||||
|
|
||||||
|
class Link(models.Model):
|
||||||
|
owner = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='links'
|
||||||
|
)
|
||||||
|
group = models.ForeignKey(
|
||||||
|
LinkGroup,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name='links'
|
||||||
|
)
|
||||||
|
title = models.CharField(max_length=200)
|
||||||
|
url = models.URLField()
|
||||||
|
icon = models.ImageField(
|
||||||
|
upload_to='links/',
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text='Иконка для этой ссылки'
|
||||||
|
)
|
||||||
|
order = models.PositiveIntegerField(default=0)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.title
|
||||||
60
.history/backend/api/serializers_20250507134438.py
Normal file
60
.history/backend/api/serializers_20250507134438.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
# api/serializers.py
|
||||||
|
from rest_framework import serializers
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from .models import Link, LinkGroup
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
class RegisterSerializer(serializers.ModelSerializer):
|
||||||
|
password = serializers.CharField(write_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = User
|
||||||
|
fields = ('username', 'email', 'password')
|
||||||
|
|
||||||
|
def create(self, validated_data):
|
||||||
|
user = User(
|
||||||
|
username=validated_data['username'],
|
||||||
|
email=validated_data.get('email', '')
|
||||||
|
)
|
||||||
|
user.set_password(validated_data['password'])
|
||||||
|
user.save()
|
||||||
|
return user
|
||||||
|
# api/serializers.py
|
||||||
|
from rest_framework import serializers
|
||||||
|
from django.conf import settings
|
||||||
|
from .models import Link, LinkGroup
|
||||||
|
|
||||||
|
# сериализатор для ссылок
|
||||||
|
class LinkSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = Link
|
||||||
|
fields = ['id', 'title', 'url', 'icon', 'order', 'group']
|
||||||
|
|
||||||
|
# сериализатор для групп со вложенными ссылками
|
||||||
|
class LinkGroupSerializer(serializers.ModelSerializer):
|
||||||
|
owner = serializers.ReadOnlyField(source='owner.username')
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = LinkGroup
|
||||||
|
fields = [
|
||||||
|
'id',
|
||||||
|
'name',
|
||||||
|
'description',
|
||||||
|
'icon',
|
||||||
|
'owner',
|
||||||
|
'created_at',
|
||||||
|
]
|
||||||
|
read_only_fields = ['id', 'owner', 'created_at']
|
||||||
|
|
||||||
|
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
class UserSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = User
|
||||||
|
# поля, которые хотите отдавать на фронт:
|
||||||
|
fields = ['id', 'username', 'email', 'full_name', 'bio', 'avatar', 'last_login', 'date_joined']
|
||||||
65
.history/backend/api/serializers_20250507142241.py
Normal file
65
.history/backend/api/serializers_20250507142241.py
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
# api/serializers.py
|
||||||
|
from rest_framework import serializers
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from .models import Link, LinkGroup
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
class RegisterSerializer(serializers.ModelSerializer):
|
||||||
|
password = serializers.CharField(write_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = User
|
||||||
|
fields = ('username', 'email', 'password')
|
||||||
|
|
||||||
|
def create(self, validated_data):
|
||||||
|
user = User(
|
||||||
|
username=validated_data['username'],
|
||||||
|
email=validated_data.get('email', '')
|
||||||
|
)
|
||||||
|
user.set_password(validated_data['password'])
|
||||||
|
user.save()
|
||||||
|
return user
|
||||||
|
# api/serializers.py
|
||||||
|
from rest_framework import serializers
|
||||||
|
from django.conf import settings
|
||||||
|
from .models import Link, LinkGroup
|
||||||
|
|
||||||
|
# сериализатор для ссылок
|
||||||
|
class LinkSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = Link
|
||||||
|
fields = ['id', 'title', 'url', 'icon', 'order', 'group']
|
||||||
|
|
||||||
|
# сериализатор для групп со вложенными ссылками
|
||||||
|
class LinkGroupSerializer(serializers.ModelSerializer):
|
||||||
|
owner = serializers.ReadOnlyField(source='owner.username')
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = LinkGroup
|
||||||
|
fields = [
|
||||||
|
'id',
|
||||||
|
'name',
|
||||||
|
'description',
|
||||||
|
'icon',
|
||||||
|
'owner',
|
||||||
|
'created_at',
|
||||||
|
]
|
||||||
|
read_only_fields = ['id', 'owner', 'created_at']
|
||||||
|
|
||||||
|
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
class UserSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = User
|
||||||
|
# поля, которые хотите отдавать на фронт:
|
||||||
|
fields = ['id', 'username', 'email', 'full_name', 'bio', 'avatar', 'last_login', 'date_joined']
|
||||||
|
|
||||||
|
|
||||||
|
class PublicUserGroupsSerializer(serializers.Serializer):
|
||||||
|
username = serializers.CharField()
|
||||||
|
groups = LinkGroupSerializer(many=True)
|
||||||
66
.history/backend/api/serializers_20250507145525.py
Normal file
66
.history/backend/api/serializers_20250507145525.py
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
# api/serializers.py
|
||||||
|
from rest_framework import serializers
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from .models import Link, LinkGroup
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
class RegisterSerializer(serializers.ModelSerializer):
|
||||||
|
password2 = serializers.CharField(write_only=True)
|
||||||
|
class Meta:
|
||||||
|
model = User
|
||||||
|
fields = ['username', 'email', 'first_name', 'last_name', 'password', 'password2']
|
||||||
|
extra_kwargs = {'password': {'write_only': True}}
|
||||||
|
|
||||||
|
def validate(self, attrs):
|
||||||
|
if attrs['password'] != attrs.pop('password2'):
|
||||||
|
raise serializers.ValidationError("Пароли не совпадают")
|
||||||
|
return attrs
|
||||||
|
|
||||||
|
def create(self, validated_data):
|
||||||
|
return User.objects.create_user(**validated_data)
|
||||||
|
|
||||||
|
|
||||||
|
# api/serializers.py
|
||||||
|
from rest_framework import serializers
|
||||||
|
from django.conf import settings
|
||||||
|
from .models import Link, LinkGroup
|
||||||
|
|
||||||
|
# сериализатор для ссылок
|
||||||
|
class LinkSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = Link
|
||||||
|
fields = ['id', 'title', 'url', 'icon', 'order', 'group']
|
||||||
|
|
||||||
|
# сериализатор для групп со вложенными ссылками
|
||||||
|
class LinkGroupSerializer(serializers.ModelSerializer):
|
||||||
|
owner = serializers.ReadOnlyField(source='owner.username')
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = LinkGroup
|
||||||
|
fields = [
|
||||||
|
'id',
|
||||||
|
'name',
|
||||||
|
'description',
|
||||||
|
'icon',
|
||||||
|
'owner',
|
||||||
|
'created_at',
|
||||||
|
]
|
||||||
|
read_only_fields = ['id', 'owner', 'created_at']
|
||||||
|
|
||||||
|
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
class UserSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = User
|
||||||
|
# поля, которые хотите отдавать на фронт:
|
||||||
|
fields = ['id', 'username', 'email', 'full_name', 'bio', 'avatar', 'last_login', 'date_joined']
|
||||||
|
|
||||||
|
|
||||||
|
class PublicUserGroupsSerializer(serializers.Serializer):
|
||||||
|
username = serializers.CharField()
|
||||||
|
groups = LinkGroupSerializer(many=True)
|
||||||
48
.history/backend/api/serializers_20250507145609.py
Normal file
48
.history/backend/api/serializers_20250507145609.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# api/serializers.py
|
||||||
|
from rest_framework import serializers
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from .models import Link, LinkGroup
|
||||||
|
from django.conf import settings
|
||||||
|
from .models import Link, LinkGroup
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
class RegisterSerializer(serializers.ModelSerializer):
|
||||||
|
password2 = serializers.CharField(write_only=True)
|
||||||
|
class Meta:
|
||||||
|
model = User
|
||||||
|
fields = ['username', 'email', 'first_name', 'last_name', 'password', 'password2']
|
||||||
|
extra_kwargs = {'password': {'write_only': True}}
|
||||||
|
|
||||||
|
def validate(self, attrs):
|
||||||
|
if attrs['password'] != attrs.pop('password2'):
|
||||||
|
raise serializers.ValidationError("Пароли не совпадают")
|
||||||
|
return attrs
|
||||||
|
|
||||||
|
def create(self, validated_data):
|
||||||
|
return User.objects.create_user(**validated_data)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# сериализатор для ссылок
|
||||||
|
class UserSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = User
|
||||||
|
fields = ['id', 'username', 'email', 'full_name', 'bio', 'avatar', 'last_login', 'date_joined']
|
||||||
|
|
||||||
|
|
||||||
|
class LinkGroupSerializer(serializers.ModelSerializer):
|
||||||
|
icon = serializers.ImageField(required=False, allow_null=True)
|
||||||
|
class Meta:
|
||||||
|
model = LinkGroup
|
||||||
|
fields = ['id', 'name', 'description', 'icon', 'order', 'is_public', 'is_favorite',
|
||||||
|
'created_at', 'updated_at']
|
||||||
|
read_only_fields = ['id', 'created_at', 'updated_at']
|
||||||
|
|
||||||
|
|
||||||
|
class LinkSerializer(serializers.ModelSerializer):
|
||||||
|
icon = serializers.ImageField(required=False, allow_null=True)
|
||||||
|
class Meta:
|
||||||
|
model = Link
|
||||||
|
fields = ['id', 'title', 'url', 'icon', 'group', 'order']
|
||||||
|
read_only_fields = ['id']
|
||||||
24
.history/backend/api/urls_20250507072806.py
Normal file
24
.history/backend/api/urls_20250507072806.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
from django.urls import path
|
||||||
|
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
|
||||||
|
from .views import (
|
||||||
|
RegisterView,
|
||||||
|
UserProfileView,
|
||||||
|
LinkViewSet,
|
||||||
|
LinkGroupViewSet
|
||||||
|
)
|
||||||
|
from rest_framework.routers import DefaultRouter
|
||||||
|
|
||||||
|
router = DefaultRouter()
|
||||||
|
router.register('links', LinkViewSet, basename='link')
|
||||||
|
router.register('groups', LinkGroupViewSet, basename='group')
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('auth/register/', RegisterView.as_view(), name='auth_register'),
|
||||||
|
path('auth/login/', TokenObtainPairView.as_view(), name='token_obtain_pair'),
|
||||||
|
path('auth/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
|
||||||
|
path('auth/user/', UserProfileView.as_view(), name='user-profile'), # ← новый
|
||||||
|
path('users/<str:username>/public/',
|
||||||
|
PublicUserGroupsView.as_view(),
|
||||||
|
name='public-user-groups'
|
||||||
|
),
|
||||||
|
] + router.urls
|
||||||
25
.history/backend/api/urls_20250507072816.py
Normal file
25
.history/backend/api/urls_20250507072816.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
from django.urls import path
|
||||||
|
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
|
||||||
|
from .views import (
|
||||||
|
RegisterView,
|
||||||
|
UserProfileView,
|
||||||
|
LinkViewSet,
|
||||||
|
LinkGroupViewSet,
|
||||||
|
PublicUserGroupsView
|
||||||
|
)
|
||||||
|
from rest_framework.routers import DefaultRouter
|
||||||
|
|
||||||
|
router = DefaultRouter()
|
||||||
|
router.register('links', LinkViewSet, basename='link')
|
||||||
|
router.register('groups', LinkGroupViewSet, basename='group')
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('auth/register/', RegisterView.as_view(), name='auth_register'),
|
||||||
|
path('auth/login/', TokenObtainPairView.as_view(), name='token_obtain_pair'),
|
||||||
|
path('auth/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
|
||||||
|
path('auth/user/', UserProfileView.as_view(), name='user-profile'), # ← новый
|
||||||
|
path('users/<str:username>/public/',
|
||||||
|
PublicUserGroupsView.as_view(),
|
||||||
|
name='public-user-groups'
|
||||||
|
),
|
||||||
|
] + router.urls
|
||||||
28
.history/backend/api/urls_20250507080627.py
Normal file
28
.history/backend/api/urls_20250507080627.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
from django.urls import path
|
||||||
|
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
|
||||||
|
from .views import (
|
||||||
|
RegisterView,
|
||||||
|
UserProfileView,
|
||||||
|
LinkViewSet,
|
||||||
|
LinkGroupViewSet,
|
||||||
|
PublicUserGroupsView
|
||||||
|
)
|
||||||
|
from rest_framework.routers import DefaultRouter
|
||||||
|
|
||||||
|
router = DefaultRouter()
|
||||||
|
router.register('links', LinkViewSet, basename='link')
|
||||||
|
router.register('groups', LinkGroupViewSet, basename='group')
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('auth/register/', RegisterView.as_view(), name='auth_register'),
|
||||||
|
path('auth/login/', TokenObtainPairView.as_view(), name='token_obtain_pair'),
|
||||||
|
path('auth/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
|
||||||
|
path('auth/user/', UserProfileView.as_view(), name='user-profile'), # ← новый
|
||||||
|
path('users/<str:username>/public/',
|
||||||
|
PublicUserGroupsView.as_view(),
|
||||||
|
name='public-user-groups'
|
||||||
|
),
|
||||||
|
path('schema/', SpectacularAPIView.as_view(), name='schema'),
|
||||||
|
path('swagger/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'),
|
||||||
|
|
||||||
|
] + router.urls
|
||||||
29
.history/backend/api/urls_20250507080637.py
Normal file
29
.history/backend/api/urls_20250507080637.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
from django.urls import path
|
||||||
|
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
|
||||||
|
from .views import (
|
||||||
|
RegisterView,
|
||||||
|
UserProfileView,
|
||||||
|
LinkViewSet,
|
||||||
|
LinkGroupViewSet,
|
||||||
|
PublicUserGroupsView
|
||||||
|
)
|
||||||
|
from rest_framework.routers import DefaultRouter
|
||||||
|
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView
|
||||||
|
|
||||||
|
router = DefaultRouter()
|
||||||
|
router.register('links', LinkViewSet, basename='link')
|
||||||
|
router.register('groups', LinkGroupViewSet, basename='group')
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('auth/register/', RegisterView.as_view(), name='auth_register'),
|
||||||
|
path('auth/login/', TokenObtainPairView.as_view(), name='token_obtain_pair'),
|
||||||
|
path('auth/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
|
||||||
|
path('auth/user/', UserProfileView.as_view(), name='user-profile'), # ← новый
|
||||||
|
path('users/<str:username>/public/',
|
||||||
|
PublicUserGroupsView.as_view(),
|
||||||
|
name='public-user-groups'
|
||||||
|
),
|
||||||
|
path('schema/', SpectacularAPIView.as_view(), name='schema'),
|
||||||
|
path('swagger/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'),
|
||||||
|
|
||||||
|
] + router.urls
|
||||||
35
.history/backend/api/urls_20250507080822.py
Normal file
35
.history/backend/api/urls_20250507080822.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
from django.urls import path
|
||||||
|
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
|
||||||
|
from .views import (
|
||||||
|
RegisterView,
|
||||||
|
UserProfileView,
|
||||||
|
LinkViewSet,
|
||||||
|
LinkGroupViewSet,
|
||||||
|
PublicUserGroupsView
|
||||||
|
)
|
||||||
|
from rest_framework.routers import DefaultRouter
|
||||||
|
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView
|
||||||
|
|
||||||
|
router = DefaultRouter()
|
||||||
|
router.register('links', LinkViewSet, basename='link')
|
||||||
|
router.register('groups', LinkGroupViewSet, basename='group')
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('auth/register/', RegisterView.as_view(), name='auth_register'),
|
||||||
|
path('auth/login/', TokenObtainPairView.as_view(), name='token_obtain_pair'),
|
||||||
|
path('auth/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
|
||||||
|
path('auth/user/', UserProfileView.as_view(), name='user-profile'), # ← новый
|
||||||
|
path('users/<str:username>/public/',
|
||||||
|
PublicUserGroupsView.as_view(),
|
||||||
|
name='public-user-groups'
|
||||||
|
),
|
||||||
|
# схема OpenAPI
|
||||||
|
path('api/schema/', SpectacularAPIView.as_view(), name='schema'),
|
||||||
|
# Swagger UI, берёт шаблон из drf_spectacular_sidecar
|
||||||
|
path(
|
||||||
|
'api/swagger/',
|
||||||
|
SpectacularSwaggerView.as_view(url_name='schema'),
|
||||||
|
name='swagger-ui'
|
||||||
|
),
|
||||||
|
|
||||||
|
] + router.urls
|
||||||
35
.history/backend/api/urls_20250507081428.py
Normal file
35
.history/backend/api/urls_20250507081428.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
from django.urls import path
|
||||||
|
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
|
||||||
|
from .views import (
|
||||||
|
RegisterView,
|
||||||
|
UserProfileView,
|
||||||
|
LinkViewSet,
|
||||||
|
LinkGroupViewSet,
|
||||||
|
PublicUserGroupsView
|
||||||
|
)
|
||||||
|
from rest_framework.routers import DefaultRouter
|
||||||
|
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView
|
||||||
|
|
||||||
|
router = DefaultRouter()
|
||||||
|
router.register('links', LinkViewSet, basename='link')
|
||||||
|
router.register('groups', LinkGroupViewSet, basename='group')
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('auth/register/', RegisterView.as_view(), name='auth_register'),
|
||||||
|
path('auth/login/', TokenObtainPairView.as_view(), name='token_obtain_pair'),
|
||||||
|
path('auth/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
|
||||||
|
path('auth/user/', UserProfileView.as_view(), name='user-profile'), # ← новый
|
||||||
|
path('users/<str:username>/public/',
|
||||||
|
PublicUserGroupsView.as_view(),
|
||||||
|
name='public-user-groups'
|
||||||
|
),
|
||||||
|
# схема OpenAPI
|
||||||
|
path('api/schema/', SpectacularAPIView.as_view(), name='schema'),
|
||||||
|
# Swagger UI, берёт шаблон из drf_spectacular_sidecar
|
||||||
|
path(
|
||||||
|
'api/swagger/',
|
||||||
|
SpectacularSwaggerView.as_view(url_name='schema'),
|
||||||
|
name='swagger-ui'
|
||||||
|
),
|
||||||
|
|
||||||
|
] + router.urls
|
||||||
35
.history/backend/api/urls_20250507081622.py
Normal file
35
.history/backend/api/urls_20250507081622.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
from django.urls import path
|
||||||
|
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
|
||||||
|
from .views import (
|
||||||
|
RegisterView,
|
||||||
|
UserProfileView,
|
||||||
|
LinkViewSet,
|
||||||
|
LinkGroupViewSet,
|
||||||
|
PublicUserGroupsView
|
||||||
|
)
|
||||||
|
from rest_framework.routers import DefaultRouter
|
||||||
|
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView
|
||||||
|
|
||||||
|
router = DefaultRouter()
|
||||||
|
router.register('links', LinkViewSet, basename='link')
|
||||||
|
router.register('groups', LinkGroupViewSet, basename='group')
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('auth/register/', RegisterView.as_view(), name='auth_register'),
|
||||||
|
path('auth/login/', TokenObtainPairView.as_view(), name='token_obtain_pair'),
|
||||||
|
path('auth/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
|
||||||
|
path('auth/user/', UserProfileView.as_view(), name='user-profile'), # ← новый
|
||||||
|
path('users/<str:username>/public/',
|
||||||
|
PublicUserGroupsView.as_view(),
|
||||||
|
name='public-user-groups'
|
||||||
|
),
|
||||||
|
# схема OpenAPI
|
||||||
|
path('schema/', SpectacularAPIView.as_view(), name='schema'),
|
||||||
|
# Swagger UI, берёт шаблон из drf_spectacular_sidecar
|
||||||
|
path(
|
||||||
|
'swagger/',
|
||||||
|
SpectacularSwaggerView.as_view(url_name='schema'),
|
||||||
|
name='swagger-ui'
|
||||||
|
),
|
||||||
|
|
||||||
|
] + router.urls
|
||||||
113
.history/backend/api/views_20250507072833.py
Normal file
113
.history/backend/api/views_20250507072833.py
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
# api/views.py
|
||||||
|
from rest_framework import generics, viewsets, permissions
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from rest_framework_simplejwt.views import TokenObtainPairView
|
||||||
|
from .serializers import RegisterSerializer, LinkSerializer, LinkGroupSerializer
|
||||||
|
from .models import Link, LinkGroup
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
class RegisterView(generics.CreateAPIView):
|
||||||
|
queryset = User.objects.all()
|
||||||
|
permission_classes = (permissions.AllowAny,)
|
||||||
|
serializer_class = RegisterSerializer
|
||||||
|
|
||||||
|
class LoginView(TokenObtainPairView):
|
||||||
|
permission_classes = (permissions.AllowAny,)
|
||||||
|
|
||||||
|
class LinkGroupViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = LinkGroup.objects.all()
|
||||||
|
serializer_class = LinkGroupSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
# возвращаем только группы текущего пользователя
|
||||||
|
return self.queryset.filter(owner=self.request.user).order_by('order')
|
||||||
|
|
||||||
|
class LinkViewSet(viewsets.ModelViewSet):
|
||||||
|
serializer_class = LinkSerializer
|
||||||
|
permission_classes = (permissions.IsAuthenticated,)
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return Link.objects.filter(owner=self.request.user).order_by('order')
|
||||||
|
|
||||||
|
def perform_create(self, serializer):
|
||||||
|
serializer.save(owner=self.request.user)
|
||||||
|
|
||||||
|
class UserLinksListView(generics.ListAPIView):
|
||||||
|
serializer_class = LinkSerializer
|
||||||
|
permission_classes = (permissions.AllowAny,)
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
username = self.kwargs['username']
|
||||||
|
return Link.objects.filter(owner__username=username).order_by('order')
|
||||||
|
|
||||||
|
|
||||||
|
from .serializers import UserSerializer # нужно завести сериализатор для пользователя
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
class UserProfileView(generics.RetrieveAPIView):
|
||||||
|
"""
|
||||||
|
Возвращает данные авторизованного пользователя.
|
||||||
|
GET /api/auth/user/
|
||||||
|
"""
|
||||||
|
serializer_class = UserSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
|
def get_object(self):
|
||||||
|
return self.request.user
|
||||||
|
|
||||||
|
|
||||||
|
class PublicUserGroupsView(APIView):
|
||||||
|
"""
|
||||||
|
GET /api/users/{username}/public/
|
||||||
|
Возвращает публичную страницу пользователя:
|
||||||
|
{
|
||||||
|
"username": "...",
|
||||||
|
"groups": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "...",
|
||||||
|
"icon": "/storage/images/link_groups/1.png",
|
||||||
|
"links": [
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"title": "...",
|
||||||
|
"url": "...",
|
||||||
|
"icon": "/storage/images/links/5.png"
|
||||||
|
},
|
||||||
|
…
|
||||||
|
]
|
||||||
|
},
|
||||||
|
…
|
||||||
|
]
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
permission_classes = [permissions.AllowAny]
|
||||||
|
|
||||||
|
def get(self, request, username):
|
||||||
|
User = get_user_model()
|
||||||
|
user = get_object_or_404(User, username=username)
|
||||||
|
# достаём все группы пользователя вместе с их ссылками
|
||||||
|
groups_qs = LinkGroup.objects.filter(owner=user).prefetch_related('links')
|
||||||
|
data = {
|
||||||
|
"username": user.username,
|
||||||
|
"groups": []
|
||||||
|
}
|
||||||
|
for grp in groups_qs:
|
||||||
|
grp_data = {
|
||||||
|
"id": grp.id,
|
||||||
|
"name": grp.name,
|
||||||
|
"icon": grp.icon.url if grp.icon else None,
|
||||||
|
"links": []
|
||||||
|
}
|
||||||
|
for ln in grp.links.all():
|
||||||
|
grp_data["links"].append({
|
||||||
|
"id": ln.id,
|
||||||
|
"title": ln.title,
|
||||||
|
"url": ln.url,
|
||||||
|
"icon": ln.icon.url if ln.icon else None
|
||||||
|
})
|
||||||
|
data["groups"].append(grp_data)
|
||||||
|
return Response(data, status=status.HTTP_200_OK)
|
||||||
118
.history/backend/api/views_20250507072920.py
Normal file
118
.history/backend/api/views_20250507072920.py
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
# api/views.py
|
||||||
|
from rest_framework import generics, viewsets, permissions,status
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from rest_framework_simplejwt.views import TokenObtainPairView
|
||||||
|
from .serializers import RegisterSerializer, LinkSerializer, LinkGroupSerializer
|
||||||
|
from .models import Link, LinkGroup
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from django.shortcuts import get_object_or_404
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
class RegisterView(generics.CreateAPIView):
|
||||||
|
queryset = User.objects.all()
|
||||||
|
permission_classes = (permissions.AllowAny,)
|
||||||
|
serializer_class = RegisterSerializer
|
||||||
|
|
||||||
|
class LoginView(TokenObtainPairView):
|
||||||
|
permission_classes = (permissions.AllowAny,)
|
||||||
|
|
||||||
|
class LinkGroupViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = LinkGroup.objects.all()
|
||||||
|
serializer_class = LinkGroupSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
# возвращаем только группы текущего пользователя
|
||||||
|
return self.queryset.filter(owner=self.request.user).order_by('order')
|
||||||
|
|
||||||
|
class LinkViewSet(viewsets.ModelViewSet):
|
||||||
|
serializer_class = LinkSerializer
|
||||||
|
permission_classes = (permissions.IsAuthenticated,)
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return Link.objects.filter(owner=self.request.user).order_by('order')
|
||||||
|
|
||||||
|
def perform_create(self, serializer):
|
||||||
|
serializer.save(owner=self.request.user)
|
||||||
|
|
||||||
|
class UserLinksListView(generics.ListAPIView):
|
||||||
|
serializer_class = LinkSerializer
|
||||||
|
permission_classes = (permissions.AllowAny,)
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
username = self.kwargs['username']
|
||||||
|
return Link.objects.filter(owner__username=username).order_by('order')
|
||||||
|
|
||||||
|
|
||||||
|
from .serializers import UserSerializer # нужно завести сериализатор для пользователя
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
class UserProfileView(generics.RetrieveAPIView):
|
||||||
|
"""
|
||||||
|
Возвращает данные авторизованного пользователя.
|
||||||
|
GET /api/auth/user/
|
||||||
|
"""
|
||||||
|
serializer_class = UserSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
|
def get_object(self):
|
||||||
|
return self.request.user
|
||||||
|
|
||||||
|
|
||||||
|
class PublicUserGroupsView(APIView):
|
||||||
|
"""
|
||||||
|
GET /api/users/{username}/public/
|
||||||
|
Возвращает публичную страницу пользователя:
|
||||||
|
{
|
||||||
|
"username": "...",
|
||||||
|
"groups": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "...",
|
||||||
|
"icon": "/storage/images/link_groups/1.png",
|
||||||
|
"links": [
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"title": "...",
|
||||||
|
"url": "...",
|
||||||
|
"icon": "/storage/images/links/5.png"
|
||||||
|
},
|
||||||
|
…
|
||||||
|
]
|
||||||
|
},
|
||||||
|
…
|
||||||
|
]
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
permission_classes = [permissions.AllowAny]
|
||||||
|
|
||||||
|
def get(self, request, username):
|
||||||
|
User = get_user_model()
|
||||||
|
user = get_object_or_404(User, username=username)
|
||||||
|
# достаём все группы пользователя вместе с их ссылками
|
||||||
|
groups_qs = LinkGroup.objects.filter(owner=user).prefetch_related('links')
|
||||||
|
data = {
|
||||||
|
"username": user.username,
|
||||||
|
"groups": []
|
||||||
|
}
|
||||||
|
for grp in groups_qs:
|
||||||
|
grp_data = {
|
||||||
|
"id": grp.id,
|
||||||
|
"name": grp.name,
|
||||||
|
"icon": grp.icon.url if grp.icon else None,
|
||||||
|
"links": []
|
||||||
|
}
|
||||||
|
for ln in grp.links.all():
|
||||||
|
grp_data["links"].append({
|
||||||
|
"id": ln.id,
|
||||||
|
"title": ln.title,
|
||||||
|
"url": ln.url,
|
||||||
|
"icon": ln.icon.url if ln.icon else None
|
||||||
|
})
|
||||||
|
data["groups"].append(grp_data)
|
||||||
|
return Response(data, status=status.HTTP_200_OK)
|
||||||
109
.history/backend/api/views_20250507073141.py
Normal file
109
.history/backend/api/views_20250507073141.py
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
# api/views.py
|
||||||
|
from rest_framework import generics, viewsets, permissions,status
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from rest_framework_simplejwt.views import TokenObtainPairView
|
||||||
|
from .serializers import RegisterSerializer, LinkSerializer, LinkGroupSerializer
|
||||||
|
from .models import Link, LinkGroup
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from django.shortcuts import get_object_or_404
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
class RegisterView(generics.CreateAPIView):
|
||||||
|
queryset = User.objects.all()
|
||||||
|
permission_classes = (permissions.AllowAny,)
|
||||||
|
serializer_class = RegisterSerializer
|
||||||
|
|
||||||
|
class LoginView(TokenObtainPairView):
|
||||||
|
permission_classes = (permissions.AllowAny,)
|
||||||
|
|
||||||
|
class LinkGroupViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = LinkGroup.objects.all()
|
||||||
|
serializer_class = LinkGroupSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
# возвращаем только группы текущего пользователя
|
||||||
|
return self.queryset.filter(owner=self.request.user).order_by('order')
|
||||||
|
|
||||||
|
class LinkViewSet(viewsets.ModelViewSet):
|
||||||
|
serializer_class = LinkSerializer
|
||||||
|
permission_classes = (permissions.IsAuthenticated,)
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return Link.objects.filter(owner=self.request.user).order_by('order')
|
||||||
|
|
||||||
|
def perform_create(self, serializer):
|
||||||
|
serializer.save(owner=self.request.user)
|
||||||
|
|
||||||
|
class UserLinksListView(generics.ListAPIView):
|
||||||
|
serializer_class = LinkSerializer
|
||||||
|
permission_classes = (permissions.AllowAny,)
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
username = self.kwargs['username']
|
||||||
|
return Link.objects.filter(owner__username=username).order_by('order')
|
||||||
|
|
||||||
|
|
||||||
|
from .serializers import UserSerializer # нужно завести сериализатор для пользователя
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
class UserProfileView(generics.RetrieveAPIView):
|
||||||
|
"""
|
||||||
|
Возвращает данные авторизованного пользователя.
|
||||||
|
GET /api/auth/user/
|
||||||
|
"""
|
||||||
|
serializer_class = UserSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
|
def get_object(self):
|
||||||
|
return self.request.user
|
||||||
|
|
||||||
|
|
||||||
|
class PublicUserGroupsView(APIView):
|
||||||
|
"""
|
||||||
|
GET /api/users/{username}/public/
|
||||||
|
"""
|
||||||
|
permission_classes = [permissions.AllowAny]
|
||||||
|
|
||||||
|
def get(self, request, username):
|
||||||
|
User = get_user_model()
|
||||||
|
user = get_object_or_404(User, username=username)
|
||||||
|
|
||||||
|
# Берём все группы пользователя вместе с их ссылками
|
||||||
|
groups_qs = LinkGroup.objects.filter(owner=user).prefetch_related('links')
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"username": user.username,
|
||||||
|
"groups": []
|
||||||
|
}
|
||||||
|
|
||||||
|
for grp in groups_qs:
|
||||||
|
# поле image у группы
|
||||||
|
grp_image_url = grp.image.url if getattr(grp, 'image', None) else None
|
||||||
|
|
||||||
|
grp_data = {
|
||||||
|
"id": grp.id,
|
||||||
|
"name": grp.name,
|
||||||
|
"image": grp_image_url,
|
||||||
|
"links": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
for ln in grp.links.all():
|
||||||
|
# поле image у отдельной ссылки
|
||||||
|
link_image_url = ln.image.url if getattr(ln, 'image', None) else None
|
||||||
|
|
||||||
|
grp_data["links"].append({
|
||||||
|
"id": ln.id,
|
||||||
|
"title": ln.title,
|
||||||
|
"url": ln.url,
|
||||||
|
"image": link_image_url,
|
||||||
|
})
|
||||||
|
|
||||||
|
result["groups"].append(grp_data)
|
||||||
|
|
||||||
|
return Response(result, status=status.HTTP_200_OK)
|
||||||
109
.history/backend/api/views_20250507073143.py
Normal file
109
.history/backend/api/views_20250507073143.py
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
# api/views.py
|
||||||
|
from rest_framework import generics, viewsets, permissions,status
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from rest_framework_simplejwt.views import TokenObtainPairView
|
||||||
|
from .serializers import RegisterSerializer, LinkSerializer, LinkGroupSerializer
|
||||||
|
from .models import Link, LinkGroup
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from django.shortcuts import get_object_or_404
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
class RegisterView(generics.CreateAPIView):
|
||||||
|
queryset = User.objects.all()
|
||||||
|
permission_classes = (permissions.AllowAny,)
|
||||||
|
serializer_class = RegisterSerializer
|
||||||
|
|
||||||
|
class LoginView(TokenObtainPairView):
|
||||||
|
permission_classes = (permissions.AllowAny,)
|
||||||
|
|
||||||
|
class LinkGroupViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = LinkGroup.objects.all()
|
||||||
|
serializer_class = LinkGroupSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
# возвращаем только группы текущего пользователя
|
||||||
|
return self.queryset.filter(owner=self.request.user).order_by('order')
|
||||||
|
|
||||||
|
class LinkViewSet(viewsets.ModelViewSet):
|
||||||
|
serializer_class = LinkSerializer
|
||||||
|
permission_classes = (permissions.IsAuthenticated,)
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return Link.objects.filter(owner=self.request.user).order_by('order')
|
||||||
|
|
||||||
|
def perform_create(self, serializer):
|
||||||
|
serializer.save(owner=self.request.user)
|
||||||
|
|
||||||
|
class UserLinksListView(generics.ListAPIView):
|
||||||
|
serializer_class = LinkSerializer
|
||||||
|
permission_classes = (permissions.AllowAny,)
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
username = self.kwargs['username']
|
||||||
|
return Link.objects.filter(owner__username=username).order_by('order')
|
||||||
|
|
||||||
|
|
||||||
|
from .serializers import UserSerializer # нужно завести сериализатор для пользователя
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
class UserProfileView(generics.RetrieveAPIView):
|
||||||
|
"""
|
||||||
|
Возвращает данные авторизованного пользователя.
|
||||||
|
GET /api/auth/user/
|
||||||
|
"""
|
||||||
|
serializer_class = UserSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
|
def get_object(self):
|
||||||
|
return self.request.user
|
||||||
|
|
||||||
|
|
||||||
|
class PublicUserGroupsView(APIView):
|
||||||
|
"""
|
||||||
|
GET /api/users/{username}/public/
|
||||||
|
"""
|
||||||
|
permission_classes = [permissions.AllowAny]
|
||||||
|
|
||||||
|
def get(self, request, username):
|
||||||
|
User = get_user_model()
|
||||||
|
user = get_object_or_404(User, username=username)
|
||||||
|
|
||||||
|
# Берём все группы пользователя вместе с их ссылками
|
||||||
|
groups_qs = LinkGroup.objects.filter(owner=user).prefetch_related('links')
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"username": user.username,
|
||||||
|
"groups": []
|
||||||
|
}
|
||||||
|
|
||||||
|
for grp in groups_qs:
|
||||||
|
# поле image у группы
|
||||||
|
grp_image_url = grp.image.url if getattr(grp, 'image', None) else None
|
||||||
|
|
||||||
|
grp_data = {
|
||||||
|
"id": grp.id,
|
||||||
|
"name": grp.name,
|
||||||
|
"image": grp_image_url,
|
||||||
|
"links": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
for ln in grp.links.all():
|
||||||
|
# поле image у отдельной ссылки
|
||||||
|
link_image_url = ln.image.url if getattr(ln, 'image', None) else None
|
||||||
|
|
||||||
|
grp_data["links"].append({
|
||||||
|
"id": ln.id,
|
||||||
|
"title": ln.title,
|
||||||
|
"url": ln.url,
|
||||||
|
"image": link_image_url,
|
||||||
|
})
|
||||||
|
|
||||||
|
result["groups"].append(grp_data)
|
||||||
|
|
||||||
|
return Response(result, status=status.HTTP_200_OK)
|
||||||
119
.history/backend/api/views_20250507141914.py
Normal file
119
.history/backend/api/views_20250507141914.py
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
# api/views.py
|
||||||
|
from rest_framework import generics, viewsets, permissions,status
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from rest_framework_simplejwt.views import TokenObtainPairView
|
||||||
|
from .serializers import RegisterSerializer, LinkSerializer, LinkGroupSerializer
|
||||||
|
from .models import Link, LinkGroup
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from django.shortcuts import get_object_or_404
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
parameters=[
|
||||||
|
OpenApiParameter(
|
||||||
|
name='id',
|
||||||
|
type=int,
|
||||||
|
location=OpenApiParameter.PATH
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
class RegisterView(generics.CreateAPIView):
|
||||||
|
queryset = User.objects.all()
|
||||||
|
permission_classes = (permissions.AllowAny,)
|
||||||
|
serializer_class = RegisterSerializer
|
||||||
|
|
||||||
|
class LoginView(TokenObtainPairView):
|
||||||
|
permission_classes = (permissions.AllowAny,)
|
||||||
|
|
||||||
|
class LinkGroupViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = LinkGroup.objects.all()
|
||||||
|
serializer_class = LinkGroupSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
# возвращаем только группы текущего пользователя
|
||||||
|
return self.queryset.filter(owner=self.request.user).order_by('order')
|
||||||
|
|
||||||
|
class LinkViewSet(viewsets.ModelViewSet):
|
||||||
|
serializer_class = LinkSerializer
|
||||||
|
permission_classes = (permissions.IsAuthenticated,)
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return Link.objects.filter(owner=self.request.user).order_by('order')
|
||||||
|
|
||||||
|
def perform_create(self, serializer):
|
||||||
|
serializer.save(owner=self.request.user)
|
||||||
|
|
||||||
|
class UserLinksListView(generics.ListAPIView):
|
||||||
|
serializer_class = LinkSerializer
|
||||||
|
permission_classes = (permissions.AllowAny,)
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
username = self.kwargs['username']
|
||||||
|
return Link.objects.filter(owner__username=username).order_by('order')
|
||||||
|
|
||||||
|
|
||||||
|
from .serializers import UserSerializer # нужно завести сериализатор для пользователя
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
class UserProfileView(generics.RetrieveAPIView):
|
||||||
|
"""
|
||||||
|
Возвращает данные авторизованного пользователя.
|
||||||
|
GET /api/auth/user/
|
||||||
|
"""
|
||||||
|
serializer_class = UserSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
|
def get_object(self):
|
||||||
|
return self.request.user
|
||||||
|
|
||||||
|
|
||||||
|
class PublicUserGroupsView(APIView):
|
||||||
|
"""
|
||||||
|
GET /api/users/{username}/public/
|
||||||
|
"""
|
||||||
|
permission_classes = [permissions.AllowAny]
|
||||||
|
|
||||||
|
def get(self, request, username):
|
||||||
|
User = get_user_model()
|
||||||
|
user = get_object_or_404(User, username=username)
|
||||||
|
|
||||||
|
# Берём все группы пользователя вместе с их ссылками
|
||||||
|
groups_qs = LinkGroup.objects.filter(owner=user).prefetch_related('links')
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"username": user.username,
|
||||||
|
"groups": []
|
||||||
|
}
|
||||||
|
|
||||||
|
for grp in groups_qs:
|
||||||
|
# поле image у группы
|
||||||
|
grp_image_url = grp.image.url if getattr(grp, 'image', None) else None
|
||||||
|
|
||||||
|
grp_data = {
|
||||||
|
"id": grp.id,
|
||||||
|
"name": grp.name,
|
||||||
|
"image": grp_image_url,
|
||||||
|
"links": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
for ln in grp.links.all():
|
||||||
|
# поле image у отдельной ссылки
|
||||||
|
link_image_url = ln.image.url if getattr(ln, 'image', None) else None
|
||||||
|
|
||||||
|
grp_data["links"].append({
|
||||||
|
"id": ln.id,
|
||||||
|
"title": ln.title,
|
||||||
|
"url": ln.url,
|
||||||
|
"image": link_image_url,
|
||||||
|
})
|
||||||
|
|
||||||
|
result["groups"].append(grp_data)
|
||||||
|
|
||||||
|
return Response(result, status=status.HTTP_200_OK)
|
||||||
119
.history/backend/api/views_20250507141924.py
Normal file
119
.history/backend/api/views_20250507141924.py
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
# api/views.py
|
||||||
|
from rest_framework import generics, viewsets, permissions,status
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from rest_framework_simplejwt.views import TokenObtainPairView
|
||||||
|
from .serializers import RegisterSerializer, LinkSerializer, LinkGroupSerializer
|
||||||
|
from .models import Link, LinkGroup
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from django.shortcuts import get_object_or_404
|
||||||
|
from drf_spectacular.utils import extend_schema, OpenApiParameter
|
||||||
|
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
parameters=[
|
||||||
|
OpenApiParameter(
|
||||||
|
name='id',
|
||||||
|
type=int,
|
||||||
|
location=OpenApiParameter.PATH
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
class RegisterView(generics.CreateAPIView):
|
||||||
|
queryset = User.objects.all()
|
||||||
|
permission_classes = (permissions.AllowAny,)
|
||||||
|
serializer_class = RegisterSerializer
|
||||||
|
|
||||||
|
class LoginView(TokenObtainPairView):
|
||||||
|
permission_classes = (permissions.AllowAny,)
|
||||||
|
|
||||||
|
class LinkGroupViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = LinkGroup.objects.all()
|
||||||
|
serializer_class = LinkGroupSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
# возвращаем только группы текущего пользователя
|
||||||
|
return self.queryset.filter(owner=self.request.user).order_by('order')
|
||||||
|
|
||||||
|
class LinkViewSet(viewsets.ModelViewSet):
|
||||||
|
serializer_class = LinkSerializer
|
||||||
|
permission_classes = (permissions.IsAuthenticated,)
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return Link.objects.filter(owner=self.request.user).order_by('order')
|
||||||
|
|
||||||
|
def perform_create(self, serializer):
|
||||||
|
serializer.save(owner=self.request.user)
|
||||||
|
|
||||||
|
class UserLinksListView(generics.ListAPIView):
|
||||||
|
serializer_class = LinkSerializer
|
||||||
|
permission_classes = (permissions.AllowAny,)
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
username = self.kwargs['username']
|
||||||
|
return Link.objects.filter(owner__username=username).order_by('order')
|
||||||
|
|
||||||
|
|
||||||
|
from .serializers import UserSerializer # нужно завести сериализатор для пользователя
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
class UserProfileView(generics.RetrieveAPIView):
|
||||||
|
"""
|
||||||
|
Возвращает данные авторизованного пользователя.
|
||||||
|
GET /api/auth/user/
|
||||||
|
"""
|
||||||
|
serializer_class = UserSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
|
def get_object(self):
|
||||||
|
return self.request.user
|
||||||
|
|
||||||
|
|
||||||
|
class PublicUserGroupsView(APIView):
|
||||||
|
"""
|
||||||
|
GET /api/users/{username}/public/
|
||||||
|
"""
|
||||||
|
permission_classes = [permissions.AllowAny]
|
||||||
|
|
||||||
|
def get(self, request, username):
|
||||||
|
User = get_user_model()
|
||||||
|
user = get_object_or_404(User, username=username)
|
||||||
|
|
||||||
|
# Берём все группы пользователя вместе с их ссылками
|
||||||
|
groups_qs = LinkGroup.objects.filter(owner=user).prefetch_related('links')
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"username": user.username,
|
||||||
|
"groups": []
|
||||||
|
}
|
||||||
|
|
||||||
|
for grp in groups_qs:
|
||||||
|
# поле image у группы
|
||||||
|
grp_image_url = grp.image.url if getattr(grp, 'image', None) else None
|
||||||
|
|
||||||
|
grp_data = {
|
||||||
|
"id": grp.id,
|
||||||
|
"name": grp.name,
|
||||||
|
"image": grp_image_url,
|
||||||
|
"links": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
for ln in grp.links.all():
|
||||||
|
# поле image у отдельной ссылки
|
||||||
|
link_image_url = ln.image.url if getattr(ln, 'image', None) else None
|
||||||
|
|
||||||
|
grp_data["links"].append({
|
||||||
|
"id": ln.id,
|
||||||
|
"title": ln.title,
|
||||||
|
"url": ln.url,
|
||||||
|
"image": link_image_url,
|
||||||
|
})
|
||||||
|
|
||||||
|
result["groups"].append(grp_data)
|
||||||
|
|
||||||
|
return Response(result, status=status.HTTP_200_OK)
|
||||||
95
.history/backend/api/views_20250507142311.py
Normal file
95
.history/backend/api/views_20250507142311.py
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
# api/views.py
|
||||||
|
from rest_framework import generics, viewsets, permissions,status
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from rest_framework_simplejwt.views import TokenObtainPairView
|
||||||
|
from .serializers import RegisterSerializer, LinkSerializer, LinkGroupSerializer
|
||||||
|
from .models import Link, LinkGroup
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from django.shortcuts import get_object_or_404
|
||||||
|
from drf_spectacular.utils import extend_schema, OpenApiParameter
|
||||||
|
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
parameters=[
|
||||||
|
OpenApiParameter(
|
||||||
|
name='id',
|
||||||
|
type=int,
|
||||||
|
location=OpenApiParameter.PATH
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
class RegisterView(generics.CreateAPIView):
|
||||||
|
queryset = User.objects.all()
|
||||||
|
permission_classes = (permissions.AllowAny,)
|
||||||
|
serializer_class = RegisterSerializer
|
||||||
|
|
||||||
|
class LoginView(TokenObtainPairView):
|
||||||
|
permission_classes = (permissions.AllowAny,)
|
||||||
|
|
||||||
|
class LinkGroupViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = LinkGroup.objects.all()
|
||||||
|
serializer_class = LinkGroupSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
# возвращаем только группы текущего пользователя
|
||||||
|
return self.queryset.filter(owner=self.request.user).order_by('order')
|
||||||
|
|
||||||
|
class LinkViewSet(viewsets.ModelViewSet):
|
||||||
|
serializer_class = LinkSerializer
|
||||||
|
permission_classes = (permissions.IsAuthenticated,)
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return Link.objects.filter(owner=self.request.user).order_by('order')
|
||||||
|
|
||||||
|
def perform_create(self, serializer):
|
||||||
|
serializer.save(owner=self.request.user)
|
||||||
|
|
||||||
|
class UserLinksListView(generics.ListAPIView):
|
||||||
|
serializer_class = LinkSerializer
|
||||||
|
permission_classes = (permissions.AllowAny,)
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
username = self.kwargs['username']
|
||||||
|
return Link.objects.filter(owner__username=username).order_by('order')
|
||||||
|
|
||||||
|
|
||||||
|
from .serializers import UserSerializer # нужно завести сериализатор для пользователя
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
class UserProfileView(generics.RetrieveAPIView):
|
||||||
|
"""
|
||||||
|
Возвращает данные авторизованного пользователя.
|
||||||
|
GET /api/auth/user/
|
||||||
|
"""
|
||||||
|
serializer_class = UserSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
|
def get_object(self):
|
||||||
|
return self.request.user
|
||||||
|
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
responses=PublicUserGroupsSerializer
|
||||||
|
)
|
||||||
|
class PublicUserGroupsView(generics.GenericAPIView):
|
||||||
|
"""
|
||||||
|
Возвращает публичные группы и их ссылки для заданного username.
|
||||||
|
GET /api/users/{username}/public/
|
||||||
|
"""
|
||||||
|
serializer_class = PublicUserGroupsSerializer
|
||||||
|
permission_classes = [permissions.AllowAny]
|
||||||
|
|
||||||
|
def get(self, request, username):
|
||||||
|
user = get_object_or_404(User, username=username)
|
||||||
|
groups = LinkGroup.objects.filter(owner=user, is_public=True).prefetch_related('links')
|
||||||
|
# Формируем ответ в виде словаря — GenericAPIView сам вызовет нужный сериализатор:
|
||||||
|
return Response({
|
||||||
|
'username': user.username,
|
||||||
|
'groups': groups
|
||||||
|
})
|
||||||
98
.history/backend/api/views_20250507142317.py
Normal file
98
.history/backend/api/views_20250507142317.py
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
# api/views.py
|
||||||
|
from rest_framework import generics, viewsets, permissions,status
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from rest_framework_simplejwt.views import TokenObtainPairView
|
||||||
|
from .serializers import RegisterSerializer, LinkSerializer, LinkGroupSerializer
|
||||||
|
from .models import Link, LinkGroup
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from django.shortcuts import get_object_or_404
|
||||||
|
from drf_spectacular.utils import extend_schema, OpenApiParameter
|
||||||
|
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
parameters=[
|
||||||
|
OpenApiParameter(
|
||||||
|
name='id',
|
||||||
|
type=int,
|
||||||
|
location=OpenApiParameter.PATH
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
class RegisterView(generics.CreateAPIView):
|
||||||
|
queryset = User.objects.all()
|
||||||
|
permission_classes = (permissions.AllowAny,)
|
||||||
|
serializer_class = RegisterSerializer
|
||||||
|
|
||||||
|
class LoginView(TokenObtainPairView):
|
||||||
|
permission_classes = (permissions.AllowAny,)
|
||||||
|
|
||||||
|
class LinkGroupViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = LinkGroup.objects.all()
|
||||||
|
serializer_class = LinkGroupSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
# возвращаем только группы текущего пользователя
|
||||||
|
return self.queryset.filter(owner=self.request.user).order_by('order')
|
||||||
|
|
||||||
|
class LinkViewSet(viewsets.ModelViewSet):
|
||||||
|
serializer_class = LinkSerializer
|
||||||
|
permission_classes = (permissions.IsAuthenticated,)
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return Link.objects.filter(owner=self.request.user).order_by('order')
|
||||||
|
|
||||||
|
def perform_create(self, serializer):
|
||||||
|
serializer.save(owner=self.request.user)
|
||||||
|
|
||||||
|
class UserLinksListView(generics.ListAPIView):
|
||||||
|
serializer_class = LinkSerializer
|
||||||
|
permission_classes = (permissions.AllowAny,)
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
username = self.kwargs['username']
|
||||||
|
return Link.objects.filter(owner__username=username).order_by('order')
|
||||||
|
|
||||||
|
|
||||||
|
from .serializers import UserSerializer # нужно завести сериализатор для пользователя
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
class UserProfileView(generics.RetrieveAPIView):
|
||||||
|
"""
|
||||||
|
Возвращает данные авторизованного пользователя.
|
||||||
|
GET /api/auth/user/
|
||||||
|
"""
|
||||||
|
serializer_class = UserSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
|
def get_object(self):
|
||||||
|
return self.request.user
|
||||||
|
|
||||||
|
from users.models import User
|
||||||
|
from links.models import LinkGroup
|
||||||
|
from .serializers import PublicUserGroupsSerializer
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
responses=PublicUserGroupsSerializer
|
||||||
|
)
|
||||||
|
class PublicUserGroupsView(generics.GenericAPIView):
|
||||||
|
"""
|
||||||
|
Возвращает публичные группы и их ссылки для заданного username.
|
||||||
|
GET /api/users/{username}/public/
|
||||||
|
"""
|
||||||
|
serializer_class = PublicUserGroupsSerializer
|
||||||
|
permission_classes = [permissions.AllowAny]
|
||||||
|
|
||||||
|
def get(self, request, username):
|
||||||
|
user = get_object_or_404(User, username=username)
|
||||||
|
groups = LinkGroup.objects.filter(owner=user, is_public=True).prefetch_related('links')
|
||||||
|
# Формируем ответ в виде словаря — GenericAPIView сам вызовет нужный сериализатор:
|
||||||
|
return Response({
|
||||||
|
'username': user.username,
|
||||||
|
'groups': groups
|
||||||
|
})
|
||||||
119
.history/backend/api/views_20250507142335.py
Normal file
119
.history/backend/api/views_20250507142335.py
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
# api/views.py
|
||||||
|
from rest_framework import generics, viewsets, permissions,status
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from rest_framework_simplejwt.views import TokenObtainPairView
|
||||||
|
from .serializers import RegisterSerializer, LinkSerializer, LinkGroupSerializer
|
||||||
|
from .models import Link, LinkGroup
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from django.shortcuts import get_object_or_404
|
||||||
|
from drf_spectacular.utils import extend_schema, OpenApiParameter
|
||||||
|
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
parameters=[
|
||||||
|
OpenApiParameter(
|
||||||
|
name='id',
|
||||||
|
type=int,
|
||||||
|
location=OpenApiParameter.PATH
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
class RegisterView(generics.CreateAPIView):
|
||||||
|
queryset = User.objects.all()
|
||||||
|
permission_classes = (permissions.AllowAny,)
|
||||||
|
serializer_class = RegisterSerializer
|
||||||
|
|
||||||
|
class LoginView(TokenObtainPairView):
|
||||||
|
permission_classes = (permissions.AllowAny,)
|
||||||
|
|
||||||
|
class LinkGroupViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = LinkGroup.objects.all()
|
||||||
|
serializer_class = LinkGroupSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
# возвращаем только группы текущего пользователя
|
||||||
|
return self.queryset.filter(owner=self.request.user).order_by('order')
|
||||||
|
|
||||||
|
class LinkViewSet(viewsets.ModelViewSet):
|
||||||
|
serializer_class = LinkSerializer
|
||||||
|
permission_classes = (permissions.IsAuthenticated,)
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return Link.objects.filter(owner=self.request.user).order_by('order')
|
||||||
|
|
||||||
|
def perform_create(self, serializer):
|
||||||
|
serializer.save(owner=self.request.user)
|
||||||
|
|
||||||
|
class UserLinksListView(generics.ListAPIView):
|
||||||
|
serializer_class = LinkSerializer
|
||||||
|
permission_classes = (permissions.AllowAny,)
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
username = self.kwargs['username']
|
||||||
|
return Link.objects.filter(owner__username=username).order_by('order')
|
||||||
|
|
||||||
|
|
||||||
|
from .serializers import UserSerializer # нужно завести сериализатор для пользователя
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
class UserProfileView(generics.RetrieveAPIView):
|
||||||
|
"""
|
||||||
|
Возвращает данные авторизованного пользователя.
|
||||||
|
GET /api/auth/user/
|
||||||
|
"""
|
||||||
|
serializer_class = UserSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
|
def get_object(self):
|
||||||
|
return self.request.user
|
||||||
|
|
||||||
|
|
||||||
|
class PublicUserGroupsView(APIView):
|
||||||
|
"""
|
||||||
|
GET /api/users/{username}/public/
|
||||||
|
"""
|
||||||
|
permission_classes = [permissions.AllowAny]
|
||||||
|
|
||||||
|
def get(self, request, username):
|
||||||
|
User = get_user_model()
|
||||||
|
user = get_object_or_404(User, username=username)
|
||||||
|
|
||||||
|
# Берём все группы пользователя вместе с их ссылками
|
||||||
|
groups_qs = LinkGroup.objects.filter(owner=user).prefetch_related('links')
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"username": user.username,
|
||||||
|
"groups": []
|
||||||
|
}
|
||||||
|
|
||||||
|
for grp in groups_qs:
|
||||||
|
# поле image у группы
|
||||||
|
grp_image_url = grp.image.url if getattr(grp, 'image', None) else None
|
||||||
|
|
||||||
|
grp_data = {
|
||||||
|
"id": grp.id,
|
||||||
|
"name": grp.name,
|
||||||
|
"image": grp_image_url,
|
||||||
|
"links": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
for ln in grp.links.all():
|
||||||
|
# поле image у отдельной ссылки
|
||||||
|
link_image_url = ln.image.url if getattr(ln, 'image', None) else None
|
||||||
|
|
||||||
|
grp_data["links"].append({
|
||||||
|
"id": ln.id,
|
||||||
|
"title": ln.title,
|
||||||
|
"url": ln.url,
|
||||||
|
"image": link_image_url,
|
||||||
|
})
|
||||||
|
|
||||||
|
result["groups"].append(grp_data)
|
||||||
|
|
||||||
|
return Response(result, status=status.HTTP_200_OK)
|
||||||
151
.history/backend/api/views_20250507145006.py
Normal file
151
.history/backend/api/views_20250507145006.py
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
# backend/api/views.py
|
||||||
|
|
||||||
|
from rest_framework import generics, viewsets, permissions, status
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from rest_framework_simplejwt.views import TokenObtainPairView
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from django.shortcuts import get_object_or_404
|
||||||
|
from drf_spectacular.utils import extend_schema, OpenApiParameter
|
||||||
|
|
||||||
|
from .models import Link, LinkGroup
|
||||||
|
from .serializers import (
|
||||||
|
RegisterSerializer,
|
||||||
|
UserSerializer,
|
||||||
|
LinkSerializer,
|
||||||
|
LinkGroupSerializer,
|
||||||
|
)
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
parameters=[
|
||||||
|
OpenApiParameter(
|
||||||
|
name='id',
|
||||||
|
type=int,
|
||||||
|
location=OpenApiParameter.PATH
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
class RegisterView(generics.CreateAPIView):
|
||||||
|
"""
|
||||||
|
POST /api/auth/register/
|
||||||
|
Регистрирует нового пользователя.
|
||||||
|
"""
|
||||||
|
queryset = User.objects.all()
|
||||||
|
permission_classes = (permissions.AllowAny,)
|
||||||
|
serializer_class = RegisterSerializer
|
||||||
|
|
||||||
|
|
||||||
|
class LoginView(TokenObtainPairView):
|
||||||
|
"""
|
||||||
|
POST /api/auth/login/
|
||||||
|
Возвращает JWT-токены.
|
||||||
|
"""
|
||||||
|
permission_classes = (permissions.AllowAny,)
|
||||||
|
|
||||||
|
|
||||||
|
class LinkGroupViewSet(viewsets.ModelViewSet):
|
||||||
|
"""
|
||||||
|
/api/groups/
|
||||||
|
CRUD для групп ссылок текущего пользователя.
|
||||||
|
"""
|
||||||
|
queryset = LinkGroup.objects.all()
|
||||||
|
serializer_class = LinkGroupSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
# Возвращаем только свои группы, упорядоченные по полю order
|
||||||
|
return self.queryset.filter(owner=self.request.user).order_by('order')
|
||||||
|
|
||||||
|
def perform_create(self, serializer):
|
||||||
|
# При создании модели автоматически ставим owner = текущий пользователь
|
||||||
|
serializer.save(owner=self.request.user)
|
||||||
|
|
||||||
|
|
||||||
|
class LinkViewSet(viewsets.ModelViewSet):
|
||||||
|
"""
|
||||||
|
/api/links/
|
||||||
|
CRUD для ссылок текущего пользователя.
|
||||||
|
"""
|
||||||
|
serializer_class = LinkSerializer
|
||||||
|
permission_classes = (permissions.IsAuthenticated,)
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
# Возвращаем только свои ссылки, упорядоченные по полю order
|
||||||
|
return Link.objects.filter(owner=self.request.user).order_by('order')
|
||||||
|
|
||||||
|
def perform_create(self, serializer):
|
||||||
|
# При создании модели автоматически ставим owner = текущий пользователь
|
||||||
|
serializer.save(owner=self.request.user)
|
||||||
|
|
||||||
|
|
||||||
|
class UserLinksListView(generics.ListAPIView):
|
||||||
|
"""
|
||||||
|
GET /api/users/{username}/links/
|
||||||
|
Список публичных ссылок пользователя (без группировки).
|
||||||
|
"""
|
||||||
|
serializer_class = LinkSerializer
|
||||||
|
permission_classes = (permissions.AllowAny,)
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
username = self.kwargs['username']
|
||||||
|
return Link.objects.filter(owner__username=username).order_by('order')
|
||||||
|
|
||||||
|
|
||||||
|
class UserProfileView(generics.RetrieveAPIView):
|
||||||
|
"""
|
||||||
|
GET /api/auth/user/
|
||||||
|
Возвращает данные авторизованного пользователя.
|
||||||
|
"""
|
||||||
|
serializer_class = UserSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
|
def get_object(self):
|
||||||
|
return self.request.user
|
||||||
|
|
||||||
|
|
||||||
|
class PublicUserGroupsView(APIView):
|
||||||
|
"""
|
||||||
|
GET /api/users/{username}/public/
|
||||||
|
Возвращает публичные группы и ссылки пользователя.
|
||||||
|
"""
|
||||||
|
permission_classes = [permissions.AllowAny]
|
||||||
|
|
||||||
|
def get(self, request, username):
|
||||||
|
user = get_object_or_404(User, username=username)
|
||||||
|
|
||||||
|
# Берем все группы пользователя вместе с их ссылками
|
||||||
|
groups_qs = LinkGroup.objects.filter(owner=user).prefetch_related('links')
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"username": user.username,
|
||||||
|
"groups": []
|
||||||
|
}
|
||||||
|
|
||||||
|
for grp in groups_qs:
|
||||||
|
# URL иконки группы (поле icon в модели)
|
||||||
|
grp_icon_url = grp.icon.url if grp.icon else None
|
||||||
|
|
||||||
|
grp_data = {
|
||||||
|
"id": grp.id,
|
||||||
|
"name": grp.name,
|
||||||
|
"icon": grp_icon_url,
|
||||||
|
"links": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
for ln in grp.links.all():
|
||||||
|
# URL иконки ссылки (поле icon в модели Link — URLField)
|
||||||
|
link_icon_url = ln.icon if ln.icon else None
|
||||||
|
|
||||||
|
grp_data["links"].append({
|
||||||
|
"id": ln.id,
|
||||||
|
"title": ln.title,
|
||||||
|
"url": ln.url,
|
||||||
|
"icon": link_icon_url,
|
||||||
|
})
|
||||||
|
|
||||||
|
result["groups"].append(grp_data)
|
||||||
|
|
||||||
|
return Response(result, status=status.HTTP_200_OK)
|
||||||
248
.history/backend/api/views_20250507145615.py
Normal file
248
.history/backend/api/views_20250507145615.py
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
# # backend/api/views.py
|
||||||
|
|
||||||
|
# from rest_framework import generics, viewsets, permissions, status
|
||||||
|
# from django.contrib.auth import get_user_model
|
||||||
|
# from rest_framework_simplejwt.views import TokenObtainPairView
|
||||||
|
# from rest_framework.views import APIView
|
||||||
|
# from rest_framework.response import Response
|
||||||
|
# from django.shortcuts import get_object_or_404
|
||||||
|
# from drf_spectacular.utils import extend_schema, OpenApiParameter
|
||||||
|
|
||||||
|
# from .models import Link, LinkGroup
|
||||||
|
# from .serializers import (
|
||||||
|
# RegisterSerializer,
|
||||||
|
# UserSerializer,
|
||||||
|
# LinkSerializer,
|
||||||
|
# LinkGroupSerializer,
|
||||||
|
# )
|
||||||
|
|
||||||
|
# User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
# @extend_schema(
|
||||||
|
# parameters=[
|
||||||
|
# OpenApiParameter(
|
||||||
|
# name='id',
|
||||||
|
# type=int,
|
||||||
|
# location=OpenApiParameter.PATH
|
||||||
|
# )
|
||||||
|
# ]
|
||||||
|
# )
|
||||||
|
# class RegisterView(generics.CreateAPIView):
|
||||||
|
# """
|
||||||
|
# POST /api/auth/register/
|
||||||
|
# Регистрирует нового пользователя.
|
||||||
|
# """
|
||||||
|
# queryset = User.objects.all()
|
||||||
|
# permission_classes = (permissions.AllowAny,)
|
||||||
|
# serializer_class = RegisterSerializer
|
||||||
|
|
||||||
|
|
||||||
|
# class LoginView(TokenObtainPairView):
|
||||||
|
# """
|
||||||
|
# POST /api/auth/login/
|
||||||
|
# Возвращает JWT-токены.
|
||||||
|
# """
|
||||||
|
# permission_classes = (permissions.AllowAny,)
|
||||||
|
|
||||||
|
|
||||||
|
# class LinkGroupViewSet(viewsets.ModelViewSet):
|
||||||
|
# """
|
||||||
|
# /api/groups/
|
||||||
|
# CRUD для групп ссылок текущего пользователя.
|
||||||
|
# """
|
||||||
|
# queryset = LinkGroup.objects.all()
|
||||||
|
# serializer_class = LinkGroupSerializer
|
||||||
|
# permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
|
# def get_queryset(self):
|
||||||
|
# # Возвращаем только свои группы, упорядоченные по полю order
|
||||||
|
# return self.queryset.filter(owner=self.request.user).order_by('order')
|
||||||
|
|
||||||
|
# def perform_create(self, serializer):
|
||||||
|
# # При создании модели автоматически ставим owner = текущий пользователь
|
||||||
|
# serializer.save(owner=self.request.user)
|
||||||
|
|
||||||
|
|
||||||
|
# class LinkViewSet(viewsets.ModelViewSet):
|
||||||
|
# """
|
||||||
|
# /api/links/
|
||||||
|
# CRUD для ссылок текущего пользователя.
|
||||||
|
# """
|
||||||
|
# serializer_class = LinkSerializer
|
||||||
|
# permission_classes = (permissions.IsAuthenticated,)
|
||||||
|
|
||||||
|
# def get_queryset(self):
|
||||||
|
# # Возвращаем только свои ссылки, упорядоченные по полю order
|
||||||
|
# return Link.objects.filter(owner=self.request.user).order_by('order')
|
||||||
|
|
||||||
|
# def perform_create(self, serializer):
|
||||||
|
# # При создании модели автоматически ставим owner = текущий пользователь
|
||||||
|
# serializer.save(owner=self.request.user)
|
||||||
|
|
||||||
|
|
||||||
|
# class UserLinksListView(generics.ListAPIView):
|
||||||
|
# """
|
||||||
|
# GET /api/users/{username}/links/
|
||||||
|
# Список публичных ссылок пользователя (без группировки).
|
||||||
|
# """
|
||||||
|
# serializer_class = LinkSerializer
|
||||||
|
# permission_classes = (permissions.AllowAny,)
|
||||||
|
|
||||||
|
# def get_queryset(self):
|
||||||
|
# username = self.kwargs['username']
|
||||||
|
# return Link.objects.filter(owner__username=username).order_by('order')
|
||||||
|
|
||||||
|
|
||||||
|
# class UserProfileView(generics.RetrieveAPIView):
|
||||||
|
# """
|
||||||
|
# GET /api/auth/user/
|
||||||
|
# Возвращает данные авторизованного пользователя.
|
||||||
|
# """
|
||||||
|
# serializer_class = UserSerializer
|
||||||
|
# permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
|
# def get_object(self):
|
||||||
|
# return self.request.user
|
||||||
|
|
||||||
|
|
||||||
|
# class PublicUserGroupsView(APIView):
|
||||||
|
# """
|
||||||
|
# GET /api/users/{username}/public/
|
||||||
|
# Возвращает публичные группы и ссылки пользователя.
|
||||||
|
# """
|
||||||
|
# permission_classes = [permissions.AllowAny]
|
||||||
|
|
||||||
|
# def get(self, request, username):
|
||||||
|
# user = get_object_or_404(User, username=username)
|
||||||
|
|
||||||
|
# # Берем все группы пользователя вместе с их ссылками
|
||||||
|
# groups_qs = LinkGroup.objects.filter(owner=user).prefetch_related('links')
|
||||||
|
|
||||||
|
# result = {
|
||||||
|
# "username": user.username,
|
||||||
|
# "groups": []
|
||||||
|
# }
|
||||||
|
|
||||||
|
# for grp in groups_qs:
|
||||||
|
# # URL иконки группы (поле icon в модели)
|
||||||
|
# grp_icon_url = grp.icon.url if grp.icon else None
|
||||||
|
|
||||||
|
# grp_data = {
|
||||||
|
# "id": grp.id,
|
||||||
|
# "name": grp.name,
|
||||||
|
# "icon": grp_icon_url,
|
||||||
|
# "links": [],
|
||||||
|
# }
|
||||||
|
|
||||||
|
# for ln in grp.links.all():
|
||||||
|
# # URL иконки ссылки (поле icon в модели Link — URLField)
|
||||||
|
# link_icon_url = ln.icon if ln.icon else None
|
||||||
|
|
||||||
|
# grp_data["links"].append({
|
||||||
|
# "id": ln.id,
|
||||||
|
# "title": ln.title,
|
||||||
|
# "url": ln.url,
|
||||||
|
# "icon": link_icon_url,
|
||||||
|
# })
|
||||||
|
|
||||||
|
# result["groups"].append(grp_data)
|
||||||
|
|
||||||
|
# return Response(result, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
# backend/api/views.py
|
||||||
|
|
||||||
|
from rest_framework import generics, viewsets, permissions, status
|
||||||
|
from rest_framework.parsers import MultiPartParser, FormParser, JSONParser
|
||||||
|
from rest_framework_simplejwt.views import TokenObtainPairView
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from django.shortcuts import get_object_or_404
|
||||||
|
from drf_spectacular.utils import extend_schema, OpenApiParameter
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
|
||||||
|
from .models import Link, LinkGroup
|
||||||
|
from .serializers import (
|
||||||
|
RegisterSerializer,
|
||||||
|
UserSerializer,
|
||||||
|
LinkSerializer,
|
||||||
|
LinkGroupSerializer,
|
||||||
|
)
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
class RegisterView(generics.CreateAPIView):
|
||||||
|
queryset = User.objects.all()
|
||||||
|
permission_classes = [permissions.AllowAny]
|
||||||
|
serializer_class = RegisterSerializer
|
||||||
|
|
||||||
|
|
||||||
|
class LoginView(TokenObtainPairView):
|
||||||
|
permission_classes = [permissions.AllowAny]
|
||||||
|
|
||||||
|
|
||||||
|
class LinkGroupViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = LinkGroup.objects.all()
|
||||||
|
serializer_class = LinkGroupSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
parser_classes = [MultiPartParser, FormParser, JSONParser]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return self.queryset.filter(owner=self.request.user).order_by('order')
|
||||||
|
|
||||||
|
def perform_create(self, serializer):
|
||||||
|
serializer.save(owner=self.request.user)
|
||||||
|
|
||||||
|
|
||||||
|
class LinkViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = Link.objects.all()
|
||||||
|
serializer_class = LinkSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
parser_classes = [MultiPartParser, FormParser, JSONParser]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return Link.objects.filter(owner=self.request.user).order_by('order')
|
||||||
|
|
||||||
|
def perform_create(self, serializer):
|
||||||
|
serializer.save(owner=self.request.user)
|
||||||
|
|
||||||
|
|
||||||
|
class UserProfileView(generics.RetrieveAPIView):
|
||||||
|
serializer_class = UserSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
def get_object(self):
|
||||||
|
return self.request.user
|
||||||
|
|
||||||
|
|
||||||
|
class UserLinksListView(generics.ListAPIView):
|
||||||
|
serializer_class = LinkSerializer
|
||||||
|
permission_classes = [permissions.AllowAny]
|
||||||
|
def get_queryset(self):
|
||||||
|
username = self.kwargs['username']
|
||||||
|
return Link.objects.filter(owner__username=username).order_by('order')
|
||||||
|
|
||||||
|
|
||||||
|
class PublicUserGroupsView(APIView):
|
||||||
|
permission_classes = [permissions.AllowAny]
|
||||||
|
|
||||||
|
def get(self, request, username):
|
||||||
|
user = get_object_or_404(User, username=username)
|
||||||
|
groups_qs = LinkGroup.objects.filter(owner=user).prefetch_related('links')
|
||||||
|
result = {"username": user.username, "groups": []}
|
||||||
|
|
||||||
|
for grp in groups_qs:
|
||||||
|
grp_icon = grp.icon.url if grp.icon else None
|
||||||
|
grp_data = {"id": grp.id, "name": grp.name, "icon": grp_icon, "links": []}
|
||||||
|
|
||||||
|
for ln in grp.links.all():
|
||||||
|
link_icon = ln.icon.url if hasattr(ln.icon, 'url') else ln.icon
|
||||||
|
grp_data["links"].append({
|
||||||
|
"id": ln.id,
|
||||||
|
"title": ln.title,
|
||||||
|
"url": ln.url,
|
||||||
|
"icon": link_icon,
|
||||||
|
})
|
||||||
|
result["groups"].append(grp_data)
|
||||||
|
|
||||||
|
return Response(result, status=status.HTTP_200_OK)
|
||||||
272
.history/backend/api/views_20250507150101.py
Normal file
272
.history/backend/api/views_20250507150101.py
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
# # backend/api/views.py
|
||||||
|
|
||||||
|
# from rest_framework import generics, viewsets, permissions, status
|
||||||
|
# from django.contrib.auth import get_user_model
|
||||||
|
# from rest_framework_simplejwt.views import TokenObtainPairView
|
||||||
|
# from rest_framework.views import APIView
|
||||||
|
# from rest_framework.response import Response
|
||||||
|
# from django.shortcuts import get_object_or_404
|
||||||
|
# from drf_spectacular.utils import extend_schema, OpenApiParameter
|
||||||
|
|
||||||
|
# from .models import Link, LinkGroup
|
||||||
|
# from .serializers import (
|
||||||
|
# RegisterSerializer,
|
||||||
|
# UserSerializer,
|
||||||
|
# LinkSerializer,
|
||||||
|
# LinkGroupSerializer,
|
||||||
|
# )
|
||||||
|
|
||||||
|
# User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
# @extend_schema(
|
||||||
|
# parameters=[
|
||||||
|
# OpenApiParameter(
|
||||||
|
# name='id',
|
||||||
|
# type=int,
|
||||||
|
# location=OpenApiParameter.PATH
|
||||||
|
# )
|
||||||
|
# ]
|
||||||
|
# )
|
||||||
|
# class RegisterView(generics.CreateAPIView):
|
||||||
|
# """
|
||||||
|
# POST /api/auth/register/
|
||||||
|
# Регистрирует нового пользователя.
|
||||||
|
# """
|
||||||
|
# queryset = User.objects.all()
|
||||||
|
# permission_classes = (permissions.AllowAny,)
|
||||||
|
# serializer_class = RegisterSerializer
|
||||||
|
|
||||||
|
|
||||||
|
# class LoginView(TokenObtainPairView):
|
||||||
|
# """
|
||||||
|
# POST /api/auth/login/
|
||||||
|
# Возвращает JWT-токены.
|
||||||
|
# """
|
||||||
|
# permission_classes = (permissions.AllowAny,)
|
||||||
|
|
||||||
|
|
||||||
|
# class LinkGroupViewSet(viewsets.ModelViewSet):
|
||||||
|
# """
|
||||||
|
# /api/groups/
|
||||||
|
# CRUD для групп ссылок текущего пользователя.
|
||||||
|
# """
|
||||||
|
# queryset = LinkGroup.objects.all()
|
||||||
|
# serializer_class = LinkGroupSerializer
|
||||||
|
# permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
|
# def get_queryset(self):
|
||||||
|
# # Возвращаем только свои группы, упорядоченные по полю order
|
||||||
|
# return self.queryset.filter(owner=self.request.user).order_by('order')
|
||||||
|
|
||||||
|
# def perform_create(self, serializer):
|
||||||
|
# # При создании модели автоматически ставим owner = текущий пользователь
|
||||||
|
# serializer.save(owner=self.request.user)
|
||||||
|
|
||||||
|
|
||||||
|
# class LinkViewSet(viewsets.ModelViewSet):
|
||||||
|
# """
|
||||||
|
# /api/links/
|
||||||
|
# CRUD для ссылок текущего пользователя.
|
||||||
|
# """
|
||||||
|
# serializer_class = LinkSerializer
|
||||||
|
# permission_classes = (permissions.IsAuthenticated,)
|
||||||
|
|
||||||
|
# def get_queryset(self):
|
||||||
|
# # Возвращаем только свои ссылки, упорядоченные по полю order
|
||||||
|
# return Link.objects.filter(owner=self.request.user).order_by('order')
|
||||||
|
|
||||||
|
# def perform_create(self, serializer):
|
||||||
|
# # При создании модели автоматически ставим owner = текущий пользователь
|
||||||
|
# serializer.save(owner=self.request.user)
|
||||||
|
|
||||||
|
|
||||||
|
# class UserLinksListView(generics.ListAPIView):
|
||||||
|
# """
|
||||||
|
# GET /api/users/{username}/links/
|
||||||
|
# Список публичных ссылок пользователя (без группировки).
|
||||||
|
# """
|
||||||
|
# serializer_class = LinkSerializer
|
||||||
|
# permission_classes = (permissions.AllowAny,)
|
||||||
|
|
||||||
|
# def get_queryset(self):
|
||||||
|
# username = self.kwargs['username']
|
||||||
|
# return Link.objects.filter(owner__username=username).order_by('order')
|
||||||
|
|
||||||
|
|
||||||
|
# class UserProfileView(generics.RetrieveAPIView):
|
||||||
|
# """
|
||||||
|
# GET /api/auth/user/
|
||||||
|
# Возвращает данные авторизованного пользователя.
|
||||||
|
# """
|
||||||
|
# serializer_class = UserSerializer
|
||||||
|
# permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
|
# def get_object(self):
|
||||||
|
# return self.request.user
|
||||||
|
|
||||||
|
|
||||||
|
# class PublicUserGroupsView(APIView):
|
||||||
|
# """
|
||||||
|
# GET /api/users/{username}/public/
|
||||||
|
# Возвращает публичные группы и ссылки пользователя.
|
||||||
|
# """
|
||||||
|
# permission_classes = [permissions.AllowAny]
|
||||||
|
|
||||||
|
# def get(self, request, username):
|
||||||
|
# user = get_object_or_404(User, username=username)
|
||||||
|
|
||||||
|
# # Берем все группы пользователя вместе с их ссылками
|
||||||
|
# groups_qs = LinkGroup.objects.filter(owner=user).prefetch_related('links')
|
||||||
|
|
||||||
|
# result = {
|
||||||
|
# "username": user.username,
|
||||||
|
# "groups": []
|
||||||
|
# }
|
||||||
|
|
||||||
|
# for grp in groups_qs:
|
||||||
|
# # URL иконки группы (поле icon в модели)
|
||||||
|
# grp_icon_url = grp.icon.url if grp.icon else None
|
||||||
|
|
||||||
|
# grp_data = {
|
||||||
|
# "id": grp.id,
|
||||||
|
# "name": grp.name,
|
||||||
|
# "icon": grp_icon_url,
|
||||||
|
# "links": [],
|
||||||
|
# }
|
||||||
|
|
||||||
|
# for ln in grp.links.all():
|
||||||
|
# # URL иконки ссылки (поле icon в модели Link — URLField)
|
||||||
|
# link_icon_url = ln.icon if ln.icon else None
|
||||||
|
|
||||||
|
# grp_data["links"].append({
|
||||||
|
# "id": ln.id,
|
||||||
|
# "title": ln.title,
|
||||||
|
# "url": ln.url,
|
||||||
|
# "icon": link_icon_url,
|
||||||
|
# })
|
||||||
|
|
||||||
|
# result["groups"].append(grp_data)
|
||||||
|
|
||||||
|
# return Response(result, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
# backend/api/views.py
|
||||||
|
|
||||||
|
from rest_framework import generics, viewsets, permissions, status
|
||||||
|
from rest_framework.parsers import MultiPartParser, FormParser, JSONParser
|
||||||
|
from rest_framework_simplejwt.views import TokenObtainPairView
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from django.shortcuts import get_object_or_404
|
||||||
|
from drf_spectacular.utils import extend_schema, OpenApiParameter
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
|
||||||
|
from .models import Link, LinkGroup
|
||||||
|
from .serializers import (
|
||||||
|
RegisterSerializer,
|
||||||
|
UserSerializer,
|
||||||
|
LinkSerializer,
|
||||||
|
LinkGroupSerializer,
|
||||||
|
)
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
class RegisterView(generics.CreateAPIView):
|
||||||
|
queryset = User.objects.all()
|
||||||
|
permission_classes = [permissions.AllowAny]
|
||||||
|
serializer_class = RegisterSerializer
|
||||||
|
|
||||||
|
|
||||||
|
class LoginView(TokenObtainPairView):
|
||||||
|
permission_classes = [permissions.AllowAny]
|
||||||
|
|
||||||
|
|
||||||
|
class LinkGroupViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = LinkGroup.objects.all()
|
||||||
|
serializer_class = LinkGroupSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
parser_classes = [MultiPartParser, FormParser, JSONParser]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return self.queryset.filter(owner=self.request.user).order_by('order')
|
||||||
|
|
||||||
|
def perform_create(self, serializer):
|
||||||
|
serializer.save(owner=self.request.user)
|
||||||
|
|
||||||
|
|
||||||
|
class LinkViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = Link.objects.all()
|
||||||
|
serializer_class = LinkSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
parser_classes = [MultiPartParser, FormParser, JSONParser]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return Link.objects.filter(owner=self.request.user).order_by('order')
|
||||||
|
|
||||||
|
def perform_create(self, serializer):
|
||||||
|
serializer.save(owner=self.request.user)
|
||||||
|
|
||||||
|
|
||||||
|
class UserProfileView(generics.RetrieveAPIView):
|
||||||
|
serializer_class = UserSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
def get_object(self):
|
||||||
|
return self.request.user
|
||||||
|
|
||||||
|
|
||||||
|
class UserLinksListView(generics.ListAPIView):
|
||||||
|
serializer_class = LinkSerializer
|
||||||
|
permission_classes = [permissions.AllowAny]
|
||||||
|
def get_queryset(self):
|
||||||
|
username = self.kwargs['username']
|
||||||
|
return Link.objects.filter(owner__username=username).order_by('order')
|
||||||
|
|
||||||
|
|
||||||
|
class PublicUserGroupsView(APIView):
|
||||||
|
"""
|
||||||
|
GET /api/users/{username}/public/
|
||||||
|
"""
|
||||||
|
permission_classes = [permissions.AllowAny]
|
||||||
|
|
||||||
|
def get(self, request, username):
|
||||||
|
# 1. Ищем пользователя
|
||||||
|
user = get_object_or_404(User, username=username)
|
||||||
|
|
||||||
|
# 2. Берём его группы со ссылками
|
||||||
|
groups_qs = LinkGroup.objects.filter(owner=user).prefetch_related('links')
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"username": user.username,
|
||||||
|
"groups": []
|
||||||
|
}
|
||||||
|
|
||||||
|
for grp in groups_qs:
|
||||||
|
# icon у группы (абсолютный URL)
|
||||||
|
grp_icon_url = None
|
||||||
|
if grp.icon:
|
||||||
|
grp_icon_url = request.build_absolute_uri(grp.icon.url)
|
||||||
|
|
||||||
|
grp_data = {
|
||||||
|
"id": grp.id,
|
||||||
|
"name": grp.name,
|
||||||
|
"icon": grp_icon_url,
|
||||||
|
"links": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
for ln in grp.links.all():
|
||||||
|
# icon у ссылки
|
||||||
|
ln_icon_url = None
|
||||||
|
if ln.icon:
|
||||||
|
ln_icon_url = request.build_absolute_uri(ln.icon.url)
|
||||||
|
|
||||||
|
grp_data["links"].append({
|
||||||
|
"id": ln.id,
|
||||||
|
"title": ln.title,
|
||||||
|
"url": ln.url,
|
||||||
|
"icon": ln_icon_url,
|
||||||
|
})
|
||||||
|
|
||||||
|
result["groups"].append(grp_data)
|
||||||
|
|
||||||
|
return Response(result, status=status.HTTP_200_OK)
|
||||||
267
.history/backend/api/views_20250507150709.py
Normal file
267
.history/backend/api/views_20250507150709.py
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
# # backend/api/views.py
|
||||||
|
|
||||||
|
# from rest_framework import generics, viewsets, permissions, status
|
||||||
|
# from django.contrib.auth import get_user_model
|
||||||
|
# from rest_framework_simplejwt.views import TokenObtainPairView
|
||||||
|
# from rest_framework.views import APIView
|
||||||
|
# from rest_framework.response import Response
|
||||||
|
# from django.shortcuts import get_object_or_404
|
||||||
|
# from drf_spectacular.utils import extend_schema, OpenApiParameter
|
||||||
|
|
||||||
|
# from .models import Link, LinkGroup
|
||||||
|
# from .serializers import (
|
||||||
|
# RegisterSerializer,
|
||||||
|
# UserSerializer,
|
||||||
|
# LinkSerializer,
|
||||||
|
# LinkGroupSerializer,
|
||||||
|
# )
|
||||||
|
|
||||||
|
# User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
# @extend_schema(
|
||||||
|
# parameters=[
|
||||||
|
# OpenApiParameter(
|
||||||
|
# name='id',
|
||||||
|
# type=int,
|
||||||
|
# location=OpenApiParameter.PATH
|
||||||
|
# )
|
||||||
|
# ]
|
||||||
|
# )
|
||||||
|
# class RegisterView(generics.CreateAPIView):
|
||||||
|
# """
|
||||||
|
# POST /api/auth/register/
|
||||||
|
# Регистрирует нового пользователя.
|
||||||
|
# """
|
||||||
|
# queryset = User.objects.all()
|
||||||
|
# permission_classes = (permissions.AllowAny,)
|
||||||
|
# serializer_class = RegisterSerializer
|
||||||
|
|
||||||
|
|
||||||
|
# class LoginView(TokenObtainPairView):
|
||||||
|
# """
|
||||||
|
# POST /api/auth/login/
|
||||||
|
# Возвращает JWT-токены.
|
||||||
|
# """
|
||||||
|
# permission_classes = (permissions.AllowAny,)
|
||||||
|
|
||||||
|
|
||||||
|
# class LinkGroupViewSet(viewsets.ModelViewSet):
|
||||||
|
# """
|
||||||
|
# /api/groups/
|
||||||
|
# CRUD для групп ссылок текущего пользователя.
|
||||||
|
# """
|
||||||
|
# queryset = LinkGroup.objects.all()
|
||||||
|
# serializer_class = LinkGroupSerializer
|
||||||
|
# permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
|
# def get_queryset(self):
|
||||||
|
# # Возвращаем только свои группы, упорядоченные по полю order
|
||||||
|
# return self.queryset.filter(owner=self.request.user).order_by('order')
|
||||||
|
|
||||||
|
# def perform_create(self, serializer):
|
||||||
|
# # При создании модели автоматически ставим owner = текущий пользователь
|
||||||
|
# serializer.save(owner=self.request.user)
|
||||||
|
|
||||||
|
|
||||||
|
# class LinkViewSet(viewsets.ModelViewSet):
|
||||||
|
# """
|
||||||
|
# /api/links/
|
||||||
|
# CRUD для ссылок текущего пользователя.
|
||||||
|
# """
|
||||||
|
# serializer_class = LinkSerializer
|
||||||
|
# permission_classes = (permissions.IsAuthenticated,)
|
||||||
|
|
||||||
|
# def get_queryset(self):
|
||||||
|
# # Возвращаем только свои ссылки, упорядоченные по полю order
|
||||||
|
# return Link.objects.filter(owner=self.request.user).order_by('order')
|
||||||
|
|
||||||
|
# def perform_create(self, serializer):
|
||||||
|
# # При создании модели автоматически ставим owner = текущий пользователь
|
||||||
|
# serializer.save(owner=self.request.user)
|
||||||
|
|
||||||
|
|
||||||
|
# class UserLinksListView(generics.ListAPIView):
|
||||||
|
# """
|
||||||
|
# GET /api/users/{username}/links/
|
||||||
|
# Список публичных ссылок пользователя (без группировки).
|
||||||
|
# """
|
||||||
|
# serializer_class = LinkSerializer
|
||||||
|
# permission_classes = (permissions.AllowAny,)
|
||||||
|
|
||||||
|
# def get_queryset(self):
|
||||||
|
# username = self.kwargs['username']
|
||||||
|
# return Link.objects.filter(owner__username=username).order_by('order')
|
||||||
|
|
||||||
|
|
||||||
|
# class UserProfileView(generics.RetrieveAPIView):
|
||||||
|
# """
|
||||||
|
# GET /api/auth/user/
|
||||||
|
# Возвращает данные авторизованного пользователя.
|
||||||
|
# """
|
||||||
|
# serializer_class = UserSerializer
|
||||||
|
# permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
|
# def get_object(self):
|
||||||
|
# return self.request.user
|
||||||
|
|
||||||
|
|
||||||
|
# class PublicUserGroupsView(APIView):
|
||||||
|
# """
|
||||||
|
# GET /api/users/{username}/public/
|
||||||
|
# Возвращает публичные группы и ссылки пользователя.
|
||||||
|
# """
|
||||||
|
# permission_classes = [permissions.AllowAny]
|
||||||
|
|
||||||
|
# def get(self, request, username):
|
||||||
|
# user = get_object_or_404(User, username=username)
|
||||||
|
|
||||||
|
# # Берем все группы пользователя вместе с их ссылками
|
||||||
|
# groups_qs = LinkGroup.objects.filter(owner=user).prefetch_related('links')
|
||||||
|
|
||||||
|
# result = {
|
||||||
|
# "username": user.username,
|
||||||
|
# "groups": []
|
||||||
|
# }
|
||||||
|
|
||||||
|
# for grp in groups_qs:
|
||||||
|
# # URL иконки группы (поле icon в модели)
|
||||||
|
# grp_icon_url = grp.icon.url if grp.icon else None
|
||||||
|
|
||||||
|
# grp_data = {
|
||||||
|
# "id": grp.id,
|
||||||
|
# "name": grp.name,
|
||||||
|
# "icon": grp_icon_url,
|
||||||
|
# "links": [],
|
||||||
|
# }
|
||||||
|
|
||||||
|
# for ln in grp.links.all():
|
||||||
|
# # URL иконки ссылки (поле icon в модели Link — URLField)
|
||||||
|
# link_icon_url = ln.icon if ln.icon else None
|
||||||
|
|
||||||
|
# grp_data["links"].append({
|
||||||
|
# "id": ln.id,
|
||||||
|
# "title": ln.title,
|
||||||
|
# "url": ln.url,
|
||||||
|
# "icon": link_icon_url,
|
||||||
|
# })
|
||||||
|
|
||||||
|
# result["groups"].append(grp_data)
|
||||||
|
|
||||||
|
# return Response(result, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
# backend/api/views.py
|
||||||
|
|
||||||
|
from rest_framework import generics, viewsets, permissions, status
|
||||||
|
from rest_framework.parsers import MultiPartParser, FormParser, JSONParser
|
||||||
|
from rest_framework_simplejwt.views import TokenObtainPairView
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from django.shortcuts import get_object_or_404
|
||||||
|
from drf_spectacular.utils import extend_schema, OpenApiParameter
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
|
||||||
|
from .models import Link, LinkGroup
|
||||||
|
from .serializers import (
|
||||||
|
RegisterSerializer,
|
||||||
|
UserSerializer,
|
||||||
|
LinkSerializer,
|
||||||
|
LinkGroupSerializer,
|
||||||
|
)
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
class RegisterView(generics.CreateAPIView):
|
||||||
|
queryset = User.objects.all()
|
||||||
|
permission_classes = [permissions.AllowAny]
|
||||||
|
serializer_class = RegisterSerializer
|
||||||
|
|
||||||
|
|
||||||
|
class LoginView(TokenObtainPairView):
|
||||||
|
permission_classes = [permissions.AllowAny]
|
||||||
|
|
||||||
|
|
||||||
|
class LinkGroupViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = LinkGroup.objects.all()
|
||||||
|
serializer_class = LinkGroupSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
parser_classes = [MultiPartParser, FormParser, JSONParser]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return self.queryset.filter(owner=self.request.user).order_by('order')
|
||||||
|
|
||||||
|
def perform_create(self, serializer):
|
||||||
|
serializer.save(owner=self.request.user)
|
||||||
|
|
||||||
|
|
||||||
|
class LinkViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = Link.objects.all()
|
||||||
|
serializer_class = LinkSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
parser_classes = [MultiPartParser, FormParser, JSONParser]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return Link.objects.filter(owner=self.request.user).order_by('order')
|
||||||
|
|
||||||
|
def perform_create(self, serializer):
|
||||||
|
serializer.save(owner=self.request.user)
|
||||||
|
|
||||||
|
|
||||||
|
class UserProfileView(generics.RetrieveAPIView):
|
||||||
|
serializer_class = UserSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
def get_object(self):
|
||||||
|
return self.request.user
|
||||||
|
|
||||||
|
|
||||||
|
class UserLinksListView(generics.ListAPIView):
|
||||||
|
serializer_class = LinkSerializer
|
||||||
|
permission_classes = [permissions.AllowAny]
|
||||||
|
def get_queryset(self):
|
||||||
|
username = self.kwargs['username']
|
||||||
|
return Link.objects.filter(owner__username=username).order_by('order')
|
||||||
|
|
||||||
|
|
||||||
|
class PublicUserGroupsView(APIView):
|
||||||
|
"""
|
||||||
|
GET /api/users/{username}/public/
|
||||||
|
"""
|
||||||
|
permission_classes = [permissions.AllowAny]
|
||||||
|
|
||||||
|
def get(self, request, username):
|
||||||
|
user = get_object_or_404(User, username=username)
|
||||||
|
|
||||||
|
# Берём все группы пользователя вместе с их ссылками
|
||||||
|
groups_qs = LinkGroup.objects.filter(owner=user).prefetch_related('links')
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"username": user.username,
|
||||||
|
"groups": []
|
||||||
|
}
|
||||||
|
|
||||||
|
for grp in groups_qs:
|
||||||
|
# поле icon у группы — это ImageField
|
||||||
|
grp_icon_url = grp.icon.url if grp.icon else None
|
||||||
|
|
||||||
|
grp_data = {
|
||||||
|
"id": grp.id,
|
||||||
|
"name": grp.name,
|
||||||
|
"image": grp_icon_url, # отдаём под ключом "image", как ждёт фронт
|
||||||
|
"links": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
for ln in grp.links.all():
|
||||||
|
# поле icon у ссылки — это URLField, сразу строка
|
||||||
|
link_icon_url = ln.icon if ln.icon else None
|
||||||
|
|
||||||
|
grp_data["links"].append({
|
||||||
|
"id": ln.id,
|
||||||
|
"title": ln.title,
|
||||||
|
"url": ln.url,
|
||||||
|
"image": link_icon_url, # тоже "image"
|
||||||
|
})
|
||||||
|
|
||||||
|
result["groups"].append(grp_data)
|
||||||
|
|
||||||
|
return Response(result, status=status.HTTP_200_OK)
|
||||||
272
.history/backend/api/views_20250507150738.py
Normal file
272
.history/backend/api/views_20250507150738.py
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
# # backend/api/views.py
|
||||||
|
|
||||||
|
# from rest_framework import generics, viewsets, permissions, status
|
||||||
|
# from django.contrib.auth import get_user_model
|
||||||
|
# from rest_framework_simplejwt.views import TokenObtainPairView
|
||||||
|
# from rest_framework.views import APIView
|
||||||
|
# from rest_framework.response import Response
|
||||||
|
# from django.shortcuts import get_object_or_404
|
||||||
|
# from drf_spectacular.utils import extend_schema, OpenApiParameter
|
||||||
|
|
||||||
|
# from .models import Link, LinkGroup
|
||||||
|
# from .serializers import (
|
||||||
|
# RegisterSerializer,
|
||||||
|
# UserSerializer,
|
||||||
|
# LinkSerializer,
|
||||||
|
# LinkGroupSerializer,
|
||||||
|
# )
|
||||||
|
|
||||||
|
# User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
# @extend_schema(
|
||||||
|
# parameters=[
|
||||||
|
# OpenApiParameter(
|
||||||
|
# name='id',
|
||||||
|
# type=int,
|
||||||
|
# location=OpenApiParameter.PATH
|
||||||
|
# )
|
||||||
|
# ]
|
||||||
|
# )
|
||||||
|
# class RegisterView(generics.CreateAPIView):
|
||||||
|
# """
|
||||||
|
# POST /api/auth/register/
|
||||||
|
# Регистрирует нового пользователя.
|
||||||
|
# """
|
||||||
|
# queryset = User.objects.all()
|
||||||
|
# permission_classes = (permissions.AllowAny,)
|
||||||
|
# serializer_class = RegisterSerializer
|
||||||
|
|
||||||
|
|
||||||
|
# class LoginView(TokenObtainPairView):
|
||||||
|
# """
|
||||||
|
# POST /api/auth/login/
|
||||||
|
# Возвращает JWT-токены.
|
||||||
|
# """
|
||||||
|
# permission_classes = (permissions.AllowAny,)
|
||||||
|
|
||||||
|
|
||||||
|
# class LinkGroupViewSet(viewsets.ModelViewSet):
|
||||||
|
# """
|
||||||
|
# /api/groups/
|
||||||
|
# CRUD для групп ссылок текущего пользователя.
|
||||||
|
# """
|
||||||
|
# queryset = LinkGroup.objects.all()
|
||||||
|
# serializer_class = LinkGroupSerializer
|
||||||
|
# permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
|
# def get_queryset(self):
|
||||||
|
# # Возвращаем только свои группы, упорядоченные по полю order
|
||||||
|
# return self.queryset.filter(owner=self.request.user).order_by('order')
|
||||||
|
|
||||||
|
# def perform_create(self, serializer):
|
||||||
|
# # При создании модели автоматически ставим owner = текущий пользователь
|
||||||
|
# serializer.save(owner=self.request.user)
|
||||||
|
|
||||||
|
|
||||||
|
# class LinkViewSet(viewsets.ModelViewSet):
|
||||||
|
# """
|
||||||
|
# /api/links/
|
||||||
|
# CRUD для ссылок текущего пользователя.
|
||||||
|
# """
|
||||||
|
# serializer_class = LinkSerializer
|
||||||
|
# permission_classes = (permissions.IsAuthenticated,)
|
||||||
|
|
||||||
|
# def get_queryset(self):
|
||||||
|
# # Возвращаем только свои ссылки, упорядоченные по полю order
|
||||||
|
# return Link.objects.filter(owner=self.request.user).order_by('order')
|
||||||
|
|
||||||
|
# def perform_create(self, serializer):
|
||||||
|
# # При создании модели автоматически ставим owner = текущий пользователь
|
||||||
|
# serializer.save(owner=self.request.user)
|
||||||
|
|
||||||
|
|
||||||
|
# class UserLinksListView(generics.ListAPIView):
|
||||||
|
# """
|
||||||
|
# GET /api/users/{username}/links/
|
||||||
|
# Список публичных ссылок пользователя (без группировки).
|
||||||
|
# """
|
||||||
|
# serializer_class = LinkSerializer
|
||||||
|
# permission_classes = (permissions.AllowAny,)
|
||||||
|
|
||||||
|
# def get_queryset(self):
|
||||||
|
# username = self.kwargs['username']
|
||||||
|
# return Link.objects.filter(owner__username=username).order_by('order')
|
||||||
|
|
||||||
|
|
||||||
|
# class UserProfileView(generics.RetrieveAPIView):
|
||||||
|
# """
|
||||||
|
# GET /api/auth/user/
|
||||||
|
# Возвращает данные авторизованного пользователя.
|
||||||
|
# """
|
||||||
|
# serializer_class = UserSerializer
|
||||||
|
# permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
|
# def get_object(self):
|
||||||
|
# return self.request.user
|
||||||
|
|
||||||
|
|
||||||
|
# class PublicUserGroupsView(APIView):
|
||||||
|
# """
|
||||||
|
# GET /api/users/{username}/public/
|
||||||
|
# Возвращает публичные группы и ссылки пользователя.
|
||||||
|
# """
|
||||||
|
# permission_classes = [permissions.AllowAny]
|
||||||
|
|
||||||
|
# def get(self, request, username):
|
||||||
|
# user = get_object_or_404(User, username=username)
|
||||||
|
|
||||||
|
# # Берем все группы пользователя вместе с их ссылками
|
||||||
|
# groups_qs = LinkGroup.objects.filter(owner=user).prefetch_related('links')
|
||||||
|
|
||||||
|
# result = {
|
||||||
|
# "username": user.username,
|
||||||
|
# "groups": []
|
||||||
|
# }
|
||||||
|
|
||||||
|
# for grp in groups_qs:
|
||||||
|
# # URL иконки группы (поле icon в модели)
|
||||||
|
# grp_icon_url = grp.icon.url if grp.icon else None
|
||||||
|
|
||||||
|
# grp_data = {
|
||||||
|
# "id": grp.id,
|
||||||
|
# "name": grp.name,
|
||||||
|
# "icon": grp_icon_url,
|
||||||
|
# "links": [],
|
||||||
|
# }
|
||||||
|
|
||||||
|
# for ln in grp.links.all():
|
||||||
|
# # URL иконки ссылки (поле icon в модели Link — URLField)
|
||||||
|
# link_icon_url = ln.icon if ln.icon else None
|
||||||
|
|
||||||
|
# grp_data["links"].append({
|
||||||
|
# "id": ln.id,
|
||||||
|
# "title": ln.title,
|
||||||
|
# "url": ln.url,
|
||||||
|
# "icon": link_icon_url,
|
||||||
|
# })
|
||||||
|
|
||||||
|
# result["groups"].append(grp_data)
|
||||||
|
|
||||||
|
# return Response(result, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
# backend/api/views.py
|
||||||
|
|
||||||
|
from rest_framework import generics, viewsets, permissions, status
|
||||||
|
from rest_framework.parsers import MultiPartParser, FormParser, JSONParser
|
||||||
|
from rest_framework_simplejwt.views import TokenObtainPairView
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from django.shortcuts import get_object_or_404
|
||||||
|
from drf_spectacular.utils import extend_schema, OpenApiParameter
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
|
||||||
|
from .models import Link, LinkGroup
|
||||||
|
from .serializers import (
|
||||||
|
RegisterSerializer,
|
||||||
|
UserSerializer,
|
||||||
|
LinkSerializer,
|
||||||
|
LinkGroupSerializer,
|
||||||
|
)
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
class RegisterView(generics.CreateAPIView):
|
||||||
|
queryset = User.objects.all()
|
||||||
|
permission_classes = [permissions.AllowAny]
|
||||||
|
serializer_class = RegisterSerializer
|
||||||
|
|
||||||
|
|
||||||
|
class LoginView(TokenObtainPairView):
|
||||||
|
permission_classes = [permissions.AllowAny]
|
||||||
|
|
||||||
|
|
||||||
|
class LinkGroupViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = LinkGroup.objects.all()
|
||||||
|
serializer_class = LinkGroupSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
parser_classes = [MultiPartParser, FormParser, JSONParser]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return self.queryset.filter(owner=self.request.user).order_by('order')
|
||||||
|
|
||||||
|
def perform_create(self, serializer):
|
||||||
|
serializer.save(owner=self.request.user)
|
||||||
|
|
||||||
|
|
||||||
|
class LinkViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = Link.objects.all()
|
||||||
|
serializer_class = LinkSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
parser_classes = [MultiPartParser, FormParser, JSONParser]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return Link.objects.filter(owner=self.request.user).order_by('order')
|
||||||
|
|
||||||
|
def perform_create(self, serializer):
|
||||||
|
serializer.save(owner=self.request.user)
|
||||||
|
|
||||||
|
|
||||||
|
class UserProfileView(generics.RetrieveAPIView):
|
||||||
|
serializer_class = UserSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
def get_object(self):
|
||||||
|
return self.request.user
|
||||||
|
|
||||||
|
|
||||||
|
class UserLinksListView(generics.ListAPIView):
|
||||||
|
serializer_class = LinkSerializer
|
||||||
|
permission_classes = [permissions.AllowAny]
|
||||||
|
def get_queryset(self):
|
||||||
|
username = self.kwargs['username']
|
||||||
|
return Link.objects.filter(owner__username=username).order_by('order')
|
||||||
|
|
||||||
|
|
||||||
|
class PublicUserGroupsView(APIView):
|
||||||
|
"""
|
||||||
|
GET /api/users/{username}/public/
|
||||||
|
"""
|
||||||
|
permission_classes = [permissions.AllowAny]
|
||||||
|
|
||||||
|
def get(self, request, username):
|
||||||
|
# 1. Ищем пользователя
|
||||||
|
user = get_object_or_404(User, username=username)
|
||||||
|
|
||||||
|
# 2. Берём его группы со ссылками
|
||||||
|
groups_qs = LinkGroup.objects.filter(owner=user).prefetch_related('links')
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"username": user.username,
|
||||||
|
"groups": []
|
||||||
|
}
|
||||||
|
|
||||||
|
for grp in groups_qs:
|
||||||
|
# icon у группы (абсолютный URL)
|
||||||
|
grp_icon_url = None
|
||||||
|
if grp.icon:
|
||||||
|
grp_icon_url = request.build_absolute_uri(grp.icon.url)
|
||||||
|
|
||||||
|
grp_data = {
|
||||||
|
"id": grp.id,
|
||||||
|
"name": grp.name,
|
||||||
|
"icon": grp_icon_url,
|
||||||
|
"links": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
for ln in grp.links.all():
|
||||||
|
# icon у ссылки
|
||||||
|
ln_icon_url = None
|
||||||
|
if ln.icon:
|
||||||
|
ln_icon_url = request.build_absolute_uri(ln.icon.url)
|
||||||
|
|
||||||
|
grp_data["links"].append({
|
||||||
|
"id": ln.id,
|
||||||
|
"title": ln.title,
|
||||||
|
"url": ln.url,
|
||||||
|
"icon": ln_icon_url,
|
||||||
|
})
|
||||||
|
|
||||||
|
result["groups"].append(grp_data)
|
||||||
|
|
||||||
|
return Response(result, status=status.HTTP_200_OK)
|
||||||
119
.history/backend/api/views_20250507150907.py
Normal file
119
.history/backend/api/views_20250507150907.py
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
# coding: utf-8
|
||||||
|
from rest_framework import generics, viewsets, permissions, status
|
||||||
|
from rest_framework.parsers import MultiPartParser, FormParser, JSONParser
|
||||||
|
from rest_framework_simplejwt.views import TokenObtainPairView
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from django.shortcuts import get_object_or_404
|
||||||
|
from drf_spectacular.utils import extend_schema, OpenApiParameter
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
|
||||||
|
from .models import Link, LinkGroup
|
||||||
|
from .serializers import (
|
||||||
|
RegisterSerializer,
|
||||||
|
UserSerializer,
|
||||||
|
LinkSerializer,
|
||||||
|
LinkGroupSerializer,
|
||||||
|
)
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
class RegisterView(generics.CreateAPIView):
|
||||||
|
queryset = User.objects.all()
|
||||||
|
permission_classes = [permissions.AllowAny]
|
||||||
|
serializer_class = RegisterSerializer
|
||||||
|
|
||||||
|
|
||||||
|
class LoginView(TokenObtainPairView):
|
||||||
|
permission_classes = [permissions.AllowAny]
|
||||||
|
|
||||||
|
|
||||||
|
class LinkGroupViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = LinkGroup.objects.all()
|
||||||
|
serializer_class = LinkGroupSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
parser_classes = [MultiPartParser, FormParser, JSONParser]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return self.queryset.filter(owner=self.request.user).order_by('order')
|
||||||
|
|
||||||
|
def perform_create(self, serializer):
|
||||||
|
serializer.save(owner=self.request.user)
|
||||||
|
|
||||||
|
|
||||||
|
class LinkViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = Link.objects.all()
|
||||||
|
serializer_class = LinkSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
parser_classes = [MultiPartParser, FormParser, JSONParser]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return Link.objects.filter(owner=self.request.user).order_by('order')
|
||||||
|
|
||||||
|
def perform_create(self, serializer):
|
||||||
|
serializer.save(owner=self.request.user)
|
||||||
|
|
||||||
|
|
||||||
|
class UserProfileView(generics.RetrieveAPIView):
|
||||||
|
serializer_class = UserSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
def get_object(self):
|
||||||
|
return self.request.user
|
||||||
|
|
||||||
|
|
||||||
|
class UserLinksListView(generics.ListAPIView):
|
||||||
|
serializer_class = LinkSerializer
|
||||||
|
permission_classes = [permissions.AllowAny]
|
||||||
|
def get_queryset(self):
|
||||||
|
username = self.kwargs['username']
|
||||||
|
return Link.objects.filter(owner__username=username).order_by('order')
|
||||||
|
|
||||||
|
|
||||||
|
class PublicUserGroupsView(APIView):
|
||||||
|
"""
|
||||||
|
GET /api/users/{username}/public/
|
||||||
|
"""
|
||||||
|
permission_classes = [permissions.AllowAny]
|
||||||
|
|
||||||
|
def get(self, request, username):
|
||||||
|
# 1. Ищем пользователя
|
||||||
|
user = get_object_or_404(User, username=username)
|
||||||
|
|
||||||
|
# 2. Берём его группы со ссылками
|
||||||
|
groups_qs = LinkGroup.objects.filter(owner=user).prefetch_related('links')
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"username": user.username,
|
||||||
|
"groups": []
|
||||||
|
}
|
||||||
|
|
||||||
|
for grp in groups_qs:
|
||||||
|
# icon у группы (абсолютный URL)
|
||||||
|
grp_icon_url = None
|
||||||
|
if grp.icon:
|
||||||
|
grp_icon_url = request.build_absolute_uri(grp.icon.url)
|
||||||
|
|
||||||
|
grp_data = {
|
||||||
|
"id": grp.id,
|
||||||
|
"name": grp.name,
|
||||||
|
"icon": grp_icon_url,
|
||||||
|
"links": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
for ln in grp.links.all():
|
||||||
|
# icon у ссылки
|
||||||
|
ln_icon_url = None
|
||||||
|
if ln.icon:
|
||||||
|
ln_icon_url = request.build_absolute_uri(ln.icon.url)
|
||||||
|
|
||||||
|
grp_data["links"].append({
|
||||||
|
"id": ln.id,
|
||||||
|
"title": ln.title,
|
||||||
|
"url": ln.url,
|
||||||
|
"icon": ln_icon_url,
|
||||||
|
})
|
||||||
|
|
||||||
|
result["groups"].append(grp_data)
|
||||||
|
|
||||||
|
return Response(result, status=status.HTTP_200_OK)
|
||||||
150
.history/backend/api/views_20250507151205.py
Normal file
150
.history/backend/api/views_20250507151205.py
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
# api/views.py
|
||||||
|
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.shortcuts import get_object_or_404
|
||||||
|
|
||||||
|
from rest_framework import generics, viewsets, permissions, status
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework_simplejwt.views import TokenObtainPairView
|
||||||
|
|
||||||
|
from drf_spectacular.utils import extend_schema, OpenApiParameter
|
||||||
|
|
||||||
|
from .models import Link, LinkGroup
|
||||||
|
from .serializers import (
|
||||||
|
RegisterSerializer,
|
||||||
|
UserSerializer,
|
||||||
|
LinkSerializer,
|
||||||
|
LinkGroupSerializer,
|
||||||
|
)
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
parameters=[
|
||||||
|
OpenApiParameter(
|
||||||
|
name='id',
|
||||||
|
type=int,
|
||||||
|
location=OpenApiParameter.PATH,
|
||||||
|
description='ID создаваемого или изменяемого объекта',
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
class RegisterView(generics.CreateAPIView):
|
||||||
|
"""
|
||||||
|
POST /api/auth/register/
|
||||||
|
Регистрация нового пользователя.
|
||||||
|
"""
|
||||||
|
queryset = User.objects.all()
|
||||||
|
permission_classes = (permissions.AllowAny,)
|
||||||
|
serializer_class = RegisterSerializer
|
||||||
|
|
||||||
|
|
||||||
|
class LoginView(TokenObtainPairView):
|
||||||
|
"""
|
||||||
|
POST /api/auth/login/
|
||||||
|
Получение JWT-токенов для входа.
|
||||||
|
"""
|
||||||
|
permission_classes = (permissions.AllowAny,)
|
||||||
|
|
||||||
|
|
||||||
|
class LinkGroupViewSet(viewsets.ModelViewSet):
|
||||||
|
"""
|
||||||
|
CRUD для групп ссылок текущего пользователя.
|
||||||
|
/api/groups/
|
||||||
|
"""
|
||||||
|
queryset = LinkGroup.objects.all()
|
||||||
|
serializer_class = LinkGroupSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
# Только свои группы
|
||||||
|
return self.queryset.filter(owner=self.request.user).order_by('order')
|
||||||
|
|
||||||
|
|
||||||
|
class LinkViewSet(viewsets.ModelViewSet):
|
||||||
|
"""
|
||||||
|
CRUD для ссылок текущего пользователя.
|
||||||
|
/api/links/
|
||||||
|
"""
|
||||||
|
serializer_class = LinkSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return Link.objects.filter(owner=self.request.user).order_by('order')
|
||||||
|
|
||||||
|
def perform_create(self, serializer):
|
||||||
|
# При создании автоматически ставим владельца
|
||||||
|
serializer.save(owner=self.request.user)
|
||||||
|
|
||||||
|
|
||||||
|
class UserLinksListView(generics.ListAPIView):
|
||||||
|
"""
|
||||||
|
GET /api/users/{username}/links/
|
||||||
|
Список всех ссылок публичного пользователя.
|
||||||
|
"""
|
||||||
|
serializer_class = LinkSerializer
|
||||||
|
permission_classes = (permissions.AllowAny,)
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
username = self.kwargs['username']
|
||||||
|
return Link.objects.filter(owner__username=username).order_by('order')
|
||||||
|
|
||||||
|
|
||||||
|
class UserProfileView(generics.RetrieveAPIView):
|
||||||
|
"""
|
||||||
|
GET /api/auth/user/
|
||||||
|
Данные текущего авторизованного пользователя.
|
||||||
|
"""
|
||||||
|
serializer_class = UserSerializer
|
||||||
|
permission_classes = (permissions.IsAuthenticated,)
|
||||||
|
|
||||||
|
def get_object(self):
|
||||||
|
return self.request.user
|
||||||
|
|
||||||
|
|
||||||
|
class PublicUserGroupsView(APIView):
|
||||||
|
"""
|
||||||
|
GET /api/users/{username}/public/
|
||||||
|
Публичные группы ссылок пользователя вместе с их ссылками.
|
||||||
|
"""
|
||||||
|
permission_classes = (permissions.AllowAny,)
|
||||||
|
|
||||||
|
def get(self, request, username):
|
||||||
|
# Находим пользователя по имени
|
||||||
|
user = get_object_or_404(User, username=username)
|
||||||
|
|
||||||
|
# Берём его группы с предзагрузкой ссылок
|
||||||
|
groups_qs = LinkGroup.objects.filter(owner=user).prefetch_related('links')
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"username": user.username,
|
||||||
|
"groups": []
|
||||||
|
}
|
||||||
|
|
||||||
|
for grp in groups_qs:
|
||||||
|
# иконка группы (ImageField)
|
||||||
|
grp_icon_url = grp.icon.url if grp.icon else None
|
||||||
|
|
||||||
|
grp_data = {
|
||||||
|
"id": grp.id,
|
||||||
|
"name": grp.name,
|
||||||
|
"image": grp_icon_url,
|
||||||
|
"links": []
|
||||||
|
}
|
||||||
|
|
||||||
|
for ln in grp.links.all():
|
||||||
|
# иконка ссылки (URLField)
|
||||||
|
link_icon_url = ln.icon if ln.icon else None
|
||||||
|
|
||||||
|
grp_data["links"].append({
|
||||||
|
"id": ln.id,
|
||||||
|
"title": ln.title,
|
||||||
|
"url": ln.url,
|
||||||
|
"image": link_icon_url,
|
||||||
|
})
|
||||||
|
|
||||||
|
result["groups"].append(grp_data)
|
||||||
|
|
||||||
|
return Response(result, status=status.HTTP_200_OK)
|
||||||
119
.history/backend/api/views_20250507151600.py
Normal file
119
.history/backend/api/views_20250507151600.py
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
# coding: utf-8
|
||||||
|
from rest_framework import generics, viewsets, permissions, status
|
||||||
|
from rest_framework.parsers import MultiPartParser, FormParser, JSONParser
|
||||||
|
from rest_framework_simplejwt.views import TokenObtainPairView
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from django.shortcuts import get_object_or_404
|
||||||
|
from drf_spectacular.utils import extend_schema, OpenApiParameter
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
|
||||||
|
from .models import Link, LinkGroup
|
||||||
|
from .serializers import (
|
||||||
|
RegisterSerializer,
|
||||||
|
UserSerializer,
|
||||||
|
LinkSerializer,
|
||||||
|
LinkGroupSerializer,
|
||||||
|
)
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
class RegisterView(generics.CreateAPIView):
|
||||||
|
queryset = User.objects.all()
|
||||||
|
permission_classes = [permissions.AllowAny]
|
||||||
|
serializer_class = RegisterSerializer
|
||||||
|
|
||||||
|
|
||||||
|
class LoginView(TokenObtainPairView):
|
||||||
|
permission_classes = [permissions.AllowAny]
|
||||||
|
|
||||||
|
|
||||||
|
class LinkGroupViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = LinkGroup.objects.all()
|
||||||
|
serializer_class = LinkGroupSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
parser_classes = [MultiPartParser, FormParser, JSONParser]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return self.queryset.filter(owner=self.request.user).order_by('order')
|
||||||
|
|
||||||
|
def perform_create(self, serializer):
|
||||||
|
serializer.save(owner=self.request.user)
|
||||||
|
|
||||||
|
|
||||||
|
class LinkViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = Link.objects.all()
|
||||||
|
serializer_class = LinkSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
parser_classes = [MultiPartParser, FormParser, JSONParser]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return Link.objects.filter(owner=self.request.user).order_by('order')
|
||||||
|
|
||||||
|
def perform_create(self, serializer):
|
||||||
|
serializer.save(owner=self.request.user)
|
||||||
|
|
||||||
|
|
||||||
|
class UserProfileView(generics.RetrieveAPIView):
|
||||||
|
serializer_class = UserSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
def get_object(self):
|
||||||
|
return self.request.user
|
||||||
|
|
||||||
|
|
||||||
|
class UserLinksListView(generics.ListAPIView):
|
||||||
|
serializer_class = LinkSerializer
|
||||||
|
permission_classes = [permissions.AllowAny]
|
||||||
|
def get_queryset(self):
|
||||||
|
username = self.kwargs['username']
|
||||||
|
return Link.objects.filter(owner__username=username).order_by('order')
|
||||||
|
|
||||||
|
|
||||||
|
class PublicUserGroupsView(APIView):
|
||||||
|
"""
|
||||||
|
GET /api/users/{username}/public/
|
||||||
|
"""
|
||||||
|
permission_classes = [permissions.AllowAny]
|
||||||
|
|
||||||
|
def get(self, request, username):
|
||||||
|
# 1. Ищем пользователя
|
||||||
|
user = get_object_or_404(User, username=username)
|
||||||
|
|
||||||
|
# 2. Берём его группы со ссылками
|
||||||
|
groups_qs = LinkGroup.objects.filter(owner=user).prefetch_related('links')
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"username": user.username,
|
||||||
|
"groups": []
|
||||||
|
}
|
||||||
|
|
||||||
|
for grp in groups_qs:
|
||||||
|
# icon у группы (абсолютный URL)
|
||||||
|
grp_icon_url = None
|
||||||
|
if grp.icon:
|
||||||
|
grp_icon_url = request.build_absolute_uri(grp.icon.url)
|
||||||
|
|
||||||
|
grp_data = {
|
||||||
|
"id": grp.id,
|
||||||
|
"name": grp.name,
|
||||||
|
"icon": grp_icon_url,
|
||||||
|
"links": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
for ln in grp.links.all():
|
||||||
|
# icon у ссылки
|
||||||
|
ln_icon_url = None
|
||||||
|
if ln.icon:
|
||||||
|
ln_icon_url = request.build_absolute_uri(ln.icon.url)
|
||||||
|
|
||||||
|
grp_data["links"].append({
|
||||||
|
"id": ln.id,
|
||||||
|
"title": ln.title,
|
||||||
|
"url": ln.url,
|
||||||
|
"icon": ln_icon_url,
|
||||||
|
})
|
||||||
|
|
||||||
|
result["groups"].append(grp_data)
|
||||||
|
|
||||||
|
return Response(result, status=status.HTTP_200_OK)
|
||||||
164
.history/backend/backend/settings_20250507080801.py
Normal file
164
.history/backend/backend/settings_20250507080801.py
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
"""
|
||||||
|
Django settings for backend project.
|
||||||
|
|
||||||
|
Generated by 'django-admin startproject' using Django 5.2.
|
||||||
|
|
||||||
|
For more information on this file, see
|
||||||
|
https://docs.djangoproject.com/en/5.2/topics/settings/
|
||||||
|
|
||||||
|
For the full list of settings and their values, see
|
||||||
|
https://docs.djangoproject.com/en/5.2/ref/settings/
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
import os
|
||||||
|
# Load environment variables from .env file
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
|
||||||
|
# Quick-start development settings - unsuitable for production
|
||||||
|
# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/
|
||||||
|
|
||||||
|
# SECURITY WARNING: keep the secret key used in production secret!
|
||||||
|
SECRET_KEY = os.getenv('DJANGO_SECRET_KEY')
|
||||||
|
|
||||||
|
# SECURITY WARNING: don't run with debug turned on in production!
|
||||||
|
DEBUG = os.getenv('DJANGO_DEBUG', 'False') == 'True'
|
||||||
|
|
||||||
|
ALLOWED_HOSTS = os.getenv('DJANGO_ALLOWED_HOSTS', '127.0.0.1').split(',')
|
||||||
|
|
||||||
|
CORS_ALLOWED_ORIGINS = [
|
||||||
|
"http://127.0.0.1:3001",
|
||||||
|
"http://localhost:3001",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Application definition
|
||||||
|
|
||||||
|
INSTALLED_APPS = [
|
||||||
|
"corsheaders",
|
||||||
|
'django.contrib.admin',
|
||||||
|
'django.contrib.auth',
|
||||||
|
'django.contrib.contenttypes',
|
||||||
|
'django.contrib.sessions',
|
||||||
|
'django.contrib.messages',
|
||||||
|
'django.contrib.staticfiles',
|
||||||
|
'users',
|
||||||
|
'links',
|
||||||
|
'customization',
|
||||||
|
'api',
|
||||||
|
'rest_framework',
|
||||||
|
'rest_framework_simplejwt',
|
||||||
|
'drf_spectacular',
|
||||||
|
'drf_spectacular_sidecar',
|
||||||
|
]
|
||||||
|
|
||||||
|
MIDDLEWARE = [
|
||||||
|
"corsheaders.middleware.CorsMiddleware",
|
||||||
|
'django.middleware.security.SecurityMiddleware',
|
||||||
|
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||||
|
'django.middleware.common.CommonMiddleware',
|
||||||
|
'django.middleware.csrf.CsrfViewMiddleware',
|
||||||
|
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||||
|
'django.contrib.messages.middleware.MessageMiddleware',
|
||||||
|
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||||
|
]
|
||||||
|
|
||||||
|
ROOT_URLCONF = 'backend.urls'
|
||||||
|
|
||||||
|
TEMPLATES = [
|
||||||
|
{
|
||||||
|
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||||
|
'DIRS': [],
|
||||||
|
'APP_DIRS': True,
|
||||||
|
'OPTIONS': {
|
||||||
|
'context_processors': [
|
||||||
|
'django.template.context_processors.request',
|
||||||
|
'django.contrib.auth.context_processors.auth',
|
||||||
|
'django.contrib.messages.context_processors.messages',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
WSGI_APPLICATION = 'backend.wsgi.application'
|
||||||
|
|
||||||
|
REST_FRAMEWORK = {
|
||||||
|
'DEFAULT_AUTHENTICATION_CLASSES': (
|
||||||
|
'rest_framework_simplejwt.authentication.JWTAuthentication',
|
||||||
|
),
|
||||||
|
'DEFAULT_PERMISSION_CLASSES': (
|
||||||
|
'rest_framework.permissions.IsAuthenticatedOrReadOnly',
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
SIMPLE_JWT = {
|
||||||
|
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=60),
|
||||||
|
'REFRESH_TOKEN_LIFETIME': timedelta(days=1),
|
||||||
|
'AUTH_HEADER_TYPES': ('Bearer',),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Database
|
||||||
|
# https://docs.djangoproject.com/en/5.2/ref/settings/#databases
|
||||||
|
|
||||||
|
DATABASES = {
|
||||||
|
'default': {
|
||||||
|
'ENGINE': os.getenv('DATABASE_ENGINE', 'django.db.backends.postgresql'),
|
||||||
|
'NAME': os.getenv('DATABASE_NAME'),
|
||||||
|
'USER': os.getenv('DATABASE_USER'),
|
||||||
|
'PASSWORD': os.getenv('DATABASE_PASSWORD'),
|
||||||
|
'HOST': os.getenv('DATABASE_HOST'),
|
||||||
|
'PORT': os.getenv('DATABASE_PORT'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Password validation
|
||||||
|
# https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators
|
||||||
|
|
||||||
|
AUTH_USER_MODEL = 'users.User'
|
||||||
|
|
||||||
|
AUTH_PASSWORD_VALIDATORS = [
|
||||||
|
{
|
||||||
|
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# Internationalization
|
||||||
|
# https://docs.djangoproject.com/en/5.2/topics/i18n/
|
||||||
|
|
||||||
|
LANGUAGE_CODE = 'ru-ru'
|
||||||
|
|
||||||
|
TIME_ZONE = 'UTC'
|
||||||
|
|
||||||
|
USE_I18N = True
|
||||||
|
|
||||||
|
USE_TZ = True
|
||||||
|
|
||||||
|
|
||||||
|
# Static files (CSS, JavaScript, Images)
|
||||||
|
# https://docs.djangoproject.com/en/5.2/howto/static-files/
|
||||||
|
|
||||||
|
STATIC_URL = 'static/'
|
||||||
|
|
||||||
|
# Default primary key field type
|
||||||
|
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
|
||||||
|
|
||||||
|
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||||
|
|
||||||
|
MEDIA_URL = '/storage/'
|
||||||
|
MEDIA_ROOT = BASE_DIR / 'storage'
|
||||||
165
.history/backend/backend/settings_20250507080850.py
Normal file
165
.history/backend/backend/settings_20250507080850.py
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
"""
|
||||||
|
Django settings for backend project.
|
||||||
|
|
||||||
|
Generated by 'django-admin startproject' using Django 5.2.
|
||||||
|
|
||||||
|
For more information on this file, see
|
||||||
|
https://docs.djangoproject.com/en/5.2/topics/settings/
|
||||||
|
|
||||||
|
For the full list of settings and their values, see
|
||||||
|
https://docs.djangoproject.com/en/5.2/ref/settings/
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
import os
|
||||||
|
# Load environment variables from .env file
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
|
||||||
|
# Quick-start development settings - unsuitable for production
|
||||||
|
# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/
|
||||||
|
|
||||||
|
# SECURITY WARNING: keep the secret key used in production secret!
|
||||||
|
SECRET_KEY = os.getenv('DJANGO_SECRET_KEY')
|
||||||
|
|
||||||
|
# SECURITY WARNING: don't run with debug turned on in production!
|
||||||
|
DEBUG = os.getenv('DJANGO_DEBUG', 'False') == 'True'
|
||||||
|
|
||||||
|
ALLOWED_HOSTS = os.getenv('DJANGO_ALLOWED_HOSTS', '127.0.0.1').split(',')
|
||||||
|
|
||||||
|
CORS_ALLOWED_ORIGINS = [
|
||||||
|
"http://127.0.0.1:3001",
|
||||||
|
"http://localhost:3001",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Application definition
|
||||||
|
|
||||||
|
INSTALLED_APPS = [
|
||||||
|
"corsheaders",
|
||||||
|
'django.contrib.admin',
|
||||||
|
'django.contrib.auth',
|
||||||
|
'django.contrib.contenttypes',
|
||||||
|
'django.contrib.sessions',
|
||||||
|
'django.contrib.messages',
|
||||||
|
'django.contrib.staticfiles',
|
||||||
|
'users',
|
||||||
|
'links',
|
||||||
|
'customization',
|
||||||
|
'api',
|
||||||
|
'rest_framework',
|
||||||
|
'rest_framework_simplejwt',
|
||||||
|
'drf_spectacular',
|
||||||
|
'drf_spectacular_sidecar',
|
||||||
|
'django_extensions',
|
||||||
|
]
|
||||||
|
|
||||||
|
MIDDLEWARE = [
|
||||||
|
"corsheaders.middleware.CorsMiddleware",
|
||||||
|
'django.middleware.security.SecurityMiddleware',
|
||||||
|
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||||
|
'django.middleware.common.CommonMiddleware',
|
||||||
|
'django.middleware.csrf.CsrfViewMiddleware',
|
||||||
|
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||||
|
'django.contrib.messages.middleware.MessageMiddleware',
|
||||||
|
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||||
|
]
|
||||||
|
|
||||||
|
ROOT_URLCONF = 'backend.urls'
|
||||||
|
|
||||||
|
TEMPLATES = [
|
||||||
|
{
|
||||||
|
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||||
|
'DIRS': [],
|
||||||
|
'APP_DIRS': True,
|
||||||
|
'OPTIONS': {
|
||||||
|
'context_processors': [
|
||||||
|
'django.template.context_processors.request',
|
||||||
|
'django.contrib.auth.context_processors.auth',
|
||||||
|
'django.contrib.messages.context_processors.messages',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
WSGI_APPLICATION = 'backend.wsgi.application'
|
||||||
|
|
||||||
|
REST_FRAMEWORK = {
|
||||||
|
'DEFAULT_AUTHENTICATION_CLASSES': (
|
||||||
|
'rest_framework_simplejwt.authentication.JWTAuthentication',
|
||||||
|
),
|
||||||
|
'DEFAULT_PERMISSION_CLASSES': (
|
||||||
|
'rest_framework.permissions.IsAuthenticatedOrReadOnly',
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
SIMPLE_JWT = {
|
||||||
|
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=60),
|
||||||
|
'REFRESH_TOKEN_LIFETIME': timedelta(days=1),
|
||||||
|
'AUTH_HEADER_TYPES': ('Bearer',),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Database
|
||||||
|
# https://docs.djangoproject.com/en/5.2/ref/settings/#databases
|
||||||
|
|
||||||
|
DATABASES = {
|
||||||
|
'default': {
|
||||||
|
'ENGINE': os.getenv('DATABASE_ENGINE', 'django.db.backends.postgresql'),
|
||||||
|
'NAME': os.getenv('DATABASE_NAME'),
|
||||||
|
'USER': os.getenv('DATABASE_USER'),
|
||||||
|
'PASSWORD': os.getenv('DATABASE_PASSWORD'),
|
||||||
|
'HOST': os.getenv('DATABASE_HOST'),
|
||||||
|
'PORT': os.getenv('DATABASE_PORT'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Password validation
|
||||||
|
# https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators
|
||||||
|
|
||||||
|
AUTH_USER_MODEL = 'users.User'
|
||||||
|
|
||||||
|
AUTH_PASSWORD_VALIDATORS = [
|
||||||
|
{
|
||||||
|
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# Internationalization
|
||||||
|
# https://docs.djangoproject.com/en/5.2/topics/i18n/
|
||||||
|
|
||||||
|
LANGUAGE_CODE = 'ru-ru'
|
||||||
|
|
||||||
|
TIME_ZONE = 'UTC'
|
||||||
|
|
||||||
|
USE_I18N = True
|
||||||
|
|
||||||
|
USE_TZ = True
|
||||||
|
|
||||||
|
|
||||||
|
# Static files (CSS, JavaScript, Images)
|
||||||
|
# https://docs.djangoproject.com/en/5.2/howto/static-files/
|
||||||
|
|
||||||
|
STATIC_URL = 'static/'
|
||||||
|
|
||||||
|
# Default primary key field type
|
||||||
|
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
|
||||||
|
|
||||||
|
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||||
|
|
||||||
|
MEDIA_URL = '/storage/'
|
||||||
|
MEDIA_ROOT = BASE_DIR / 'storage'
|
||||||
165
.history/backend/backend/settings_20250507081134.py
Normal file
165
.history/backend/backend/settings_20250507081134.py
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
"""
|
||||||
|
Django settings for backend project.
|
||||||
|
|
||||||
|
Generated by 'django-admin startproject' using Django 5.2.
|
||||||
|
|
||||||
|
For more information on this file, see
|
||||||
|
https://docs.djangoproject.com/en/5.2/topics/settings/
|
||||||
|
|
||||||
|
For the full list of settings and their values, see
|
||||||
|
https://docs.djangoproject.com/en/5.2/ref/settings/
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
import os
|
||||||
|
# Load environment variables from .env file
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
|
||||||
|
# Quick-start development settings - unsuitable for production
|
||||||
|
# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/
|
||||||
|
|
||||||
|
# SECURITY WARNING: keep the secret key used in production secret!
|
||||||
|
SECRET_KEY = os.getenv('DJANGO_SECRET_KEY')
|
||||||
|
|
||||||
|
# SECURITY WARNING: don't run with debug turned on in production!
|
||||||
|
DEBUG = os.getenv('DJANGO_DEBUG', 'False') == 'True'
|
||||||
|
|
||||||
|
ALLOWED_HOSTS = os.getenv('DJANGO_ALLOWED_HOSTS', '127.0.0.1').split(',')
|
||||||
|
|
||||||
|
CORS_ALLOWED_ORIGINS = [
|
||||||
|
"http://127.0.0.1:3001",
|
||||||
|
"http://localhost:3001",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Application definition
|
||||||
|
|
||||||
|
INSTALLED_APPS = [
|
||||||
|
"corsheaders",
|
||||||
|
'django.contrib.admin',
|
||||||
|
'django.contrib.auth',
|
||||||
|
'django.contrib.contenttypes',
|
||||||
|
'django.contrib.sessions',
|
||||||
|
'django.contrib.messages',
|
||||||
|
'django.contrib.staticfiles',
|
||||||
|
'users',
|
||||||
|
'links',
|
||||||
|
'customization',
|
||||||
|
'api',
|
||||||
|
'rest_framework',
|
||||||
|
'rest_framework_simplejwt',
|
||||||
|
'drf_spectacular',
|
||||||
|
"drf_spectacular_sidecar",
|
||||||
|
'django_extensions',
|
||||||
|
]
|
||||||
|
|
||||||
|
MIDDLEWARE = [
|
||||||
|
"corsheaders.middleware.CorsMiddleware",
|
||||||
|
'django.middleware.security.SecurityMiddleware',
|
||||||
|
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||||
|
'django.middleware.common.CommonMiddleware',
|
||||||
|
'django.middleware.csrf.CsrfViewMiddleware',
|
||||||
|
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||||
|
'django.contrib.messages.middleware.MessageMiddleware',
|
||||||
|
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||||
|
]
|
||||||
|
|
||||||
|
ROOT_URLCONF = 'backend.urls'
|
||||||
|
|
||||||
|
TEMPLATES = [
|
||||||
|
{
|
||||||
|
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||||
|
'DIRS': [],
|
||||||
|
'APP_DIRS': True,
|
||||||
|
'OPTIONS': {
|
||||||
|
'context_processors': [
|
||||||
|
'django.template.context_processors.request',
|
||||||
|
'django.contrib.auth.context_processors.auth',
|
||||||
|
'django.contrib.messages.context_processors.messages',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
WSGI_APPLICATION = 'backend.wsgi.application'
|
||||||
|
|
||||||
|
REST_FRAMEWORK = {
|
||||||
|
'DEFAULT_AUTHENTICATION_CLASSES': (
|
||||||
|
'rest_framework_simplejwt.authentication.JWTAuthentication',
|
||||||
|
),
|
||||||
|
'DEFAULT_PERMISSION_CLASSES': (
|
||||||
|
'rest_framework.permissions.IsAuthenticatedOrReadOnly',
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
SIMPLE_JWT = {
|
||||||
|
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=60),
|
||||||
|
'REFRESH_TOKEN_LIFETIME': timedelta(days=1),
|
||||||
|
'AUTH_HEADER_TYPES': ('Bearer',),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Database
|
||||||
|
# https://docs.djangoproject.com/en/5.2/ref/settings/#databases
|
||||||
|
|
||||||
|
DATABASES = {
|
||||||
|
'default': {
|
||||||
|
'ENGINE': os.getenv('DATABASE_ENGINE', 'django.db.backends.postgresql'),
|
||||||
|
'NAME': os.getenv('DATABASE_NAME'),
|
||||||
|
'USER': os.getenv('DATABASE_USER'),
|
||||||
|
'PASSWORD': os.getenv('DATABASE_PASSWORD'),
|
||||||
|
'HOST': os.getenv('DATABASE_HOST'),
|
||||||
|
'PORT': os.getenv('DATABASE_PORT'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Password validation
|
||||||
|
# https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators
|
||||||
|
|
||||||
|
AUTH_USER_MODEL = 'users.User'
|
||||||
|
|
||||||
|
AUTH_PASSWORD_VALIDATORS = [
|
||||||
|
{
|
||||||
|
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# Internationalization
|
||||||
|
# https://docs.djangoproject.com/en/5.2/topics/i18n/
|
||||||
|
|
||||||
|
LANGUAGE_CODE = 'ru-ru'
|
||||||
|
|
||||||
|
TIME_ZONE = 'UTC'
|
||||||
|
|
||||||
|
USE_I18N = True
|
||||||
|
|
||||||
|
USE_TZ = True
|
||||||
|
|
||||||
|
|
||||||
|
# Static files (CSS, JavaScript, Images)
|
||||||
|
# https://docs.djangoproject.com/en/5.2/howto/static-files/
|
||||||
|
|
||||||
|
STATIC_URL = 'static/'
|
||||||
|
|
||||||
|
# Default primary key field type
|
||||||
|
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
|
||||||
|
|
||||||
|
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||||
|
|
||||||
|
MEDIA_URL = '/storage/'
|
||||||
|
MEDIA_ROOT = BASE_DIR / 'storage'
|
||||||
165
.history/backend/backend/settings_20250507081237.py
Normal file
165
.history/backend/backend/settings_20250507081237.py
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
"""
|
||||||
|
Django settings for backend project.
|
||||||
|
|
||||||
|
Generated by 'django-admin startproject' using Django 5.2.
|
||||||
|
|
||||||
|
For more information on this file, see
|
||||||
|
https://docs.djangoproject.com/en/5.2/topics/settings/
|
||||||
|
|
||||||
|
For the full list of settings and their values, see
|
||||||
|
https://docs.djangoproject.com/en/5.2/ref/settings/
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
import os
|
||||||
|
# Load environment variables from .env file
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
|
||||||
|
# Quick-start development settings - unsuitable for production
|
||||||
|
# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/
|
||||||
|
|
||||||
|
# SECURITY WARNING: keep the secret key used in production secret!
|
||||||
|
SECRET_KEY = os.getenv('DJANGO_SECRET_KEY')
|
||||||
|
|
||||||
|
# SECURITY WARNING: don't run with debug turned on in production!
|
||||||
|
DEBUG = os.getenv('DJANGO_DEBUG', 'False') == 'True'
|
||||||
|
|
||||||
|
ALLOWED_HOSTS = os.getenv('DJANGO_ALLOWED_HOSTS', '127.0.0.1').split(',')
|
||||||
|
|
||||||
|
CORS_ALLOWED_ORIGINS = [
|
||||||
|
"http://127.0.0.1:3001",
|
||||||
|
"http://localhost:3001",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Application definition
|
||||||
|
|
||||||
|
INSTALLED_APPS = [
|
||||||
|
"corsheaders",
|
||||||
|
'drf_spectacular',
|
||||||
|
"drf_spectacular_sidecar",
|
||||||
|
'django.contrib.admin',
|
||||||
|
'django.contrib.auth',
|
||||||
|
'django.contrib.contenttypes',
|
||||||
|
'django.contrib.sessions',
|
||||||
|
'django.contrib.messages',
|
||||||
|
'django.contrib.staticfiles',
|
||||||
|
'users',
|
||||||
|
'links',
|
||||||
|
'customization',
|
||||||
|
'api',
|
||||||
|
'rest_framework',
|
||||||
|
'rest_framework_simplejwt',
|
||||||
|
'django_extensions',
|
||||||
|
]
|
||||||
|
|
||||||
|
MIDDLEWARE = [
|
||||||
|
"corsheaders.middleware.CorsMiddleware",
|
||||||
|
'django.middleware.security.SecurityMiddleware',
|
||||||
|
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||||
|
'django.middleware.common.CommonMiddleware',
|
||||||
|
'django.middleware.csrf.CsrfViewMiddleware',
|
||||||
|
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||||
|
'django.contrib.messages.middleware.MessageMiddleware',
|
||||||
|
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||||
|
]
|
||||||
|
|
||||||
|
ROOT_URLCONF = 'backend.urls'
|
||||||
|
|
||||||
|
TEMPLATES = [
|
||||||
|
{
|
||||||
|
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||||
|
'DIRS': [],
|
||||||
|
'APP_DIRS': True,
|
||||||
|
'OPTIONS': {
|
||||||
|
'context_processors': [
|
||||||
|
'django.template.context_processors.request',
|
||||||
|
'django.contrib.auth.context_processors.auth',
|
||||||
|
'django.contrib.messages.context_processors.messages',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
WSGI_APPLICATION = 'backend.wsgi.application'
|
||||||
|
|
||||||
|
REST_FRAMEWORK = {
|
||||||
|
'DEFAULT_AUTHENTICATION_CLASSES': (
|
||||||
|
'rest_framework_simplejwt.authentication.JWTAuthentication',
|
||||||
|
),
|
||||||
|
'DEFAULT_PERMISSION_CLASSES': (
|
||||||
|
'rest_framework.permissions.IsAuthenticatedOrReadOnly',
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
SIMPLE_JWT = {
|
||||||
|
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=60),
|
||||||
|
'REFRESH_TOKEN_LIFETIME': timedelta(days=1),
|
||||||
|
'AUTH_HEADER_TYPES': ('Bearer',),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Database
|
||||||
|
# https://docs.djangoproject.com/en/5.2/ref/settings/#databases
|
||||||
|
|
||||||
|
DATABASES = {
|
||||||
|
'default': {
|
||||||
|
'ENGINE': os.getenv('DATABASE_ENGINE', 'django.db.backends.postgresql'),
|
||||||
|
'NAME': os.getenv('DATABASE_NAME'),
|
||||||
|
'USER': os.getenv('DATABASE_USER'),
|
||||||
|
'PASSWORD': os.getenv('DATABASE_PASSWORD'),
|
||||||
|
'HOST': os.getenv('DATABASE_HOST'),
|
||||||
|
'PORT': os.getenv('DATABASE_PORT'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Password validation
|
||||||
|
# https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators
|
||||||
|
|
||||||
|
AUTH_USER_MODEL = 'users.User'
|
||||||
|
|
||||||
|
AUTH_PASSWORD_VALIDATORS = [
|
||||||
|
{
|
||||||
|
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# Internationalization
|
||||||
|
# https://docs.djangoproject.com/en/5.2/topics/i18n/
|
||||||
|
|
||||||
|
LANGUAGE_CODE = 'ru-ru'
|
||||||
|
|
||||||
|
TIME_ZONE = 'UTC'
|
||||||
|
|
||||||
|
USE_I18N = True
|
||||||
|
|
||||||
|
USE_TZ = True
|
||||||
|
|
||||||
|
|
||||||
|
# Static files (CSS, JavaScript, Images)
|
||||||
|
# https://docs.djangoproject.com/en/5.2/howto/static-files/
|
||||||
|
|
||||||
|
STATIC_URL = 'static/'
|
||||||
|
|
||||||
|
# Default primary key field type
|
||||||
|
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
|
||||||
|
|
||||||
|
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||||
|
|
||||||
|
MEDIA_URL = '/storage/'
|
||||||
|
MEDIA_ROOT = BASE_DIR / 'storage'
|
||||||
166
.history/backend/backend/settings_20250507082006.py
Normal file
166
.history/backend/backend/settings_20250507082006.py
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
"""
|
||||||
|
Django settings for backend project.
|
||||||
|
|
||||||
|
Generated by 'django-admin startproject' using Django 5.2.
|
||||||
|
|
||||||
|
For more information on this file, see
|
||||||
|
https://docs.djangoproject.com/en/5.2/topics/settings/
|
||||||
|
|
||||||
|
For the full list of settings and their values, see
|
||||||
|
https://docs.djangoproject.com/en/5.2/ref/settings/
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
import os
|
||||||
|
# Load environment variables from .env file
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
|
||||||
|
# Quick-start development settings - unsuitable for production
|
||||||
|
# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/
|
||||||
|
|
||||||
|
# SECURITY WARNING: keep the secret key used in production secret!
|
||||||
|
SECRET_KEY = os.getenv('DJANGO_SECRET_KEY')
|
||||||
|
|
||||||
|
# SECURITY WARNING: don't run with debug turned on in production!
|
||||||
|
DEBUG = os.getenv('DJANGO_DEBUG', 'False') == 'True'
|
||||||
|
|
||||||
|
ALLOWED_HOSTS = os.getenv('DJANGO_ALLOWED_HOSTS', '127.0.0.1').split(',')
|
||||||
|
|
||||||
|
CORS_ALLOWED_ORIGINS = [
|
||||||
|
"http://127.0.0.1:3001",
|
||||||
|
"http://localhost:3001",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Application definition
|
||||||
|
|
||||||
|
INSTALLED_APPS = [
|
||||||
|
"corsheaders",
|
||||||
|
'drf_spectacular',
|
||||||
|
"drf_spectacular_sidecar",
|
||||||
|
'django.contrib.admin',
|
||||||
|
'django.contrib.auth',
|
||||||
|
'django.contrib.contenttypes',
|
||||||
|
'django.contrib.sessions',
|
||||||
|
'django.contrib.messages',
|
||||||
|
'django.contrib.staticfiles',
|
||||||
|
'users',
|
||||||
|
'links',
|
||||||
|
'customization',
|
||||||
|
'api',
|
||||||
|
'rest_framework',
|
||||||
|
'rest_framework_simplejwt',
|
||||||
|
'django_extensions',
|
||||||
|
]
|
||||||
|
|
||||||
|
MIDDLEWARE = [
|
||||||
|
"corsheaders.middleware.CorsMiddleware",
|
||||||
|
'django.middleware.security.SecurityMiddleware',
|
||||||
|
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||||
|
'django.middleware.common.CommonMiddleware',
|
||||||
|
'django.middleware.csrf.CsrfViewMiddleware',
|
||||||
|
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||||
|
'django.contrib.messages.middleware.MessageMiddleware',
|
||||||
|
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||||
|
]
|
||||||
|
|
||||||
|
ROOT_URLCONF = 'backend.urls'
|
||||||
|
|
||||||
|
TEMPLATES = [
|
||||||
|
{
|
||||||
|
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||||
|
'DIRS': [],
|
||||||
|
'APP_DIRS': True,
|
||||||
|
'OPTIONS': {
|
||||||
|
'context_processors': [
|
||||||
|
'django.template.context_processors.request',
|
||||||
|
'django.contrib.auth.context_processors.auth',
|
||||||
|
'django.contrib.messages.context_processors.messages',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
WSGI_APPLICATION = 'backend.wsgi.application'
|
||||||
|
|
||||||
|
REST_FRAMEWORK = {
|
||||||
|
'DEFAULT_AUTHENTICATION_CLASSES': (
|
||||||
|
'rest_framework_simplejwt.authentication.JWTAuthentication',
|
||||||
|
),
|
||||||
|
'DEFAULT_PERMISSION_CLASSES': (
|
||||||
|
'rest_framework.permissions.IsAuthenticatedOrReadOnly',
|
||||||
|
),
|
||||||
|
'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema',
|
||||||
|
}
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
SIMPLE_JWT = {
|
||||||
|
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=60),
|
||||||
|
'REFRESH_TOKEN_LIFETIME': timedelta(days=1),
|
||||||
|
'AUTH_HEADER_TYPES': ('Bearer',),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Database
|
||||||
|
# https://docs.djangoproject.com/en/5.2/ref/settings/#databases
|
||||||
|
|
||||||
|
DATABASES = {
|
||||||
|
'default': {
|
||||||
|
'ENGINE': os.getenv('DATABASE_ENGINE', 'django.db.backends.postgresql'),
|
||||||
|
'NAME': os.getenv('DATABASE_NAME'),
|
||||||
|
'USER': os.getenv('DATABASE_USER'),
|
||||||
|
'PASSWORD': os.getenv('DATABASE_PASSWORD'),
|
||||||
|
'HOST': os.getenv('DATABASE_HOST'),
|
||||||
|
'PORT': os.getenv('DATABASE_PORT'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Password validation
|
||||||
|
# https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators
|
||||||
|
|
||||||
|
AUTH_USER_MODEL = 'users.User'
|
||||||
|
|
||||||
|
AUTH_PASSWORD_VALIDATORS = [
|
||||||
|
{
|
||||||
|
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# Internationalization
|
||||||
|
# https://docs.djangoproject.com/en/5.2/topics/i18n/
|
||||||
|
|
||||||
|
LANGUAGE_CODE = 'ru-ru'
|
||||||
|
|
||||||
|
TIME_ZONE = 'UTC'
|
||||||
|
|
||||||
|
USE_I18N = True
|
||||||
|
|
||||||
|
USE_TZ = True
|
||||||
|
|
||||||
|
|
||||||
|
# Static files (CSS, JavaScript, Images)
|
||||||
|
# https://docs.djangoproject.com/en/5.2/howto/static-files/
|
||||||
|
|
||||||
|
STATIC_URL = 'static/'
|
||||||
|
|
||||||
|
# Default primary key field type
|
||||||
|
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
|
||||||
|
|
||||||
|
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||||
|
|
||||||
|
MEDIA_URL = '/storage/'
|
||||||
|
MEDIA_ROOT = BASE_DIR / 'storage'
|
||||||
168
.history/backend/backend/settings_20250507082028.py
Normal file
168
.history/backend/backend/settings_20250507082028.py
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
"""
|
||||||
|
Django settings for backend project.
|
||||||
|
|
||||||
|
Generated by 'django-admin startproject' using Django 5.2.
|
||||||
|
|
||||||
|
For more information on this file, see
|
||||||
|
https://docs.djangoproject.com/en/5.2/topics/settings/
|
||||||
|
|
||||||
|
For the full list of settings and their values, see
|
||||||
|
https://docs.djangoproject.com/en/5.2/ref/settings/
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
import os
|
||||||
|
# Load environment variables from .env file
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
|
||||||
|
# Quick-start development settings - unsuitable for production
|
||||||
|
# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/
|
||||||
|
|
||||||
|
# SECURITY WARNING: keep the secret key used in production secret!
|
||||||
|
SECRET_KEY = os.getenv('DJANGO_SECRET_KEY')
|
||||||
|
|
||||||
|
# SECURITY WARNING: don't run with debug turned on in production!
|
||||||
|
DEBUG = os.getenv('DJANGO_DEBUG', 'False') == 'True'
|
||||||
|
|
||||||
|
ALLOWED_HOSTS = os.getenv('DJANGO_ALLOWED_HOSTS', '127.0.0.1').split(',')
|
||||||
|
|
||||||
|
CORS_ALLOWED_ORIGINS = [
|
||||||
|
"http://127.0.0.1:3001",
|
||||||
|
"http://localhost:3001",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Application definition
|
||||||
|
|
||||||
|
INSTALLED_APPS = [
|
||||||
|
"corsheaders",
|
||||||
|
'drf_spectacular',
|
||||||
|
"drf_spectacular_sidecar",
|
||||||
|
'django.contrib.admin',
|
||||||
|
'django.contrib.auth',
|
||||||
|
'django.contrib.contenttypes',
|
||||||
|
'django.contrib.sessions',
|
||||||
|
'django.contrib.messages',
|
||||||
|
'django.contrib.staticfiles',
|
||||||
|
'users',
|
||||||
|
'links',
|
||||||
|
'customization',
|
||||||
|
'api',
|
||||||
|
'rest_framework',
|
||||||
|
'rest_framework_simplejwt',
|
||||||
|
'django_extensions',
|
||||||
|
]
|
||||||
|
|
||||||
|
MIDDLEWARE = [
|
||||||
|
"corsheaders.middleware.CorsMiddleware",
|
||||||
|
'django.middleware.security.SecurityMiddleware',
|
||||||
|
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||||
|
'django.middleware.common.CommonMiddleware',
|
||||||
|
'django.middleware.csrf.CsrfViewMiddleware',
|
||||||
|
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||||
|
'django.contrib.messages.middleware.MessageMiddleware',
|
||||||
|
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||||
|
]
|
||||||
|
|
||||||
|
ROOT_URLCONF = 'backend.urls'
|
||||||
|
|
||||||
|
TEMPLATES = [
|
||||||
|
{
|
||||||
|
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||||
|
'DIRS': [],
|
||||||
|
'APP_DIRS': True,
|
||||||
|
'OPTIONS': {
|
||||||
|
'context_processors': [
|
||||||
|
'django.template.context_processors.request',
|
||||||
|
'django.contrib.auth.context_processors.auth',
|
||||||
|
'django.contrib.messages.context_processors.messages',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
WSGI_APPLICATION = 'backend.wsgi.application'
|
||||||
|
|
||||||
|
REST_FRAMEWORK = {
|
||||||
|
'DEFAULT_AUTHENTICATION_CLASSES': (
|
||||||
|
'rest_framework_simplejwt.authentication.JWTAuthentication',
|
||||||
|
),
|
||||||
|
'DEFAULT_PERMISSION_CLASSES': (
|
||||||
|
'rest_framework.permissions.IsAuthenticatedOrReadOnly',
|
||||||
|
),
|
||||||
|
'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema',
|
||||||
|
}
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
SIMPLE_JWT = {
|
||||||
|
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=60),
|
||||||
|
'REFRESH_TOKEN_LIFETIME': timedelta(days=1),
|
||||||
|
'AUTH_HEADER_TYPES': ('Bearer',),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Database
|
||||||
|
# https://docs.djangoproject.com/en/5.2/ref/settings/#databases
|
||||||
|
|
||||||
|
DATABASES = {
|
||||||
|
'default': {
|
||||||
|
'ENGINE': os.getenv('DATABASE_ENGINE', 'django.db.backends.postgresql'),
|
||||||
|
'NAME': os.getenv('DATABASE_NAME'),
|
||||||
|
'USER': os.getenv('DATABASE_USER'),
|
||||||
|
'PASSWORD': os.getenv('DATABASE_PASSWORD'),
|
||||||
|
'HOST': os.getenv('DATABASE_HOST'),
|
||||||
|
'PORT': os.getenv('DATABASE_PORT'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Password validation
|
||||||
|
# https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators
|
||||||
|
|
||||||
|
AUTH_USER_MODEL = 'users.User'
|
||||||
|
|
||||||
|
AUTH_PASSWORD_VALIDATORS = [
|
||||||
|
{
|
||||||
|
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# Internationalization
|
||||||
|
# https://docs.djangoproject.com/en/5.2/topics/i18n/
|
||||||
|
|
||||||
|
LANGUAGE_CODE = 'ru-ru'
|
||||||
|
|
||||||
|
TIME_ZONE = 'UTC'
|
||||||
|
|
||||||
|
USE_I18N = True
|
||||||
|
|
||||||
|
USE_TZ = True
|
||||||
|
|
||||||
|
|
||||||
|
# Static files (CSS, JavaScript, Images)
|
||||||
|
# https://docs.djangoproject.com/en/5.2/howto/static-files/
|
||||||
|
|
||||||
|
STATIC_ROOT = BASE_DIR / 'staticfiles'
|
||||||
|
|
||||||
|
# URL, по которому статика будет доступна
|
||||||
|
STATIC_URL = '/static/'
|
||||||
|
# Default primary key field type
|
||||||
|
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
|
||||||
|
|
||||||
|
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||||
|
|
||||||
|
MEDIA_URL = '/storage/'
|
||||||
|
MEDIA_ROOT = BASE_DIR / 'storage'
|
||||||
60
.history/backend/backend/urls_20250507080600.py
Normal file
60
.history/backend/backend/urls_20250507080600.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
from django.urls import path, include
|
||||||
|
from django.conf import settings
|
||||||
|
from django.conf.urls.static import static
|
||||||
|
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('admin/', admin.site.urls),
|
||||||
|
path('api/', include('api.urls')), # API endpoints
|
||||||
|
path('users/', include('users.urls')), # User management app
|
||||||
|
path('links/', include('links.urls')), # Link management app
|
||||||
|
path('customization/', include('customization.urls')), # Design customization app
|
||||||
|
path('api-auth/', include('rest_framework.urls', namespace='rest_framework')),
|
||||||
|
]
|
||||||
|
|
||||||
|
if settings.DEBUG:
|
||||||
|
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||||
|
|
||||||
|
|
||||||
|
# Summary of API Endpoints:
|
||||||
|
# POST /api/auth/register/ - Register new user
|
||||||
|
# POST /api/auth/login/ - Obtain JWT tokens (access & refresh)
|
||||||
|
# GET /api/users/<username>/links/ - Public list of user's links
|
||||||
|
# GET /api/links/ - List authenticated user's links
|
||||||
|
# POST /api/links/ - Create a new link
|
||||||
|
# GET /api/links/{id}/ - Retrieve a specific link
|
||||||
|
# PUT /api/links/{id}/ - Update a specific link
|
||||||
|
# PATCH /api/links/{id}/ - Partially update a link
|
||||||
|
# DELETE /api/links/{id}/ - Delete a specific link
|
||||||
|
# GET /api/groups/ - List authenticated user's link groups
|
||||||
|
# POST /api/groups/ - Create a new link group
|
||||||
|
# GET /api/groups/{id}/ - Retrieve a specific link group
|
||||||
|
# PUT /api/groups/{id}/ - Update a link group
|
||||||
|
# PATCH /api/groups/{id}/ - Partially update a link group
|
||||||
|
# DELETE /api/groups/{id}/ - Delete a specific link group
|
||||||
|
|
||||||
|
# To avoid URL configuration errors, ensure the following placeholder URLConfs exist:
|
||||||
|
# users/urls.py
|
||||||
|
# ----------------
|
||||||
|
# from django.urls import path, include
|
||||||
|
#
|
||||||
|
# urlpatterns = [
|
||||||
|
# # Define user-management endpoints here (e.g., profile, settings)
|
||||||
|
# ]
|
||||||
|
|
||||||
|
# links/urls.py
|
||||||
|
# ----------------
|
||||||
|
# from django.urls import path, include
|
||||||
|
#
|
||||||
|
# urlpatterns = [
|
||||||
|
# # Define additional link-management endpoints here if needed
|
||||||
|
# ]
|
||||||
|
|
||||||
|
# customization/urls.py
|
||||||
|
# ----------------
|
||||||
|
# from django.urls import path, include
|
||||||
|
#
|
||||||
|
# urlpatterns = [
|
||||||
|
# # Define design customization endpoints here
|
||||||
|
# ]
|
||||||
60
.history/backend/backend/urls_20250507081420.py
Normal file
60
.history/backend/backend/urls_20250507081420.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
from django.urls import path, include
|
||||||
|
from django.conf import settings
|
||||||
|
from django.conf.urls.static import static
|
||||||
|
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('admin/', admin.site.urls),
|
||||||
|
path('api/', include('api.urls')), # API endpoints
|
||||||
|
path('users/', include('users.urls')), # User management app
|
||||||
|
path('links/', include('links.urls')), # Link management app
|
||||||
|
path('customization/', include('customization.urls')), # Design customization app
|
||||||
|
path('api-auth/', include('rest_framework.urls', namespace='rest_framework')),
|
||||||
|
]
|
||||||
|
|
||||||
|
if settings.DEBUG:
|
||||||
|
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||||
|
|
||||||
|
|
||||||
|
# Summary of API Endpoints:
|
||||||
|
# POST /api/auth/register/ - Register new user
|
||||||
|
# POST /api/auth/login/ - Obtain JWT tokens (access & refresh)
|
||||||
|
# GET /api/users/<username>/links/ - Public list of user's links
|
||||||
|
# GET /api/links/ - List authenticated user's links
|
||||||
|
# POST /api/links/ - Create a new link
|
||||||
|
# GET /api/links/{id}/ - Retrieve a specific link
|
||||||
|
# PUT /api/links/{id}/ - Update a specific link
|
||||||
|
# PATCH /api/links/{id}/ - Partially update a link
|
||||||
|
# DELETE /api/links/{id}/ - Delete a specific link
|
||||||
|
# GET /api/groups/ - List authenticated user's link groups
|
||||||
|
# POST /api/groups/ - Create a new link group
|
||||||
|
# GET /api/groups/{id}/ - Retrieve a specific link group
|
||||||
|
# PUT /api/groups/{id}/ - Update a link group
|
||||||
|
# PATCH /api/groups/{id}/ - Partially update a link group
|
||||||
|
# DELETE /api/groups/{id}/ - Delete a specific link group
|
||||||
|
|
||||||
|
# To avoid URL configuration errors, ensure the following placeholder URLConfs exist:
|
||||||
|
# users/urls.py
|
||||||
|
# ----------------
|
||||||
|
# from django.urls import path, include
|
||||||
|
#
|
||||||
|
# urlpatterns = [
|
||||||
|
# # Define user-management endpoints here (e.g., profile, settings)
|
||||||
|
# ]
|
||||||
|
|
||||||
|
# links/urls.py
|
||||||
|
# ----------------
|
||||||
|
# from django.urls import path, include
|
||||||
|
#
|
||||||
|
# urlpatterns = [
|
||||||
|
# # Define additional link-management endpoints here if needed
|
||||||
|
# ]
|
||||||
|
|
||||||
|
# customization/urls.py
|
||||||
|
# ----------------
|
||||||
|
# from django.urls import path, include
|
||||||
|
#
|
||||||
|
# urlpatterns = [
|
||||||
|
# # Define design customization endpoints here
|
||||||
|
# ]
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
// File: /home/trevor/links/frontend/linktree-frontend/src/app/dashboard/page.tsx
|
||||||
|
import * as entry from '../../../../src/app/dashboard/page.js'
|
||||||
|
import type { ResolvingMetadata, ResolvingViewport } from 'next/dist/lib/metadata/types/metadata-interface.js'
|
||||||
|
|
||||||
|
type TEntry = typeof import('../../../../src/app/dashboard/page.js')
|
||||||
|
|
||||||
|
type SegmentParams<T extends Object = any> = T extends Record<string, any>
|
||||||
|
? { [K in keyof T]: T[K] extends string ? string | string[] | undefined : never }
|
||||||
|
: T
|
||||||
|
|
||||||
|
// Check that the entry is a valid entry
|
||||||
|
checkFields<Diff<{
|
||||||
|
default: Function
|
||||||
|
config?: {}
|
||||||
|
generateStaticParams?: Function
|
||||||
|
revalidate?: RevalidateRange<TEntry> | false
|
||||||
|
dynamic?: 'auto' | 'force-dynamic' | 'error' | 'force-static'
|
||||||
|
dynamicParams?: boolean
|
||||||
|
fetchCache?: 'auto' | 'force-no-store' | 'only-no-store' | 'default-no-store' | 'default-cache' | 'only-cache' | 'force-cache'
|
||||||
|
preferredRegion?: 'auto' | 'global' | 'home' | string | string[]
|
||||||
|
runtime?: 'nodejs' | 'experimental-edge' | 'edge'
|
||||||
|
maxDuration?: number
|
||||||
|
|
||||||
|
metadata?: any
|
||||||
|
generateMetadata?: Function
|
||||||
|
viewport?: any
|
||||||
|
generateViewport?: Function
|
||||||
|
experimental_ppr?: boolean
|
||||||
|
|
||||||
|
}, TEntry, ''>>()
|
||||||
|
|
||||||
|
|
||||||
|
// Check the prop type of the entry function
|
||||||
|
checkFields<Diff<PageProps, FirstArg<TEntry['default']>, 'default'>>()
|
||||||
|
|
||||||
|
// Check the arguments and return type of the generateMetadata function
|
||||||
|
if ('generateMetadata' in entry) {
|
||||||
|
checkFields<Diff<PageProps, FirstArg<MaybeField<TEntry, 'generateMetadata'>>, 'generateMetadata'>>()
|
||||||
|
checkFields<Diff<ResolvingMetadata, SecondArg<MaybeField<TEntry, 'generateMetadata'>>, 'generateMetadata'>>()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check the arguments and return type of the generateViewport function
|
||||||
|
if ('generateViewport' in entry) {
|
||||||
|
checkFields<Diff<PageProps, FirstArg<MaybeField<TEntry, 'generateViewport'>>, 'generateViewport'>>()
|
||||||
|
checkFields<Diff<ResolvingViewport, SecondArg<MaybeField<TEntry, 'generateViewport'>>, 'generateViewport'>>()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check the arguments and return type of the generateStaticParams function
|
||||||
|
if ('generateStaticParams' in entry) {
|
||||||
|
checkFields<Diff<{ params: SegmentParams }, FirstArg<MaybeField<TEntry, 'generateStaticParams'>>, 'generateStaticParams'>>()
|
||||||
|
checkFields<Diff<{ __tag__: 'generateStaticParams', __return_type__: any[] | Promise<any[]> }, { __tag__: 'generateStaticParams', __return_type__: ReturnType<MaybeField<TEntry, 'generateStaticParams'>> }>>()
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PageProps {
|
||||||
|
params?: Promise<SegmentParams>
|
||||||
|
searchParams?: Promise<any>
|
||||||
|
}
|
||||||
|
export interface LayoutProps {
|
||||||
|
children?: React.ReactNode
|
||||||
|
|
||||||
|
params?: Promise<SegmentParams>
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============
|
||||||
|
// Utility types
|
||||||
|
type RevalidateRange<T> = T extends { revalidate: any } ? NonNegative<T['revalidate']> : never
|
||||||
|
|
||||||
|
// If T is unknown or any, it will be an empty {} type. Otherwise, it will be the same as Omit<T, keyof Base>.
|
||||||
|
type OmitWithTag<T, K extends keyof any, _M> = Omit<T, K>
|
||||||
|
type Diff<Base, T extends Base, Message extends string = ''> = 0 extends (1 & T) ? {} : OmitWithTag<T, keyof Base, Message>
|
||||||
|
|
||||||
|
type FirstArg<T extends Function> = T extends (...args: [infer T, any]) => any ? unknown extends T ? any : T : never
|
||||||
|
type SecondArg<T extends Function> = T extends (...args: [any, infer T]) => any ? unknown extends T ? any : T : never
|
||||||
|
type MaybeField<T, K extends string> = T extends { [k in K]: infer G } ? G extends Function ? G : never : never
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
function checkFields<_ extends { [k in keyof any]: never }>() {}
|
||||||
|
|
||||||
|
// https://github.com/sindresorhus/type-fest
|
||||||
|
type Numeric = number | bigint
|
||||||
|
type Zero = 0 | 0n
|
||||||
|
type Negative<T extends Numeric> = T extends Zero ? never : `${T}` extends `-${string}` ? T : never
|
||||||
|
type NonNegative<T extends Numeric> = T extends Zero ? T : Negative<T> extends never ? T : '__invalid_negative_number__'
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
// File: /home/trevor/links/frontend/linktree-frontend/src/app/dashboard/page.tsx
|
||||||
|
import * as entry from '../../../../src/app/(protected)/dashboard/page.jsx'
|
||||||
|
import type { ResolvingMetadata, ResolvingViewport } from 'next/dist/lib/metadata/types/metadata-interface.js'
|
||||||
|
|
||||||
|
type TEntry = typeof import('../../../../src/app/(protected)/dashboard/page.jsx')
|
||||||
|
|
||||||
|
type SegmentParams<T extends Object = any> = T extends Record<string, any>
|
||||||
|
? { [K in keyof T]: T[K] extends string ? string | string[] | undefined : never }
|
||||||
|
: T
|
||||||
|
|
||||||
|
// Check that the entry is a valid entry
|
||||||
|
checkFields<Diff<{
|
||||||
|
default: Function
|
||||||
|
config?: {}
|
||||||
|
generateStaticParams?: Function
|
||||||
|
revalidate?: RevalidateRange<TEntry> | false
|
||||||
|
dynamic?: 'auto' | 'force-dynamic' | 'error' | 'force-static'
|
||||||
|
dynamicParams?: boolean
|
||||||
|
fetchCache?: 'auto' | 'force-no-store' | 'only-no-store' | 'default-no-store' | 'default-cache' | 'only-cache' | 'force-cache'
|
||||||
|
preferredRegion?: 'auto' | 'global' | 'home' | string | string[]
|
||||||
|
runtime?: 'nodejs' | 'experimental-edge' | 'edge'
|
||||||
|
maxDuration?: number
|
||||||
|
|
||||||
|
metadata?: any
|
||||||
|
generateMetadata?: Function
|
||||||
|
viewport?: any
|
||||||
|
generateViewport?: Function
|
||||||
|
experimental_ppr?: boolean
|
||||||
|
|
||||||
|
}, TEntry, ''>>()
|
||||||
|
|
||||||
|
|
||||||
|
// Check the prop type of the entry function
|
||||||
|
checkFields<Diff<PageProps, FirstArg<TEntry['default']>, 'default'>>()
|
||||||
|
|
||||||
|
// Check the arguments and return type of the generateMetadata function
|
||||||
|
if ('generateMetadata' in entry) {
|
||||||
|
checkFields<Diff<PageProps, FirstArg<MaybeField<TEntry, 'generateMetadata'>>, 'generateMetadata'>>()
|
||||||
|
checkFields<Diff<ResolvingMetadata, SecondArg<MaybeField<TEntry, 'generateMetadata'>>, 'generateMetadata'>>()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check the arguments and return type of the generateViewport function
|
||||||
|
if ('generateViewport' in entry) {
|
||||||
|
checkFields<Diff<PageProps, FirstArg<MaybeField<TEntry, 'generateViewport'>>, 'generateViewport'>>()
|
||||||
|
checkFields<Diff<ResolvingViewport, SecondArg<MaybeField<TEntry, 'generateViewport'>>, 'generateViewport'>>()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check the arguments and return type of the generateStaticParams function
|
||||||
|
if ('generateStaticParams' in entry) {
|
||||||
|
checkFields<Diff<{ params: SegmentParams }, FirstArg<MaybeField<TEntry, 'generateStaticParams'>>, 'generateStaticParams'>>()
|
||||||
|
checkFields<Diff<{ __tag__: 'generateStaticParams', __return_type__: any[] | Promise<any[]> }, { __tag__: 'generateStaticParams', __return_type__: ReturnType<MaybeField<TEntry, 'generateStaticParams'>> }>>()
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PageProps {
|
||||||
|
params?: Promise<SegmentParams>
|
||||||
|
searchParams?: Promise<any>
|
||||||
|
}
|
||||||
|
export interface LayoutProps {
|
||||||
|
children?: React.ReactNode
|
||||||
|
|
||||||
|
params?: Promise<SegmentParams>
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============
|
||||||
|
// Utility types
|
||||||
|
type RevalidateRange<T> = T extends { revalidate: any } ? NonNegative<T['revalidate']> : never
|
||||||
|
|
||||||
|
// If T is unknown or any, it will be an empty {} type. Otherwise, it will be the same as Omit<T, keyof Base>.
|
||||||
|
type OmitWithTag<T, K extends keyof any, _M> = Omit<T, K>
|
||||||
|
type Diff<Base, T extends Base, Message extends string = ''> = 0 extends (1 & T) ? {} : OmitWithTag<T, keyof Base, Message>
|
||||||
|
|
||||||
|
type FirstArg<T extends Function> = T extends (...args: [infer T, any]) => any ? unknown extends T ? any : T : never
|
||||||
|
type SecondArg<T extends Function> = T extends (...args: [any, infer T]) => any ? unknown extends T ? any : T : never
|
||||||
|
type MaybeField<T, K extends string> = T extends { [k in K]: infer G } ? G extends Function ? G : never : never
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
function checkFields<_ extends { [k in keyof any]: never }>() {}
|
||||||
|
|
||||||
|
// https://github.com/sindresorhus/type-fest
|
||||||
|
type Numeric = number | bigint
|
||||||
|
type Zero = 0 | 0n
|
||||||
|
type Negative<T extends Numeric> = T extends Zero ? never : `${T}` extends `-${string}` ? T : never
|
||||||
|
type NonNegative<T extends Numeric> = T extends Zero ? T : Negative<T> extends never ? T : '__invalid_negative_number__'
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
images: {
|
||||||
|
remotePatterns: [
|
||||||
|
{
|
||||||
|
protocol: 'http',
|
||||||
|
hostname: '127.0.0.1',
|
||||||
|
port: '8000', // <-- обязательно 8000, где Django отдаёт медиа
|
||||||
|
pathname: '/storage/**', // <-- подпапкиstorage/avatars, images/link_groups и т.д.
|
||||||
|
},
|
||||||
|
|
||||||
|
],
|
||||||
|
},
|
||||||
|
// Разрешаем в деве обращения с вашего адреса
|
||||||
|
allowedDevOrigins: [
|
||||||
|
'http://localhost:3001',
|
||||||
|
'http://192.168.219.114:3001',
|
||||||
|
'http://0.0.0.0:3001',
|
||||||
|
'http://localhost:3000',
|
||||||
|
'http://192.168.219.114:3000',
|
||||||
|
'http://127.0.0.1:3001',
|
||||||
|
],
|
||||||
|
|
||||||
|
// Проксируем все запросы /api/* на Django
|
||||||
|
async rewrites() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: '/api/:path*', // локальный путь на фронте
|
||||||
|
destination: 'http://127.0.0.1:8000/api/:path*/' // куда реально уходит запрос
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = nextConfig;
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
images: {
|
||||||
|
remotePatterns: [
|
||||||
|
{
|
||||||
|
protocol: 'http',
|
||||||
|
hostname: '127.0.0.1',
|
||||||
|
port: '8000', // where Django serves media
|
||||||
|
pathname: '/storage/**', // storage/avatars, images/link_groups, etc.
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// proxy all /api/* calls to Django
|
||||||
|
async rewrites() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: '/api/:path*',
|
||||||
|
destination: 'http://127.0.0.1:8000/api/:path*/',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
experimental: {
|
||||||
|
// whitelist origins you’ll browse from in dev
|
||||||
|
allowedDevOrigins: [
|
||||||
|
'http://localhost:3001',
|
||||||
|
'http://127.0.0.1:3001',
|
||||||
|
'http://192.168.219.114:3001',
|
||||||
|
'http://0.0.0.0:3001',
|
||||||
|
'http://localhost:3000',
|
||||||
|
'http://192.168.219.114:3000',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = nextConfig;
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
images: {
|
||||||
|
remotePatterns: [
|
||||||
|
{
|
||||||
|
protocol: 'http',
|
||||||
|
hostname: '127.0.0.1',
|
||||||
|
port: '8000', // where Django serves media
|
||||||
|
pathname: '/storage/**', // storage/avatars, images/link_groups, etc.
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// proxy all /api/* calls to Django
|
||||||
|
async rewrites() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: '/api/:path*',
|
||||||
|
destination: 'http://127.0.0.1:8000/api/:path*',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
experimental: {
|
||||||
|
// whitelist origins you’ll browse from in dev
|
||||||
|
allowedDevOrigins: [
|
||||||
|
'http://localhost:3001',
|
||||||
|
'http://127.0.0.1:3001',
|
||||||
|
'http://192.168.219.114:3001',
|
||||||
|
'http://0.0.0.0:3001',
|
||||||
|
'http://localhost:3000',
|
||||||
|
'http://192.168.219.114:3000',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = nextConfig;
|
||||||
@@ -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,21 @@
|
|||||||
|
{
|
||||||
|
"name": "CatLink",
|
||||||
|
"short_name": "CatLink",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/web-app-manifest-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/web-app-manifest-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"theme_color": "#ffffff",
|
||||||
|
"background_color": "#ffffff",
|
||||||
|
"display": "standalone"
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
{
|
||||||
|
"name": "CatLink",
|
||||||
|
"short_name": "CatLink",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/web-app-manifest-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/web-app-manifest-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"theme_color": "#ffffff",
|
||||||
|
"background_color": "#ffffff",
|
||||||
|
"display": "standalone"
|
||||||
|
|
||||||
|
"name": "CatLink",
|
||||||
|
"short_name": "CatLink",
|
||||||
|
"description": "Все ваши ссылки в одном месте. Публикуйте CatLink в био, мессенджерах и письмах.",
|
||||||
|
"start_url": "/",
|
||||||
|
"scope": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"theme_color": "#ffffff",
|
||||||
|
"background_color": "#ffffff",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/web-app-manifest-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/web-app-manifest-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "CatLink",
|
||||||
|
"short_name": "CatLink",
|
||||||
|
"description": "Все ваши ссылки в одном месте. Публикуйте CatLink в био, мессенджерах и письмах.",
|
||||||
|
"start_url": "/",
|
||||||
|
"scope": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"theme_color": "#ffffff",
|
||||||
|
"background_color": "#ffffff",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/web-app-manifest-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/web-app-manifest-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import Image from 'next/image';
|
||||||
|
|
||||||
|
type FormData = {
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
first_name?: string;
|
||||||
|
last_name?: string;
|
||||||
|
password: string;
|
||||||
|
password2: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RegisterPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors, isSubmitting },
|
||||||
|
} = useForm<FormData>();
|
||||||
|
const [apiError, setApiError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// если уже залогинен — редирект на дашборд
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window !== 'undefined' && localStorage.getItem('token')) {
|
||||||
|
router.push('/dashboard');
|
||||||
|
}
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
async function onSubmit(data: FormData) {
|
||||||
|
setApiError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/auth/register/', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const json = await res.json();
|
||||||
|
// берём первую ошибку из ответа
|
||||||
|
const firstKey = Object.keys(json)[0];
|
||||||
|
setApiError(
|
||||||
|
Array.isArray(json[firstKey])
|
||||||
|
? (json[firstKey] as string[])[0]
|
||||||
|
: json[firstKey].toString()
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// при успехе — редирект на логин
|
||||||
|
router.push('/auth/login');
|
||||||
|
} catch (e) {
|
||||||
|
setApiError('Сетевая ошибка, попробуйте снова.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="d-flex align-items-center"
|
||||||
|
style={{ width: '100%', height: '100vh' }}
|
||||||
|
>
|
||||||
|
<div className="container">
|
||||||
|
<div className="row justify-content-center">
|
||||||
|
<div className="col-md-9 col-lg-12 col-xl-10">
|
||||||
|
<div className="card shadow-lg border-0 my-5">
|
||||||
|
<div className="row g-0">
|
||||||
|
{/* Левая половина с картинкой */}
|
||||||
|
<div className="col-lg-6 d-none d-lg-flex">
|
||||||
|
<div
|
||||||
|
className="flex-grow-1 bg-register-image"
|
||||||
|
style={{
|
||||||
|
backgroundImage: "url('/assets/img/durvill_logo.jpg')",
|
||||||
|
backgroundSize: 'cover',
|
||||||
|
backgroundPosition: 'center',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Правая — сама форма */}
|
||||||
|
<div className="col-lg-6">
|
||||||
|
<div className="p-5">
|
||||||
|
<div className="text-center mb-4">
|
||||||
|
<h4 className="text-dark">Регистрация</h4>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} noValidate>
|
||||||
|
{/* Username */}
|
||||||
|
<div className="mb-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Имя пользователя"
|
||||||
|
className={`form-control form-control-user ${
|
||||||
|
errors.username ? 'is-invalid' : ''
|
||||||
|
}`}
|
||||||
|
{...register('username', {
|
||||||
|
required: 'Введите имя пользователя',
|
||||||
|
minLength: {
|
||||||
|
value: 3,
|
||||||
|
message: 'От 3 символов',
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
{errors.username && (
|
||||||
|
<div className="invalid-feedback">
|
||||||
|
{errors.username.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Email */}
|
||||||
|
<div className="mb-3">
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
placeholder="Email"
|
||||||
|
className={`form-control form-control-user ${
|
||||||
|
errors.email ? 'is-invalid' : ''
|
||||||
|
}`}
|
||||||
|
{...register('email', {
|
||||||
|
required: 'Введите email',
|
||||||
|
pattern: {
|
||||||
|
value:
|
||||||
|
/^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/,
|
||||||
|
message: 'Неверный формат',
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
{errors.email && (
|
||||||
|
<div className="invalid-feedback">
|
||||||
|
{errors.email.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* First / Last name */}
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-md-6 mb-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Имя"
|
||||||
|
className="form-control form-control-user"
|
||||||
|
{...register('first_name')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-6 mb-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Фамилия"
|
||||||
|
className="form-control form-control-user"
|
||||||
|
{...register('last_name')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Password */}
|
||||||
|
<div className="mb-3">
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
placeholder="Пароль"
|
||||||
|
className={`form-control form-control-user ${
|
||||||
|
errors.password ? 'is-invalid' : ''
|
||||||
|
}`}
|
||||||
|
{...register('password', {
|
||||||
|
required: 'Введите пароль',
|
||||||
|
minLength: {
|
||||||
|
value: 8,
|
||||||
|
message: 'Не менее 8 символов',
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
{errors.password && (
|
||||||
|
<div className="invalid-feedback">
|
||||||
|
{errors.password.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Confirm Password */}
|
||||||
|
<div className="mb-3">
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
placeholder="Повторите пароль"
|
||||||
|
className={`form-control form-control-user ${
|
||||||
|
errors.password2 ? 'is-invalid' : ''
|
||||||
|
}`}
|
||||||
|
{...register('password2', {
|
||||||
|
required: 'Повторите пароль',
|
||||||
|
validate: (value, formValues) =>
|
||||||
|
value === formValues.password ||
|
||||||
|
'Пароли не совпадают',
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
{errors.password2 && (
|
||||||
|
<div className="invalid-feedback">
|
||||||
|
{errors.password2.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* API Error */}
|
||||||
|
{apiError && (
|
||||||
|
<div className="text-center text-danger mb-3 small">
|
||||||
|
{apiError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Кнопка */}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn btn-primary d-block btn-user w-100"
|
||||||
|
style={{ background: '#01703E' }}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
{isSubmitting ? 'Регистрация...' : 'Зарегистрироваться'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Ссылка на вход */}
|
||||||
|
<hr />
|
||||||
|
<div className="text-center">
|
||||||
|
<span className="small">
|
||||||
|
Уже есть аккаунт?{' '}
|
||||||
|
<Link href="/auth/login" style={{ color: '#01703E' }}>
|
||||||
|
Войти
|
||||||
|
</Link>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
// src/app/dashboard/page.tsx
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import React, { useEffect, useState, Fragment } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { ProfileCard } from '../../components/ProfileCard'
|
||||||
|
|
||||||
|
interface UserProfile {
|
||||||
|
avatar: string
|
||||||
|
full_name: string
|
||||||
|
email: string
|
||||||
|
bio?: string
|
||||||
|
last_login: string
|
||||||
|
date_joined: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LinkItem {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Group {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
links?: LinkItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const [user, setUser] = useState<UserProfile | null>(null)
|
||||||
|
const [groups, setGroups] = useState<Group[]>([])
|
||||||
|
const [expandedGroup, setExpandedGroup] = useState<number | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
if (!token) {
|
||||||
|
router.push('/auth/login')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
// 1) Профиль
|
||||||
|
const uRes = await fetch(`/api/auth/user/`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
if (uRes.status === 401) {
|
||||||
|
localStorage.removeItem('token')
|
||||||
|
router.push('/auth/login')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!uRes.ok) throw new Error('Не удалось получить профиль')
|
||||||
|
const userData: UserProfile = await uRes.json()
|
||||||
|
|
||||||
|
// 2) Группы
|
||||||
|
const gRes = await fetch(`/api/groups/`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
if (gRes.status === 401) {
|
||||||
|
localStorage.removeItem('token')
|
||||||
|
router.push('/auth/login')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!gRes.ok) throw new Error('Не удалось загрузить группы')
|
||||||
|
const groupsData: Group[] = await gRes.json()
|
||||||
|
|
||||||
|
setUser(userData)
|
||||||
|
setGroups(groupsData)
|
||||||
|
} catch (err) {
|
||||||
|
// на любую ошибку — редирект на логин
|
||||||
|
console.error(err)
|
||||||
|
localStorage.removeItem('token')
|
||||||
|
router.push('/auth/login')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchData()
|
||||||
|
}, [router])
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="flex items-center justify-center h-screen">Загрузка...</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pb-8">
|
||||||
|
<div className="container">
|
||||||
|
<div className="row justify-content-center">
|
||||||
|
<div className="col-xl-10 col-xxl-9">
|
||||||
|
{/* Профиль пользователя */}
|
||||||
|
{user && (
|
||||||
|
<ProfileCard
|
||||||
|
avatar={user.avatar}
|
||||||
|
full_name={user.full_name}
|
||||||
|
email={user.email}
|
||||||
|
bio={user.bio}
|
||||||
|
last_login={user.last_login}
|
||||||
|
date_joined={user.date_joined}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Секция групп ссылок */}
|
||||||
|
<div className="card shadow mt-5">
|
||||||
|
<div className="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<h5 className="mb-0">Группы ссылок</h5>
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-success"
|
||||||
|
onClick={() => {
|
||||||
|
/* TODO: открыть модальное окно добавления группы */
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<i className="bi bi-plus-lg"></i> Добавить группу
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="list-group list-group-flush">
|
||||||
|
{groups.map((group) => (
|
||||||
|
<Fragment key={group.id}>
|
||||||
|
<div className="list-group-item d-flex justify-content-between align-items-center">
|
||||||
|
<span
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
onClick={() =>
|
||||||
|
setExpandedGroup(
|
||||||
|
expandedGroup === group.id ? null : group.id
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{group.name}{' '}
|
||||||
|
<span className="badge bg-secondary rounded-pill">
|
||||||
|
{group.links?.length ?? 0}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<div className="btn-group btn-group-sm">
|
||||||
|
<button className="btn btn-outline-primary">
|
||||||
|
<i className="bi bi-link-45deg"></i>
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-outline-secondary">
|
||||||
|
<i className="bi bi-pencil"></i>
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-outline-danger">
|
||||||
|
<i className="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{expandedGroup === group.id && (
|
||||||
|
<div className="list-group-item bg-light">
|
||||||
|
<ul className="mb-0 ps-3">
|
||||||
|
{group.links?.map((link) => (
|
||||||
|
<li
|
||||||
|
key={link.id}
|
||||||
|
className="d-flex justify-content-between align-items-center mb-2"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href={link.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex-grow-1"
|
||||||
|
>
|
||||||
|
{link.title}
|
||||||
|
</a>
|
||||||
|
<div className="btn-group btn-group-sm">
|
||||||
|
<button className="btn btn-outline-secondary">
|
||||||
|
<i className="bi bi-pencil-fill"></i>
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-outline-danger">
|
||||||
|
<i className="bi bi-trash-fill"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="card-footer">
|
||||||
|
<nav>
|
||||||
|
<ul className="pagination pagination-sm justify-content-center mb-0">
|
||||||
|
{[1, 2, 3].map((p) => (
|
||||||
|
<li key={p} className="page-item">
|
||||||
|
<Link href={`?page=${p}`} className="page-link">
|
||||||
|
{p}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,423 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React, { useEffect, useState, Fragment } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import Image from 'next/image'
|
||||||
|
import { Modal } from '../components/Modal' // Модалка из примера выше
|
||||||
|
|
||||||
|
interface UserProfile {
|
||||||
|
id: number
|
||||||
|
username: string
|
||||||
|
email: string
|
||||||
|
full_name: string
|
||||||
|
bio?: string
|
||||||
|
avatar: string
|
||||||
|
last_login: string
|
||||||
|
date_joined: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LinkItem {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
url: string
|
||||||
|
icon?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Group {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
image?: string
|
||||||
|
links: LinkItem[]
|
||||||
|
_open?: boolean // флаг аккордеона
|
||||||
|
}
|
||||||
|
|
||||||
|
type ModalType =
|
||||||
|
| 'addGroup'
|
||||||
|
| 'editGroup'
|
||||||
|
| 'deleteGroup'
|
||||||
|
| 'addLink'
|
||||||
|
| 'editLink'
|
||||||
|
| 'deleteLink'
|
||||||
|
| null
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const API = process.env.NEXT_PUBLIC_API_URL
|
||||||
|
const token = typeof window !== 'undefined' ? localStorage.getItem('token') : null
|
||||||
|
|
||||||
|
const [user, setUser] = useState<UserProfile | null>(null)
|
||||||
|
const [groups, setGroups] = useState<Group[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// для модалки
|
||||||
|
const [modalType, setModalType] = useState<ModalType>(null)
|
||||||
|
const [currentGroup, setCurrentGroup] = useState<Group | null>(null)
|
||||||
|
const [currentLink, setCurrentLink] = useState<LinkItem | null>(null)
|
||||||
|
const [formValue, setFormValue] = useState<{ name?: string; url?: string }>({})
|
||||||
|
|
||||||
|
// загрузка пользователя и групп
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) {
|
||||||
|
router.replace('/auth/login')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Promise.all([
|
||||||
|
fetch(`${API}/api/auth/user/`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
}),
|
||||||
|
fetch(`${API}/api/groups/`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
.then(async ([uRes, gRes]) => {
|
||||||
|
if (!uRes.ok) throw new Error('Не удалось получить профиль')
|
||||||
|
if (!gRes.ok) throw new Error('Не удалось загрузить группы')
|
||||||
|
const [u, g] = await Promise.all([uRes.json(), gRes.json()])
|
||||||
|
setUser(u)
|
||||||
|
setGroups(g)
|
||||||
|
})
|
||||||
|
.catch((err) => setError((err as Error).message))
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [router, API, token])
|
||||||
|
|
||||||
|
if (loading) return <div className="text-center mt-5">Загрузка...</div>
|
||||||
|
if (error) return <div className="alert alert-danger m-5">{error}</div>
|
||||||
|
|
||||||
|
// закрыть модалку
|
||||||
|
const closeModal = () => {
|
||||||
|
setModalType(null)
|
||||||
|
setCurrentGroup(null)
|
||||||
|
setCurrentLink(null)
|
||||||
|
setFormValue({})
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Группы ===
|
||||||
|
async function handleGroupSubmit() {
|
||||||
|
if (!modalType) return
|
||||||
|
const isNew = modalType === 'addGroup'
|
||||||
|
const url = isNew
|
||||||
|
? `${API}/api/groups/`
|
||||||
|
: `${API}/api/groups/${currentGroup!.id}/`
|
||||||
|
const method = isNew ? 'POST' : 'PUT'
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ name: formValue.name }),
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
const grp = await res.json()
|
||||||
|
setGroups((prev) =>
|
||||||
|
isNew
|
||||||
|
? [...prev, { ...grp, links: [] }]
|
||||||
|
: prev.map((g) => (g.id === grp.id ? { ...g, name: grp.name } : g))
|
||||||
|
)
|
||||||
|
closeModal()
|
||||||
|
} else {
|
||||||
|
alert('Ошибка при сохранении группы')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleGroupDelete() {
|
||||||
|
if (!currentGroup) return
|
||||||
|
const res = await fetch(`${API}/api/groups/${currentGroup.id}/`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
setGroups((prev) => prev.filter((g) => g.id !== currentGroup.id))
|
||||||
|
closeModal()
|
||||||
|
} else {
|
||||||
|
alert('Ошибка при удалении группы')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Ссылки ===
|
||||||
|
async function handleLinkSubmit() {
|
||||||
|
if (!currentGroup) return
|
||||||
|
const isEdit = modalType === 'editLink'
|
||||||
|
const url = isEdit
|
||||||
|
? `${API}/api/links/${currentLink!.id}/`
|
||||||
|
: `${API}/api/links/`
|
||||||
|
const method = isEdit ? 'PUT' : 'POST'
|
||||||
|
const payload = {
|
||||||
|
title: formValue.name,
|
||||||
|
url: formValue.url,
|
||||||
|
group: currentGroup.id,
|
||||||
|
}
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
const linkData = await res.json()
|
||||||
|
setGroups((prev) =>
|
||||||
|
prev.map((g) =>
|
||||||
|
g.id === currentGroup.id
|
||||||
|
? {
|
||||||
|
...g,
|
||||||
|
links: isEdit
|
||||||
|
? g.links.map((l) => (l.id === linkData.id ? linkData : l))
|
||||||
|
: [...g.links, linkData],
|
||||||
|
}
|
||||||
|
: g
|
||||||
|
)
|
||||||
|
)
|
||||||
|
closeModal()
|
||||||
|
} else {
|
||||||
|
alert('Ошибка при сохранении ссылки')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLinkDelete() {
|
||||||
|
if (!currentLink || !currentGroup) return
|
||||||
|
const res = await fetch(`${API}/api/links/${currentLink.id}/`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
setGroups((prev) =>
|
||||||
|
prev.map((g) =>
|
||||||
|
g.id === currentGroup.id
|
||||||
|
? { ...g, links: g.links.filter((l) => l.id !== currentLink.id) }
|
||||||
|
: g
|
||||||
|
)
|
||||||
|
)
|
||||||
|
closeModal()
|
||||||
|
} else {
|
||||||
|
alert('Ошибка при удалении ссылки')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container py-5">
|
||||||
|
{/* Профиль */}
|
||||||
|
{user && (
|
||||||
|
<div className="card mb-4 shadow-sm">
|
||||||
|
<div className="card-body d-flex align-items-center">
|
||||||
|
<Image
|
||||||
|
src={user.avatar}
|
||||||
|
alt="Avatar"
|
||||||
|
width={60}
|
||||||
|
height={60}
|
||||||
|
className="rounded-circle me-3"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<h5 className="card-title mb-1">
|
||||||
|
{user.full_name || user.username}
|
||||||
|
</h5>
|
||||||
|
<p className="mb-0 text-muted small">{user.email}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Заголовок с кнопкой «Добавить группу» */}
|
||||||
|
<div className="mb-3 d-flex justify-content-between align-items-center">
|
||||||
|
<h4>Группы ссылок</h4>
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-success"
|
||||||
|
onClick={() => setModalType('addGroup')}
|
||||||
|
>
|
||||||
|
<i className="bi bi-plus-lg"></i> Добавить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Список групп (аккордеон) */}
|
||||||
|
{groups.map((group) => (
|
||||||
|
<Fragment key={group.id}>
|
||||||
|
<div className="card mb-2">
|
||||||
|
<div className="card-header d-flex justify-content-between">
|
||||||
|
<div>
|
||||||
|
{/* Редактировать имя группы */}
|
||||||
|
<a
|
||||||
|
href="#!"
|
||||||
|
className="me-2"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setCurrentGroup(group)
|
||||||
|
setFormValue({ name: group.name })
|
||||||
|
setModalType('editGroup')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<i className="bi bi-pencil"></i>
|
||||||
|
</a>
|
||||||
|
{/* Открыть/закрыть аккордеон */}
|
||||||
|
<span
|
||||||
|
className="fw-semibold"
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
onClick={() =>
|
||||||
|
setGroups((pg) =>
|
||||||
|
pg.map((g) =>
|
||||||
|
g.id === group.id
|
||||||
|
? { ...g, _open: !g._open }
|
||||||
|
: g
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{group.name}{' '}
|
||||||
|
<span className="badge bg-secondary">
|
||||||
|
{group.links.length}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{/* Добавить ссылку */}
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-outline-primary me-2"
|
||||||
|
onClick={() => {
|
||||||
|
setCurrentGroup(group)
|
||||||
|
setFormValue({ name: '', url: '' })
|
||||||
|
setModalType('addLink')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<i className="bi bi-link-45deg"></i>
|
||||||
|
</button>
|
||||||
|
{/* Удалить группу */}
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-outline-danger"
|
||||||
|
onClick={() => {
|
||||||
|
setCurrentGroup(group)
|
||||||
|
setModalType('deleteGroup')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<i className="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Ссылки в раскрытом состоянии */}
|
||||||
|
{group._open && (
|
||||||
|
<ul className="list-group list-group-flush">
|
||||||
|
{group.links.map((link) => (
|
||||||
|
<li
|
||||||
|
key={link.id}
|
||||||
|
className="list-group-item d-flex justify-content-between"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href={link.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex-grow-1"
|
||||||
|
>
|
||||||
|
{link.title}
|
||||||
|
</a>
|
||||||
|
<div className="btn-group btn-group-sm">
|
||||||
|
<button
|
||||||
|
className="btn btn-outline-secondary"
|
||||||
|
onClick={() => {
|
||||||
|
setCurrentGroup(group)
|
||||||
|
setCurrentLink(link)
|
||||||
|
setFormValue({ name: link.title, url: link.url })
|
||||||
|
setModalType('editLink')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<i className="bi bi-pencil-fill"></i>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-outline-danger"
|
||||||
|
onClick={() => {
|
||||||
|
setCurrentGroup(group)
|
||||||
|
setCurrentLink(link)
|
||||||
|
setModalType('deleteLink')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<i className="bi bi-trash-fill"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Универсальная модалка */}
|
||||||
|
{modalType && (
|
||||||
|
<Modal onClose={closeModal}>
|
||||||
|
{/* Группа: добавить/редактировать */}
|
||||||
|
{(modalType === 'addGroup' || modalType === 'editGroup') && (
|
||||||
|
<>
|
||||||
|
<h5>
|
||||||
|
{modalType === 'addGroup' ? 'Новая группа' : 'Редактировать группу'}
|
||||||
|
</h5>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control mb-3"
|
||||||
|
placeholder="Название группы"
|
||||||
|
value={formValue.name || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormValue({ ...formValue, name: e.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<button className="btn btn-primary" onClick={handleGroupSubmit}>
|
||||||
|
Сохранить
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{/* Подтверждение удаления группы */}
|
||||||
|
{modalType === 'deleteGroup' && (
|
||||||
|
<>
|
||||||
|
<h5>Удалить группу?</h5>
|
||||||
|
<p>Группа «{currentGroup?.name}» будет удалена навсегда.</p>
|
||||||
|
<button className="btn btn-danger me-2" onClick={handleGroupDelete}>
|
||||||
|
Удалить
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-secondary" onClick={closeModal}>
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{/* Ссылка: добавить/редактировать */}
|
||||||
|
{(modalType === 'addLink' || modalType === 'editLink') && (
|
||||||
|
<>
|
||||||
|
<h5>{modalType === 'addLink' ? 'Новая ссылка' : 'Редактировать ссылку'}</h5>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control mb-2"
|
||||||
|
placeholder="Название"
|
||||||
|
value={formValue.name || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormValue({ ...formValue, name: e.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
className="form-control mb-3"
|
||||||
|
placeholder="URL"
|
||||||
|
value={formValue.url || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormValue({ ...formValue, url: e.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<button className="btn btn-primary" onClick={handleLinkSubmit}>
|
||||||
|
Сохранить
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{/* Подтверждение удаления ссылки */}
|
||||||
|
{modalType === 'deleteLink' && (
|
||||||
|
<>
|
||||||
|
<h5>Удалить ссылку?</h5>
|
||||||
|
<p>Ссылка «{currentLink?.title}» будет удалена.</p>
|
||||||
|
<button className="btn btn-danger me-2" onClick={handleLinkDelete}>
|
||||||
|
Удалить
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-secondary" onClick={closeModal}>
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,423 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React, { useEffect, useState, Fragment } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import Image from 'next/image'
|
||||||
|
import { Modal } from '../../components/modal' // Модалка из примера выше
|
||||||
|
|
||||||
|
interface UserProfile {
|
||||||
|
id: number
|
||||||
|
username: string
|
||||||
|
email: string
|
||||||
|
full_name: string
|
||||||
|
bio?: string
|
||||||
|
avatar: string
|
||||||
|
last_login: string
|
||||||
|
date_joined: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LinkItem {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
url: string
|
||||||
|
icon?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Group {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
image?: string
|
||||||
|
links: LinkItem[]
|
||||||
|
_open?: boolean // флаг аккордеона
|
||||||
|
}
|
||||||
|
|
||||||
|
type ModalType =
|
||||||
|
| 'addGroup'
|
||||||
|
| 'editGroup'
|
||||||
|
| 'deleteGroup'
|
||||||
|
| 'addLink'
|
||||||
|
| 'editLink'
|
||||||
|
| 'deleteLink'
|
||||||
|
| null
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const API = process.env.NEXT_PUBLIC_API_URL
|
||||||
|
const token = typeof window !== 'undefined' ? localStorage.getItem('token') : null
|
||||||
|
|
||||||
|
const [user, setUser] = useState<UserProfile | null>(null)
|
||||||
|
const [groups, setGroups] = useState<Group[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// для модалки
|
||||||
|
const [modalType, setModalType] = useState<ModalType>(null)
|
||||||
|
const [currentGroup, setCurrentGroup] = useState<Group | null>(null)
|
||||||
|
const [currentLink, setCurrentLink] = useState<LinkItem | null>(null)
|
||||||
|
const [formValue, setFormValue] = useState<{ name?: string; url?: string }>({})
|
||||||
|
|
||||||
|
// загрузка пользователя и групп
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) {
|
||||||
|
router.replace('/auth/login')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Promise.all([
|
||||||
|
fetch(`${API}/api/auth/user/`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
}),
|
||||||
|
fetch(`${API}/api/groups/`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
.then(async ([uRes, gRes]) => {
|
||||||
|
if (!uRes.ok) throw new Error('Не удалось получить профиль')
|
||||||
|
if (!gRes.ok) throw new Error('Не удалось загрузить группы')
|
||||||
|
const [u, g] = await Promise.all([uRes.json(), gRes.json()])
|
||||||
|
setUser(u)
|
||||||
|
setGroups(g)
|
||||||
|
})
|
||||||
|
.catch((err) => setError((err as Error).message))
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [router, API, token])
|
||||||
|
|
||||||
|
if (loading) return <div className="text-center mt-5">Загрузка...</div>
|
||||||
|
if (error) return <div className="alert alert-danger m-5">{error}</div>
|
||||||
|
|
||||||
|
// закрыть модалку
|
||||||
|
const closeModal = () => {
|
||||||
|
setModalType(null)
|
||||||
|
setCurrentGroup(null)
|
||||||
|
setCurrentLink(null)
|
||||||
|
setFormValue({})
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Группы ===
|
||||||
|
async function handleGroupSubmit() {
|
||||||
|
if (!modalType) return
|
||||||
|
const isNew = modalType === 'addGroup'
|
||||||
|
const url = isNew
|
||||||
|
? `${API}/api/groups/`
|
||||||
|
: `${API}/api/groups/${currentGroup!.id}/`
|
||||||
|
const method = isNew ? 'POST' : 'PUT'
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ name: formValue.name }),
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
const grp = await res.json()
|
||||||
|
setGroups((prev) =>
|
||||||
|
isNew
|
||||||
|
? [...prev, { ...grp, links: [] }]
|
||||||
|
: prev.map((g) => (g.id === grp.id ? { ...g, name: grp.name } : g))
|
||||||
|
)
|
||||||
|
closeModal()
|
||||||
|
} else {
|
||||||
|
alert('Ошибка при сохранении группы')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleGroupDelete() {
|
||||||
|
if (!currentGroup) return
|
||||||
|
const res = await fetch(`${API}/api/groups/${currentGroup.id}/`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
setGroups((prev) => prev.filter((g) => g.id !== currentGroup.id))
|
||||||
|
closeModal()
|
||||||
|
} else {
|
||||||
|
alert('Ошибка при удалении группы')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Ссылки ===
|
||||||
|
async function handleLinkSubmit() {
|
||||||
|
if (!currentGroup) return
|
||||||
|
const isEdit = modalType === 'editLink'
|
||||||
|
const url = isEdit
|
||||||
|
? `${API}/api/links/${currentLink!.id}/`
|
||||||
|
: `${API}/api/links/`
|
||||||
|
const method = isEdit ? 'PUT' : 'POST'
|
||||||
|
const payload = {
|
||||||
|
title: formValue.name,
|
||||||
|
url: formValue.url,
|
||||||
|
group: currentGroup.id,
|
||||||
|
}
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
const linkData = await res.json()
|
||||||
|
setGroups((prev) =>
|
||||||
|
prev.map((g) =>
|
||||||
|
g.id === currentGroup.id
|
||||||
|
? {
|
||||||
|
...g,
|
||||||
|
links: isEdit
|
||||||
|
? g.links.map((l) => (l.id === linkData.id ? linkData : l))
|
||||||
|
: [...g.links, linkData],
|
||||||
|
}
|
||||||
|
: g
|
||||||
|
)
|
||||||
|
)
|
||||||
|
closeModal()
|
||||||
|
} else {
|
||||||
|
alert('Ошибка при сохранении ссылки')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLinkDelete() {
|
||||||
|
if (!currentLink || !currentGroup) return
|
||||||
|
const res = await fetch(`${API}/api/links/${currentLink.id}/`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
setGroups((prev) =>
|
||||||
|
prev.map((g) =>
|
||||||
|
g.id === currentGroup.id
|
||||||
|
? { ...g, links: g.links.filter((l) => l.id !== currentLink.id) }
|
||||||
|
: g
|
||||||
|
)
|
||||||
|
)
|
||||||
|
closeModal()
|
||||||
|
} else {
|
||||||
|
alert('Ошибка при удалении ссылки')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container py-5">
|
||||||
|
{/* Профиль */}
|
||||||
|
{user && (
|
||||||
|
<div className="card mb-4 shadow-sm">
|
||||||
|
<div className="card-body d-flex align-items-center">
|
||||||
|
<Image
|
||||||
|
src={user.avatar}
|
||||||
|
alt="Avatar"
|
||||||
|
width={60}
|
||||||
|
height={60}
|
||||||
|
className="rounded-circle me-3"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<h5 className="card-title mb-1">
|
||||||
|
{user.full_name || user.username}
|
||||||
|
</h5>
|
||||||
|
<p className="mb-0 text-muted small">{user.email}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Заголовок с кнопкой «Добавить группу» */}
|
||||||
|
<div className="mb-3 d-flex justify-content-between align-items-center">
|
||||||
|
<h4>Группы ссылок</h4>
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-success"
|
||||||
|
onClick={() => setModalType('addGroup')}
|
||||||
|
>
|
||||||
|
<i className="bi bi-plus-lg"></i> Добавить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Список групп (аккордеон) */}
|
||||||
|
{groups.map((group) => (
|
||||||
|
<Fragment key={group.id}>
|
||||||
|
<div className="card mb-2">
|
||||||
|
<div className="card-header d-flex justify-content-between">
|
||||||
|
<div>
|
||||||
|
{/* Редактировать имя группы */}
|
||||||
|
<a
|
||||||
|
href="#!"
|
||||||
|
className="me-2"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setCurrentGroup(group)
|
||||||
|
setFormValue({ name: group.name })
|
||||||
|
setModalType('editGroup')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<i className="bi bi-pencil"></i>
|
||||||
|
</a>
|
||||||
|
{/* Открыть/закрыть аккордеон */}
|
||||||
|
<span
|
||||||
|
className="fw-semibold"
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
onClick={() =>
|
||||||
|
setGroups((pg) =>
|
||||||
|
pg.map((g) =>
|
||||||
|
g.id === group.id
|
||||||
|
? { ...g, _open: !g._open }
|
||||||
|
: g
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{group.name}{' '}
|
||||||
|
<span className="badge bg-secondary">
|
||||||
|
{group.links.length}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{/* Добавить ссылку */}
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-outline-primary me-2"
|
||||||
|
onClick={() => {
|
||||||
|
setCurrentGroup(group)
|
||||||
|
setFormValue({ name: '', url: '' })
|
||||||
|
setModalType('addLink')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<i className="bi bi-link-45deg"></i>
|
||||||
|
</button>
|
||||||
|
{/* Удалить группу */}
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-outline-danger"
|
||||||
|
onClick={() => {
|
||||||
|
setCurrentGroup(group)
|
||||||
|
setModalType('deleteGroup')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<i className="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Ссылки в раскрытом состоянии */}
|
||||||
|
{group._open && (
|
||||||
|
<ul className="list-group list-group-flush">
|
||||||
|
{group.links.map((link) => (
|
||||||
|
<li
|
||||||
|
key={link.id}
|
||||||
|
className="list-group-item d-flex justify-content-between"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href={link.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex-grow-1"
|
||||||
|
>
|
||||||
|
{link.title}
|
||||||
|
</a>
|
||||||
|
<div className="btn-group btn-group-sm">
|
||||||
|
<button
|
||||||
|
className="btn btn-outline-secondary"
|
||||||
|
onClick={() => {
|
||||||
|
setCurrentGroup(group)
|
||||||
|
setCurrentLink(link)
|
||||||
|
setFormValue({ name: link.title, url: link.url })
|
||||||
|
setModalType('editLink')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<i className="bi bi-pencil-fill"></i>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-outline-danger"
|
||||||
|
onClick={() => {
|
||||||
|
setCurrentGroup(group)
|
||||||
|
setCurrentLink(link)
|
||||||
|
setModalType('deleteLink')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<i className="bi bi-trash-fill"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Универсальная модалка */}
|
||||||
|
{modalType && (
|
||||||
|
<Modal onClose={closeModal}>
|
||||||
|
{/* Группа: добавить/редактировать */}
|
||||||
|
{(modalType === 'addGroup' || modalType === 'editGroup') && (
|
||||||
|
<>
|
||||||
|
<h5>
|
||||||
|
{modalType === 'addGroup' ? 'Новая группа' : 'Редактировать группу'}
|
||||||
|
</h5>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control mb-3"
|
||||||
|
placeholder="Название группы"
|
||||||
|
value={formValue.name || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormValue({ ...formValue, name: e.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<button className="btn btn-primary" onClick={handleGroupSubmit}>
|
||||||
|
Сохранить
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{/* Подтверждение удаления группы */}
|
||||||
|
{modalType === 'deleteGroup' && (
|
||||||
|
<>
|
||||||
|
<h5>Удалить группу?</h5>
|
||||||
|
<p>Группа «{currentGroup?.name}» будет удалена навсегда.</p>
|
||||||
|
<button className="btn btn-danger me-2" onClick={handleGroupDelete}>
|
||||||
|
Удалить
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-secondary" onClick={closeModal}>
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{/* Ссылка: добавить/редактировать */}
|
||||||
|
{(modalType === 'addLink' || modalType === 'editLink') && (
|
||||||
|
<>
|
||||||
|
<h5>{modalType === 'addLink' ? 'Новая ссылка' : 'Редактировать ссылку'}</h5>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control mb-2"
|
||||||
|
placeholder="Название"
|
||||||
|
value={formValue.name || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormValue({ ...formValue, name: e.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
className="form-control mb-3"
|
||||||
|
placeholder="URL"
|
||||||
|
value={formValue.url || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormValue({ ...formValue, url: e.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<button className="btn btn-primary" onClick={handleLinkSubmit}>
|
||||||
|
Сохранить
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{/* Подтверждение удаления ссылки */}
|
||||||
|
{modalType === 'deleteLink' && (
|
||||||
|
<>
|
||||||
|
<h5>Удалить ссылку?</h5>
|
||||||
|
<p>Ссылка «{currentLink?.title}» будет удалена.</p>
|
||||||
|
<button className="btn btn-danger me-2" onClick={handleLinkDelete}>
|
||||||
|
Удалить
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-secondary" onClick={closeModal}>
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,638 @@
|
|||||||
|
// 'use client'
|
||||||
|
|
||||||
|
// import React, { useEffect, useState, Fragment } from 'react'
|
||||||
|
// import { useRouter } from 'next/navigation'
|
||||||
|
// import Image from 'next/image'
|
||||||
|
// import { Modal } from '../../components/modal' // Модалка из примера выше
|
||||||
|
|
||||||
|
// interface UserProfile {
|
||||||
|
// id: number
|
||||||
|
// username: string
|
||||||
|
// email: string
|
||||||
|
// full_name: string
|
||||||
|
// bio?: string
|
||||||
|
// avatar: string
|
||||||
|
// last_login: string
|
||||||
|
// date_joined: string
|
||||||
|
// }
|
||||||
|
|
||||||
|
// interface LinkItem {
|
||||||
|
// id: number
|
||||||
|
// title: string
|
||||||
|
// url: string
|
||||||
|
// icon?: string
|
||||||
|
// }
|
||||||
|
|
||||||
|
// interface Group {
|
||||||
|
// id: number
|
||||||
|
// name: string
|
||||||
|
// image?: string
|
||||||
|
// links: LinkItem[]
|
||||||
|
// _open?: boolean // флаг аккордеона
|
||||||
|
// }
|
||||||
|
|
||||||
|
// type ModalType =
|
||||||
|
// | 'addGroup'
|
||||||
|
// | 'editGroup'
|
||||||
|
// | 'deleteGroup'
|
||||||
|
// | 'addLink'
|
||||||
|
// | 'editLink'
|
||||||
|
// | 'deleteLink'
|
||||||
|
// | null
|
||||||
|
|
||||||
|
// export default function DashboardPage() {
|
||||||
|
// const router = useRouter()
|
||||||
|
// const API = process.env.NEXT_PUBLIC_API_URL
|
||||||
|
// const token = typeof window !== 'undefined' ? localStorage.getItem('token') : null
|
||||||
|
|
||||||
|
// const [user, setUser] = useState<UserProfile | null>(null)
|
||||||
|
// const [groups, setGroups] = useState<Group[]>([])
|
||||||
|
// const [loading, setLoading] = useState(true)
|
||||||
|
// const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// // для модалки
|
||||||
|
// const [modalType, setModalType] = useState<ModalType>(null)
|
||||||
|
// const [currentGroup, setCurrentGroup] = useState<Group | null>(null)
|
||||||
|
// const [currentLink, setCurrentLink] = useState<LinkItem | null>(null)
|
||||||
|
// const [formValue, setFormValue] = useState<{ name?: string; url?: string }>({})
|
||||||
|
|
||||||
|
// // загрузка пользователя и групп
|
||||||
|
// useEffect(() => {
|
||||||
|
// if (!token) {
|
||||||
|
// router.replace('/auth/login')
|
||||||
|
// return
|
||||||
|
// }
|
||||||
|
// Promise.all([
|
||||||
|
// fetch(`${API}/api/auth/user/`, {
|
||||||
|
// headers: { Authorization: `Bearer ${token}` },
|
||||||
|
// }),
|
||||||
|
// fetch(`${API}/api/groups/`, {
|
||||||
|
// headers: { Authorization: `Bearer ${token}` },
|
||||||
|
// }),
|
||||||
|
// ])
|
||||||
|
// .then(async ([uRes, gRes]) => {
|
||||||
|
// if (!uRes.ok) throw new Error('Не удалось получить профиль')
|
||||||
|
// if (!gRes.ok) throw new Error('Не удалось загрузить группы')
|
||||||
|
// const [u, g] = await Promise.all([uRes.json(), gRes.json()])
|
||||||
|
// setUser(u)
|
||||||
|
// setGroups(g)
|
||||||
|
// })
|
||||||
|
// .catch((err) => setError((err as Error).message))
|
||||||
|
// .finally(() => setLoading(false))
|
||||||
|
// }, [router, API, token])
|
||||||
|
|
||||||
|
// if (loading) return <div className="text-center mt-5">Загрузка...</div>
|
||||||
|
// if (error) return <div className="alert alert-danger m-5">{error}</div>
|
||||||
|
|
||||||
|
// // закрыть модалку
|
||||||
|
// const closeModal = () => {
|
||||||
|
// setModalType(null)
|
||||||
|
// setCurrentGroup(null)
|
||||||
|
// setCurrentLink(null)
|
||||||
|
// setFormValue({})
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // === Группы ===
|
||||||
|
// async function handleGroupSubmit() {
|
||||||
|
// if (!modalType) return
|
||||||
|
// const isNew = modalType === 'addGroup'
|
||||||
|
// const url = isNew
|
||||||
|
// ? `${API}/api/groups/`
|
||||||
|
// : `${API}/api/groups/${currentGroup!.id}/`
|
||||||
|
// const method = isNew ? 'POST' : 'PUT'
|
||||||
|
// const res = await fetch(url, {
|
||||||
|
// method,
|
||||||
|
// headers: {
|
||||||
|
// 'Content-Type': 'application/json',
|
||||||
|
// Authorization: `Bearer ${token}`,
|
||||||
|
// },
|
||||||
|
// body: JSON.stringify({ name: formValue.name }),
|
||||||
|
// })
|
||||||
|
// if (res.ok) {
|
||||||
|
// const grp = await res.json()
|
||||||
|
// setGroups((prev) =>
|
||||||
|
// isNew
|
||||||
|
// ? [...prev, { ...grp, links: [] }]
|
||||||
|
// : prev.map((g) => (g.id === grp.id ? { ...g, name: grp.name } : g))
|
||||||
|
// )
|
||||||
|
// closeModal()
|
||||||
|
// } else {
|
||||||
|
// alert('Ошибка при сохранении группы')
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// async function handleGroupDelete() {
|
||||||
|
// if (!currentGroup) return
|
||||||
|
// const res = await fetch(`${API}/api/groups/${currentGroup.id}/`, {
|
||||||
|
// method: 'DELETE',
|
||||||
|
// headers: { Authorization: `Bearer ${token}` },
|
||||||
|
// })
|
||||||
|
// if (res.ok) {
|
||||||
|
// setGroups((prev) => prev.filter((g) => g.id !== currentGroup.id))
|
||||||
|
// closeModal()
|
||||||
|
// } else {
|
||||||
|
// alert('Ошибка при удалении группы')
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // === Ссылки ===
|
||||||
|
// async function handleLinkSubmit() {
|
||||||
|
// if (!currentGroup) return
|
||||||
|
// const isEdit = modalType === 'editLink'
|
||||||
|
// const url = isEdit
|
||||||
|
// ? `${API}/api/links/${currentLink!.id}/`
|
||||||
|
// : `${API}/api/links/`
|
||||||
|
// const method = isEdit ? 'PUT' : 'POST'
|
||||||
|
// const payload = {
|
||||||
|
// title: formValue.name,
|
||||||
|
// url: formValue.url,
|
||||||
|
// group: currentGroup.id,
|
||||||
|
// }
|
||||||
|
// const res = await fetch(url, {
|
||||||
|
// method,
|
||||||
|
// headers: {
|
||||||
|
// 'Content-Type': 'application/json',
|
||||||
|
// Authorization: `Bearer ${token}`,
|
||||||
|
// },
|
||||||
|
// body: JSON.stringify(payload),
|
||||||
|
// })
|
||||||
|
// if (res.ok) {
|
||||||
|
// const linkData = await res.json()
|
||||||
|
// setGroups((prev) =>
|
||||||
|
// prev.map((g) =>
|
||||||
|
// g.id === currentGroup.id
|
||||||
|
// ? {
|
||||||
|
// ...g,
|
||||||
|
// links: isEdit
|
||||||
|
// ? g.links.map((l) => (l.id === linkData.id ? linkData : l))
|
||||||
|
// : [...g.links, linkData],
|
||||||
|
// }
|
||||||
|
// : g
|
||||||
|
// )
|
||||||
|
// )
|
||||||
|
// closeModal()
|
||||||
|
// } else {
|
||||||
|
// alert('Ошибка при сохранении ссылки')
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// async function handleLinkDelete() {
|
||||||
|
// if (!currentLink || !currentGroup) return
|
||||||
|
// const res = await fetch(`${API}/api/links/${currentLink.id}/`, {
|
||||||
|
// method: 'DELETE',
|
||||||
|
// headers: { Authorization: `Bearer ${token}` },
|
||||||
|
// })
|
||||||
|
// if (res.ok) {
|
||||||
|
// setGroups((prev) =>
|
||||||
|
// prev.map((g) =>
|
||||||
|
// g.id === currentGroup.id
|
||||||
|
// ? { ...g, links: g.links.filter((l) => l.id !== currentLink.id) }
|
||||||
|
// : g
|
||||||
|
// )
|
||||||
|
// )
|
||||||
|
// closeModal()
|
||||||
|
// } else {
|
||||||
|
// alert('Ошибка при удалении ссылки')
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// return (
|
||||||
|
// <div className="container py-5">
|
||||||
|
// {/* Профиль */}
|
||||||
|
// {user && (
|
||||||
|
// <div className="card mb-4 shadow-sm">
|
||||||
|
// <div className="card-body d-flex align-items-center">
|
||||||
|
// <Image
|
||||||
|
// src={user.avatar}
|
||||||
|
// alt="Avatar"
|
||||||
|
// width={60}
|
||||||
|
// height={60}
|
||||||
|
// className="rounded-circle me-3"
|
||||||
|
// />
|
||||||
|
// <div>
|
||||||
|
// <h5 className="card-title mb-1">
|
||||||
|
// {user.full_name || user.username}
|
||||||
|
// </h5>
|
||||||
|
// <p className="mb-0 text-muted small">{user.email}</p>
|
||||||
|
// </div>
|
||||||
|
// </div>
|
||||||
|
// </div>
|
||||||
|
// )}
|
||||||
|
|
||||||
|
// {/* Заголовок с кнопкой «Добавить группу» */}
|
||||||
|
// <div className="mb-3 d-flex justify-content-between align-items-center">
|
||||||
|
// <h4>Группы ссылок</h4>
|
||||||
|
// <button
|
||||||
|
// className="btn btn-sm btn-success"
|
||||||
|
// onClick={() => setModalType('addGroup')}
|
||||||
|
// >
|
||||||
|
// <i className="bi bi-plus-lg"></i> Добавить
|
||||||
|
// </button>
|
||||||
|
// </div>
|
||||||
|
|
||||||
|
// {/* Список групп (аккордеон) */}
|
||||||
|
// {groups.map((group) => (
|
||||||
|
// <Fragment key={group.id}>
|
||||||
|
// <div className="card mb-2">
|
||||||
|
// <div className="card-header d-flex justify-content-between">
|
||||||
|
// <div>
|
||||||
|
// {/* Редактировать имя группы */}
|
||||||
|
// <a
|
||||||
|
// href="#!"
|
||||||
|
// className="me-2"
|
||||||
|
// onClick={(e) => {
|
||||||
|
// e.preventDefault()
|
||||||
|
// setCurrentGroup(group)
|
||||||
|
// setFormValue({ name: group.name })
|
||||||
|
// setModalType('editGroup')
|
||||||
|
// }}
|
||||||
|
// >
|
||||||
|
// <i className="bi bi-pencil"></i>
|
||||||
|
// </a>
|
||||||
|
// {/* Открыть/закрыть аккордеон */}
|
||||||
|
// <span
|
||||||
|
// className="fw-semibold"
|
||||||
|
// style={{ cursor: 'pointer' }}
|
||||||
|
// onClick={() =>
|
||||||
|
// setGroups((pg) =>
|
||||||
|
// pg.map((g) =>
|
||||||
|
// g.id === group.id
|
||||||
|
// ? { ...g, _open: !g._open }
|
||||||
|
// : g
|
||||||
|
// )
|
||||||
|
// )
|
||||||
|
// }
|
||||||
|
// >
|
||||||
|
// {group.name}{' '}
|
||||||
|
// <span className="badge bg-secondary">
|
||||||
|
// {group.links.length}
|
||||||
|
// </span>
|
||||||
|
// </span>
|
||||||
|
// </div>
|
||||||
|
// <div>
|
||||||
|
// {/* Добавить ссылку */}
|
||||||
|
// <button
|
||||||
|
// className="btn btn-sm btn-outline-primary me-2"
|
||||||
|
// onClick={() => {
|
||||||
|
// setCurrentGroup(group)
|
||||||
|
// setFormValue({ name: '', url: '' })
|
||||||
|
// setModalType('addLink')
|
||||||
|
// }}
|
||||||
|
// >
|
||||||
|
// <i className="bi bi-link-45deg"></i>
|
||||||
|
// </button>
|
||||||
|
// {/* Удалить группу */}
|
||||||
|
// <button
|
||||||
|
// className="btn btn-sm btn-outline-danger"
|
||||||
|
// onClick={() => {
|
||||||
|
// setCurrentGroup(group)
|
||||||
|
// setModalType('deleteGroup')
|
||||||
|
// }}
|
||||||
|
// >
|
||||||
|
// <i className="bi bi-trash"></i>
|
||||||
|
// </button>
|
||||||
|
// </div>
|
||||||
|
// </div>
|
||||||
|
// {/* Ссылки в раскрытом состоянии */}
|
||||||
|
// {group._open && (
|
||||||
|
// <ul className="list-group list-group-flush">
|
||||||
|
// {group.links.map((link) => (
|
||||||
|
// <li
|
||||||
|
// key={link.id}
|
||||||
|
// className="list-group-item d-flex justify-content-between"
|
||||||
|
// >
|
||||||
|
// <a
|
||||||
|
// href={link.url}
|
||||||
|
// target="_blank"
|
||||||
|
// rel="noopener noreferrer"
|
||||||
|
// className="flex-grow-1"
|
||||||
|
// >
|
||||||
|
// {link.title}
|
||||||
|
// </a>
|
||||||
|
// <div className="btn-group btn-group-sm">
|
||||||
|
// <button
|
||||||
|
// className="btn btn-outline-secondary"
|
||||||
|
// onClick={() => {
|
||||||
|
// setCurrentGroup(group)
|
||||||
|
// setCurrentLink(link)
|
||||||
|
// setFormValue({ name: link.title, url: link.url })
|
||||||
|
// setModalType('editLink')
|
||||||
|
// }}
|
||||||
|
// >
|
||||||
|
// <i className="bi bi-pencil-fill"></i>
|
||||||
|
// </button>
|
||||||
|
// <button
|
||||||
|
// className="btn btn-outline-danger"
|
||||||
|
// onClick={() => {
|
||||||
|
// setCurrentGroup(group)
|
||||||
|
// setCurrentLink(link)
|
||||||
|
// setModalType('deleteLink')
|
||||||
|
// }}
|
||||||
|
// >
|
||||||
|
// <i className="bi bi-trash-fill"></i>
|
||||||
|
// </button>
|
||||||
|
// </div>
|
||||||
|
// </li>
|
||||||
|
// ))}
|
||||||
|
// </ul>
|
||||||
|
// )}
|
||||||
|
// </div>
|
||||||
|
// </Fragment>
|
||||||
|
// ))}
|
||||||
|
|
||||||
|
// {/* Универсальная модалка */}
|
||||||
|
// {modalType && (
|
||||||
|
// <Modal onClose={closeModal}>
|
||||||
|
// {/* Группа: добавить/редактировать */}
|
||||||
|
// {(modalType === 'addGroup' || modalType === 'editGroup') && (
|
||||||
|
// <>
|
||||||
|
// <h5>
|
||||||
|
// {modalType === 'addGroup' ? 'Новая группа' : 'Редактировать группу'}
|
||||||
|
// </h5>
|
||||||
|
// <input
|
||||||
|
// type="text"
|
||||||
|
// className="form-control mb-3"
|
||||||
|
// placeholder="Название группы"
|
||||||
|
// value={formValue.name || ''}
|
||||||
|
// onChange={(e) =>
|
||||||
|
// setFormValue({ ...formValue, name: e.target.value })
|
||||||
|
// }
|
||||||
|
// />
|
||||||
|
// <button className="btn btn-primary" onClick={handleGroupSubmit}>
|
||||||
|
// Сохранить
|
||||||
|
// </button>
|
||||||
|
// </>
|
||||||
|
// )}
|
||||||
|
// {/* Подтверждение удаления группы */}
|
||||||
|
// {modalType === 'deleteGroup' && (
|
||||||
|
// <>
|
||||||
|
// <h5>Удалить группу?</h5>
|
||||||
|
// <p>Группа «{currentGroup?.name}» будет удалена навсегда.</p>
|
||||||
|
// <button className="btn btn-danger me-2" onClick={handleGroupDelete}>
|
||||||
|
// Удалить
|
||||||
|
// </button>
|
||||||
|
// <button className="btn btn-secondary" onClick={closeModal}>
|
||||||
|
// Отмена
|
||||||
|
// </button>
|
||||||
|
// </>
|
||||||
|
// )}
|
||||||
|
// {/* Ссылка: добавить/редактировать */}
|
||||||
|
// {(modalType === 'addLink' || modalType === 'editLink') && (
|
||||||
|
// <>
|
||||||
|
// <h5>{modalType === 'addLink' ? 'Новая ссылка' : 'Редактировать ссылку'}</h5>
|
||||||
|
// <input
|
||||||
|
// type="text"
|
||||||
|
// className="form-control mb-2"
|
||||||
|
// placeholder="Название"
|
||||||
|
// value={formValue.name || ''}
|
||||||
|
// onChange={(e) =>
|
||||||
|
// setFormValue({ ...formValue, name: e.target.value })
|
||||||
|
// }
|
||||||
|
// />
|
||||||
|
// <input
|
||||||
|
// type="url"
|
||||||
|
// className="form-control mb-3"
|
||||||
|
// placeholder="URL"
|
||||||
|
// value={formValue.url || ''}
|
||||||
|
// onChange={(e) =>
|
||||||
|
// setFormValue({ ...formValue, url: e.target.value })
|
||||||
|
// }
|
||||||
|
// />
|
||||||
|
// <button className="btn btn-primary" onClick={handleLinkSubmit}>
|
||||||
|
// Сохранить
|
||||||
|
// </button>
|
||||||
|
// </>
|
||||||
|
// )}
|
||||||
|
// {/* Подтверждение удаления ссылки */}
|
||||||
|
// {modalType === 'deleteLink' && (
|
||||||
|
// <>
|
||||||
|
// <h5>Удалить ссылку?</h5>
|
||||||
|
// <p>Ссылка «{currentLink?.title}» будет удалена.</p>
|
||||||
|
// <button className="btn btn-danger me-2" onClick={handleLinkDelete}>
|
||||||
|
// Удалить
|
||||||
|
// </button>
|
||||||
|
// <button className="btn btn-secondary" onClick={closeModal}>
|
||||||
|
// Отмена
|
||||||
|
// </button>
|
||||||
|
// </>
|
||||||
|
// )}
|
||||||
|
// </Modal>
|
||||||
|
// )}
|
||||||
|
// </div>
|
||||||
|
// )
|
||||||
|
// }
|
||||||
|
|
||||||
|
// src/app/(protected)/dashboard/page.tsx
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import React, { useEffect, useState, Fragment } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { ProfileCard } from '../components/ProfileCard'
|
||||||
|
|
||||||
|
interface UserProfile {
|
||||||
|
id: number
|
||||||
|
username: string
|
||||||
|
email: string
|
||||||
|
full_name: string
|
||||||
|
bio?: string
|
||||||
|
avatar: string
|
||||||
|
last_login: string
|
||||||
|
date_joined: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LinkItem {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
url: string
|
||||||
|
icon?: string
|
||||||
|
group: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Group {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
icon?: string
|
||||||
|
// we'll always fill this in as an array:
|
||||||
|
links: LinkItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const [user, setUser] = useState<UserProfile | null>(null)
|
||||||
|
const [groups, setGroups] = useState<Group[]>([])
|
||||||
|
const [expandedGroup, setExpandedGroup] = useState<number | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
if (!token) {
|
||||||
|
router.push('/auth/login')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const API = process.env.NEXT_PUBLIC_API_URL ?? ''
|
||||||
|
|
||||||
|
// 1) fetch profile
|
||||||
|
// 2) fetch all groups
|
||||||
|
// 3) fetch all links
|
||||||
|
Promise.all([
|
||||||
|
fetch(`${API}/api/auth/user/`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
}),
|
||||||
|
fetch(`${API}/api/groups/`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
}),
|
||||||
|
fetch(`${API}/api/links/`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
.then(async ([uRes, gRes, lRes]) => {
|
||||||
|
if (!uRes.ok) throw new Error('Не удалось получить профиль')
|
||||||
|
if (!gRes.ok) throw new Error('Не удалось загрузить группы')
|
||||||
|
if (!lRes.ok) throw new Error('Не удалось загрузить ссылки')
|
||||||
|
|
||||||
|
const userData = await uRes.json()
|
||||||
|
const groupsData = await gRes.json()
|
||||||
|
const linksData = await lRes.json()
|
||||||
|
|
||||||
|
// group.links might not exist on the payload, so we build it:
|
||||||
|
const enrichedGroups: Group[] = groupsData.map((grp: any) => ({
|
||||||
|
...grp,
|
||||||
|
links: linksData.filter((link: LinkItem) => link.group === grp.id)
|
||||||
|
}))
|
||||||
|
|
||||||
|
setUser(userData)
|
||||||
|
setGroups(enrichedGroups)
|
||||||
|
})
|
||||||
|
.catch(err => setError((err as Error).message))
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [router])
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="flex items-center justify-center h-screen">Загрузка...</div>
|
||||||
|
}
|
||||||
|
if (error) {
|
||||||
|
return <div className="p-6 text-red-600 max-w-xl mx-auto">{error}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Подсчёт: всего групп и всего ссылок
|
||||||
|
const totalGroups = groups.length
|
||||||
|
const totalLinks = groups.reduce((sum, grp) => sum + grp.links.length, 0)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pb-8">
|
||||||
|
{user && (
|
||||||
|
<ProfileCard
|
||||||
|
avatar={user.avatar}
|
||||||
|
full_name={user.full_name}
|
||||||
|
email={user.email}
|
||||||
|
bio={user.bio}
|
||||||
|
last_login={user.last_login}
|
||||||
|
date_joined={user.date_joined}
|
||||||
|
totalGroups={totalGroups}
|
||||||
|
totalLinks={totalLinks}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section className="mt-5 container">
|
||||||
|
<div className="card shadow">
|
||||||
|
<div className="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<h5 className="mb-0">Группы ссылок</h5>
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-success"
|
||||||
|
onClick={() => {
|
||||||
|
/* open “add group” modal */
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<i className="bi bi-plus-lg"></i> Добавить группу
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="list-group list-group-flush">
|
||||||
|
{groups.map((group) => (
|
||||||
|
<Fragment key={group.id}>
|
||||||
|
<div className="list-group-item d-flex justify-content-between align-items-center">
|
||||||
|
<div
|
||||||
|
className="d-flex align-items-center"
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
onClick={() =>
|
||||||
|
setExpandedGroup(expandedGroup === group.id ? null : group.id)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{group.icon && (
|
||||||
|
<img
|
||||||
|
src={`${process.env.NEXT_PUBLIC_API_URL}${group.icon}`}
|
||||||
|
width={32}
|
||||||
|
height={32}
|
||||||
|
className="me-2 rounded"
|
||||||
|
alt={group.name}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<strong className="me-2">{group.name}</strong>
|
||||||
|
<span className="badge bg-secondary rounded-pill">
|
||||||
|
{group.links.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="btn-group btn-group-sm">
|
||||||
|
<button className="btn btn-outline-primary">
|
||||||
|
<i className="bi bi-link-45deg"></i>
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-outline-secondary">
|
||||||
|
<i className="bi bi-pencil"></i>
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-outline-danger">
|
||||||
|
<i className="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{expandedGroup === group.id && (
|
||||||
|
<div className="list-group-item bg-light">
|
||||||
|
<ul className="mb-0 ps-3">
|
||||||
|
{group.links.map((link) => (
|
||||||
|
<li
|
||||||
|
key={link.id}
|
||||||
|
className="d-flex justify-content-between align-items-center mb-2"
|
||||||
|
>
|
||||||
|
<div className="d-flex align-items-center">
|
||||||
|
{link.icon && (
|
||||||
|
<img
|
||||||
|
src={link.icon.startsWith('http')
|
||||||
|
? link.icon
|
||||||
|
: `${process.env.NEXT_PUBLIC_API_URL}${link.icon}`}
|
||||||
|
width={24}
|
||||||
|
height={24}
|
||||||
|
className="me-2"
|
||||||
|
alt={link.title}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<a
|
||||||
|
href={link.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
{link.title}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div className="btn-group btn-group-sm">
|
||||||
|
<button className="btn btn-outline-secondary">
|
||||||
|
<i className="bi bi-pencil-fill"></i>
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-outline-danger">
|
||||||
|
<i className="bi bi-trash-fill"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,638 @@
|
|||||||
|
// 'use client'
|
||||||
|
|
||||||
|
// import React, { useEffect, useState, Fragment } from 'react'
|
||||||
|
// import { useRouter } from 'next/navigation'
|
||||||
|
// import Image from 'next/image'
|
||||||
|
// import { Modal } from '../../components/modal' // Модалка из примера выше
|
||||||
|
|
||||||
|
// interface UserProfile {
|
||||||
|
// id: number
|
||||||
|
// username: string
|
||||||
|
// email: string
|
||||||
|
// full_name: string
|
||||||
|
// bio?: string
|
||||||
|
// avatar: string
|
||||||
|
// last_login: string
|
||||||
|
// date_joined: string
|
||||||
|
// }
|
||||||
|
|
||||||
|
// interface LinkItem {
|
||||||
|
// id: number
|
||||||
|
// title: string
|
||||||
|
// url: string
|
||||||
|
// icon?: string
|
||||||
|
// }
|
||||||
|
|
||||||
|
// interface Group {
|
||||||
|
// id: number
|
||||||
|
// name: string
|
||||||
|
// image?: string
|
||||||
|
// links: LinkItem[]
|
||||||
|
// _open?: boolean // флаг аккордеона
|
||||||
|
// }
|
||||||
|
|
||||||
|
// type ModalType =
|
||||||
|
// | 'addGroup'
|
||||||
|
// | 'editGroup'
|
||||||
|
// | 'deleteGroup'
|
||||||
|
// | 'addLink'
|
||||||
|
// | 'editLink'
|
||||||
|
// | 'deleteLink'
|
||||||
|
// | null
|
||||||
|
|
||||||
|
// export default function DashboardPage() {
|
||||||
|
// const router = useRouter()
|
||||||
|
// const API = process.env.NEXT_PUBLIC_API_URL
|
||||||
|
// const token = typeof window !== 'undefined' ? localStorage.getItem('token') : null
|
||||||
|
|
||||||
|
// const [user, setUser] = useState<UserProfile | null>(null)
|
||||||
|
// const [groups, setGroups] = useState<Group[]>([])
|
||||||
|
// const [loading, setLoading] = useState(true)
|
||||||
|
// const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// // для модалки
|
||||||
|
// const [modalType, setModalType] = useState<ModalType>(null)
|
||||||
|
// const [currentGroup, setCurrentGroup] = useState<Group | null>(null)
|
||||||
|
// const [currentLink, setCurrentLink] = useState<LinkItem | null>(null)
|
||||||
|
// const [formValue, setFormValue] = useState<{ name?: string; url?: string }>({})
|
||||||
|
|
||||||
|
// // загрузка пользователя и групп
|
||||||
|
// useEffect(() => {
|
||||||
|
// if (!token) {
|
||||||
|
// router.replace('/auth/login')
|
||||||
|
// return
|
||||||
|
// }
|
||||||
|
// Promise.all([
|
||||||
|
// fetch(`${API}/api/auth/user/`, {
|
||||||
|
// headers: { Authorization: `Bearer ${token}` },
|
||||||
|
// }),
|
||||||
|
// fetch(`${API}/api/groups/`, {
|
||||||
|
// headers: { Authorization: `Bearer ${token}` },
|
||||||
|
// }),
|
||||||
|
// ])
|
||||||
|
// .then(async ([uRes, gRes]) => {
|
||||||
|
// if (!uRes.ok) throw new Error('Не удалось получить профиль')
|
||||||
|
// if (!gRes.ok) throw new Error('Не удалось загрузить группы')
|
||||||
|
// const [u, g] = await Promise.all([uRes.json(), gRes.json()])
|
||||||
|
// setUser(u)
|
||||||
|
// setGroups(g)
|
||||||
|
// })
|
||||||
|
// .catch((err) => setError((err as Error).message))
|
||||||
|
// .finally(() => setLoading(false))
|
||||||
|
// }, [router, API, token])
|
||||||
|
|
||||||
|
// if (loading) return <div className="text-center mt-5">Загрузка...</div>
|
||||||
|
// if (error) return <div className="alert alert-danger m-5">{error}</div>
|
||||||
|
|
||||||
|
// // закрыть модалку
|
||||||
|
// const closeModal = () => {
|
||||||
|
// setModalType(null)
|
||||||
|
// setCurrentGroup(null)
|
||||||
|
// setCurrentLink(null)
|
||||||
|
// setFormValue({})
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // === Группы ===
|
||||||
|
// async function handleGroupSubmit() {
|
||||||
|
// if (!modalType) return
|
||||||
|
// const isNew = modalType === 'addGroup'
|
||||||
|
// const url = isNew
|
||||||
|
// ? `${API}/api/groups/`
|
||||||
|
// : `${API}/api/groups/${currentGroup!.id}/`
|
||||||
|
// const method = isNew ? 'POST' : 'PUT'
|
||||||
|
// const res = await fetch(url, {
|
||||||
|
// method,
|
||||||
|
// headers: {
|
||||||
|
// 'Content-Type': 'application/json',
|
||||||
|
// Authorization: `Bearer ${token}`,
|
||||||
|
// },
|
||||||
|
// body: JSON.stringify({ name: formValue.name }),
|
||||||
|
// })
|
||||||
|
// if (res.ok) {
|
||||||
|
// const grp = await res.json()
|
||||||
|
// setGroups((prev) =>
|
||||||
|
// isNew
|
||||||
|
// ? [...prev, { ...grp, links: [] }]
|
||||||
|
// : prev.map((g) => (g.id === grp.id ? { ...g, name: grp.name } : g))
|
||||||
|
// )
|
||||||
|
// closeModal()
|
||||||
|
// } else {
|
||||||
|
// alert('Ошибка при сохранении группы')
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// async function handleGroupDelete() {
|
||||||
|
// if (!currentGroup) return
|
||||||
|
// const res = await fetch(`${API}/api/groups/${currentGroup.id}/`, {
|
||||||
|
// method: 'DELETE',
|
||||||
|
// headers: { Authorization: `Bearer ${token}` },
|
||||||
|
// })
|
||||||
|
// if (res.ok) {
|
||||||
|
// setGroups((prev) => prev.filter((g) => g.id !== currentGroup.id))
|
||||||
|
// closeModal()
|
||||||
|
// } else {
|
||||||
|
// alert('Ошибка при удалении группы')
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // === Ссылки ===
|
||||||
|
// async function handleLinkSubmit() {
|
||||||
|
// if (!currentGroup) return
|
||||||
|
// const isEdit = modalType === 'editLink'
|
||||||
|
// const url = isEdit
|
||||||
|
// ? `${API}/api/links/${currentLink!.id}/`
|
||||||
|
// : `${API}/api/links/`
|
||||||
|
// const method = isEdit ? 'PUT' : 'POST'
|
||||||
|
// const payload = {
|
||||||
|
// title: formValue.name,
|
||||||
|
// url: formValue.url,
|
||||||
|
// group: currentGroup.id,
|
||||||
|
// }
|
||||||
|
// const res = await fetch(url, {
|
||||||
|
// method,
|
||||||
|
// headers: {
|
||||||
|
// 'Content-Type': 'application/json',
|
||||||
|
// Authorization: `Bearer ${token}`,
|
||||||
|
// },
|
||||||
|
// body: JSON.stringify(payload),
|
||||||
|
// })
|
||||||
|
// if (res.ok) {
|
||||||
|
// const linkData = await res.json()
|
||||||
|
// setGroups((prev) =>
|
||||||
|
// prev.map((g) =>
|
||||||
|
// g.id === currentGroup.id
|
||||||
|
// ? {
|
||||||
|
// ...g,
|
||||||
|
// links: isEdit
|
||||||
|
// ? g.links.map((l) => (l.id === linkData.id ? linkData : l))
|
||||||
|
// : [...g.links, linkData],
|
||||||
|
// }
|
||||||
|
// : g
|
||||||
|
// )
|
||||||
|
// )
|
||||||
|
// closeModal()
|
||||||
|
// } else {
|
||||||
|
// alert('Ошибка при сохранении ссылки')
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// async function handleLinkDelete() {
|
||||||
|
// if (!currentLink || !currentGroup) return
|
||||||
|
// const res = await fetch(`${API}/api/links/${currentLink.id}/`, {
|
||||||
|
// method: 'DELETE',
|
||||||
|
// headers: { Authorization: `Bearer ${token}` },
|
||||||
|
// })
|
||||||
|
// if (res.ok) {
|
||||||
|
// setGroups((prev) =>
|
||||||
|
// prev.map((g) =>
|
||||||
|
// g.id === currentGroup.id
|
||||||
|
// ? { ...g, links: g.links.filter((l) => l.id !== currentLink.id) }
|
||||||
|
// : g
|
||||||
|
// )
|
||||||
|
// )
|
||||||
|
// closeModal()
|
||||||
|
// } else {
|
||||||
|
// alert('Ошибка при удалении ссылки')
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// return (
|
||||||
|
// <div className="container py-5">
|
||||||
|
// {/* Профиль */}
|
||||||
|
// {user && (
|
||||||
|
// <div className="card mb-4 shadow-sm">
|
||||||
|
// <div className="card-body d-flex align-items-center">
|
||||||
|
// <Image
|
||||||
|
// src={user.avatar}
|
||||||
|
// alt="Avatar"
|
||||||
|
// width={60}
|
||||||
|
// height={60}
|
||||||
|
// className="rounded-circle me-3"
|
||||||
|
// />
|
||||||
|
// <div>
|
||||||
|
// <h5 className="card-title mb-1">
|
||||||
|
// {user.full_name || user.username}
|
||||||
|
// </h5>
|
||||||
|
// <p className="mb-0 text-muted small">{user.email}</p>
|
||||||
|
// </div>
|
||||||
|
// </div>
|
||||||
|
// </div>
|
||||||
|
// )}
|
||||||
|
|
||||||
|
// {/* Заголовок с кнопкой «Добавить группу» */}
|
||||||
|
// <div className="mb-3 d-flex justify-content-between align-items-center">
|
||||||
|
// <h4>Группы ссылок</h4>
|
||||||
|
// <button
|
||||||
|
// className="btn btn-sm btn-success"
|
||||||
|
// onClick={() => setModalType('addGroup')}
|
||||||
|
// >
|
||||||
|
// <i className="bi bi-plus-lg"></i> Добавить
|
||||||
|
// </button>
|
||||||
|
// </div>
|
||||||
|
|
||||||
|
// {/* Список групп (аккордеон) */}
|
||||||
|
// {groups.map((group) => (
|
||||||
|
// <Fragment key={group.id}>
|
||||||
|
// <div className="card mb-2">
|
||||||
|
// <div className="card-header d-flex justify-content-between">
|
||||||
|
// <div>
|
||||||
|
// {/* Редактировать имя группы */}
|
||||||
|
// <a
|
||||||
|
// href="#!"
|
||||||
|
// className="me-2"
|
||||||
|
// onClick={(e) => {
|
||||||
|
// e.preventDefault()
|
||||||
|
// setCurrentGroup(group)
|
||||||
|
// setFormValue({ name: group.name })
|
||||||
|
// setModalType('editGroup')
|
||||||
|
// }}
|
||||||
|
// >
|
||||||
|
// <i className="bi bi-pencil"></i>
|
||||||
|
// </a>
|
||||||
|
// {/* Открыть/закрыть аккордеон */}
|
||||||
|
// <span
|
||||||
|
// className="fw-semibold"
|
||||||
|
// style={{ cursor: 'pointer' }}
|
||||||
|
// onClick={() =>
|
||||||
|
// setGroups((pg) =>
|
||||||
|
// pg.map((g) =>
|
||||||
|
// g.id === group.id
|
||||||
|
// ? { ...g, _open: !g._open }
|
||||||
|
// : g
|
||||||
|
// )
|
||||||
|
// )
|
||||||
|
// }
|
||||||
|
// >
|
||||||
|
// {group.name}{' '}
|
||||||
|
// <span className="badge bg-secondary">
|
||||||
|
// {group.links.length}
|
||||||
|
// </span>
|
||||||
|
// </span>
|
||||||
|
// </div>
|
||||||
|
// <div>
|
||||||
|
// {/* Добавить ссылку */}
|
||||||
|
// <button
|
||||||
|
// className="btn btn-sm btn-outline-primary me-2"
|
||||||
|
// onClick={() => {
|
||||||
|
// setCurrentGroup(group)
|
||||||
|
// setFormValue({ name: '', url: '' })
|
||||||
|
// setModalType('addLink')
|
||||||
|
// }}
|
||||||
|
// >
|
||||||
|
// <i className="bi bi-link-45deg"></i>
|
||||||
|
// </button>
|
||||||
|
// {/* Удалить группу */}
|
||||||
|
// <button
|
||||||
|
// className="btn btn-sm btn-outline-danger"
|
||||||
|
// onClick={() => {
|
||||||
|
// setCurrentGroup(group)
|
||||||
|
// setModalType('deleteGroup')
|
||||||
|
// }}
|
||||||
|
// >
|
||||||
|
// <i className="bi bi-trash"></i>
|
||||||
|
// </button>
|
||||||
|
// </div>
|
||||||
|
// </div>
|
||||||
|
// {/* Ссылки в раскрытом состоянии */}
|
||||||
|
// {group._open && (
|
||||||
|
// <ul className="list-group list-group-flush">
|
||||||
|
// {group.links.map((link) => (
|
||||||
|
// <li
|
||||||
|
// key={link.id}
|
||||||
|
// className="list-group-item d-flex justify-content-between"
|
||||||
|
// >
|
||||||
|
// <a
|
||||||
|
// href={link.url}
|
||||||
|
// target="_blank"
|
||||||
|
// rel="noopener noreferrer"
|
||||||
|
// className="flex-grow-1"
|
||||||
|
// >
|
||||||
|
// {link.title}
|
||||||
|
// </a>
|
||||||
|
// <div className="btn-group btn-group-sm">
|
||||||
|
// <button
|
||||||
|
// className="btn btn-outline-secondary"
|
||||||
|
// onClick={() => {
|
||||||
|
// setCurrentGroup(group)
|
||||||
|
// setCurrentLink(link)
|
||||||
|
// setFormValue({ name: link.title, url: link.url })
|
||||||
|
// setModalType('editLink')
|
||||||
|
// }}
|
||||||
|
// >
|
||||||
|
// <i className="bi bi-pencil-fill"></i>
|
||||||
|
// </button>
|
||||||
|
// <button
|
||||||
|
// className="btn btn-outline-danger"
|
||||||
|
// onClick={() => {
|
||||||
|
// setCurrentGroup(group)
|
||||||
|
// setCurrentLink(link)
|
||||||
|
// setModalType('deleteLink')
|
||||||
|
// }}
|
||||||
|
// >
|
||||||
|
// <i className="bi bi-trash-fill"></i>
|
||||||
|
// </button>
|
||||||
|
// </div>
|
||||||
|
// </li>
|
||||||
|
// ))}
|
||||||
|
// </ul>
|
||||||
|
// )}
|
||||||
|
// </div>
|
||||||
|
// </Fragment>
|
||||||
|
// ))}
|
||||||
|
|
||||||
|
// {/* Универсальная модалка */}
|
||||||
|
// {modalType && (
|
||||||
|
// <Modal onClose={closeModal}>
|
||||||
|
// {/* Группа: добавить/редактировать */}
|
||||||
|
// {(modalType === 'addGroup' || modalType === 'editGroup') && (
|
||||||
|
// <>
|
||||||
|
// <h5>
|
||||||
|
// {modalType === 'addGroup' ? 'Новая группа' : 'Редактировать группу'}
|
||||||
|
// </h5>
|
||||||
|
// <input
|
||||||
|
// type="text"
|
||||||
|
// className="form-control mb-3"
|
||||||
|
// placeholder="Название группы"
|
||||||
|
// value={formValue.name || ''}
|
||||||
|
// onChange={(e) =>
|
||||||
|
// setFormValue({ ...formValue, name: e.target.value })
|
||||||
|
// }
|
||||||
|
// />
|
||||||
|
// <button className="btn btn-primary" onClick={handleGroupSubmit}>
|
||||||
|
// Сохранить
|
||||||
|
// </button>
|
||||||
|
// </>
|
||||||
|
// )}
|
||||||
|
// {/* Подтверждение удаления группы */}
|
||||||
|
// {modalType === 'deleteGroup' && (
|
||||||
|
// <>
|
||||||
|
// <h5>Удалить группу?</h5>
|
||||||
|
// <p>Группа «{currentGroup?.name}» будет удалена навсегда.</p>
|
||||||
|
// <button className="btn btn-danger me-2" onClick={handleGroupDelete}>
|
||||||
|
// Удалить
|
||||||
|
// </button>
|
||||||
|
// <button className="btn btn-secondary" onClick={closeModal}>
|
||||||
|
// Отмена
|
||||||
|
// </button>
|
||||||
|
// </>
|
||||||
|
// )}
|
||||||
|
// {/* Ссылка: добавить/редактировать */}
|
||||||
|
// {(modalType === 'addLink' || modalType === 'editLink') && (
|
||||||
|
// <>
|
||||||
|
// <h5>{modalType === 'addLink' ? 'Новая ссылка' : 'Редактировать ссылку'}</h5>
|
||||||
|
// <input
|
||||||
|
// type="text"
|
||||||
|
// className="form-control mb-2"
|
||||||
|
// placeholder="Название"
|
||||||
|
// value={formValue.name || ''}
|
||||||
|
// onChange={(e) =>
|
||||||
|
// setFormValue({ ...formValue, name: e.target.value })
|
||||||
|
// }
|
||||||
|
// />
|
||||||
|
// <input
|
||||||
|
// type="url"
|
||||||
|
// className="form-control mb-3"
|
||||||
|
// placeholder="URL"
|
||||||
|
// value={formValue.url || ''}
|
||||||
|
// onChange={(e) =>
|
||||||
|
// setFormValue({ ...formValue, url: e.target.value })
|
||||||
|
// }
|
||||||
|
// />
|
||||||
|
// <button className="btn btn-primary" onClick={handleLinkSubmit}>
|
||||||
|
// Сохранить
|
||||||
|
// </button>
|
||||||
|
// </>
|
||||||
|
// )}
|
||||||
|
// {/* Подтверждение удаления ссылки */}
|
||||||
|
// {modalType === 'deleteLink' && (
|
||||||
|
// <>
|
||||||
|
// <h5>Удалить ссылку?</h5>
|
||||||
|
// <p>Ссылка «{currentLink?.title}» будет удалена.</p>
|
||||||
|
// <button className="btn btn-danger me-2" onClick={handleLinkDelete}>
|
||||||
|
// Удалить
|
||||||
|
// </button>
|
||||||
|
// <button className="btn btn-secondary" onClick={closeModal}>
|
||||||
|
// Отмена
|
||||||
|
// </button>
|
||||||
|
// </>
|
||||||
|
// )}
|
||||||
|
// </Modal>
|
||||||
|
// )}
|
||||||
|
// </div>
|
||||||
|
// )
|
||||||
|
// }
|
||||||
|
|
||||||
|
// src/app/(protected)/dashboard/page.tsx
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import React, { useEffect, useState, Fragment } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { ProfileCard } from '../../components/ProfileCard'
|
||||||
|
|
||||||
|
interface UserProfile {
|
||||||
|
id: number
|
||||||
|
username: string
|
||||||
|
email: string
|
||||||
|
full_name: string
|
||||||
|
bio?: string
|
||||||
|
avatar: string
|
||||||
|
last_login: string
|
||||||
|
date_joined: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LinkItem {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
url: string
|
||||||
|
icon?: string
|
||||||
|
group: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Group {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
icon?: string
|
||||||
|
// we'll always fill this in as an array:
|
||||||
|
links: LinkItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const [user, setUser] = useState<UserProfile | null>(null)
|
||||||
|
const [groups, setGroups] = useState<Group[]>([])
|
||||||
|
const [expandedGroup, setExpandedGroup] = useState<number | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
if (!token) {
|
||||||
|
router.push('/auth/login')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const API = process.env.NEXT_PUBLIC_API_URL ?? ''
|
||||||
|
|
||||||
|
// 1) fetch profile
|
||||||
|
// 2) fetch all groups
|
||||||
|
// 3) fetch all links
|
||||||
|
Promise.all([
|
||||||
|
fetch(`${API}/api/auth/user/`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
}),
|
||||||
|
fetch(`${API}/api/groups/`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
}),
|
||||||
|
fetch(`${API}/api/links/`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
.then(async ([uRes, gRes, lRes]) => {
|
||||||
|
if (!uRes.ok) throw new Error('Не удалось получить профиль')
|
||||||
|
if (!gRes.ok) throw new Error('Не удалось загрузить группы')
|
||||||
|
if (!lRes.ok) throw new Error('Не удалось загрузить ссылки')
|
||||||
|
|
||||||
|
const userData = await uRes.json()
|
||||||
|
const groupsData = await gRes.json()
|
||||||
|
const linksData = await lRes.json()
|
||||||
|
|
||||||
|
// group.links might not exist on the payload, so we build it:
|
||||||
|
const enrichedGroups: Group[] = groupsData.map((grp: any) => ({
|
||||||
|
...grp,
|
||||||
|
links: linksData.filter((link: LinkItem) => link.group === grp.id)
|
||||||
|
}))
|
||||||
|
|
||||||
|
setUser(userData)
|
||||||
|
setGroups(enrichedGroups)
|
||||||
|
})
|
||||||
|
.catch(err => setError((err as Error).message))
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [router])
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="flex items-center justify-center h-screen">Загрузка...</div>
|
||||||
|
}
|
||||||
|
if (error) {
|
||||||
|
return <div className="p-6 text-red-600 max-w-xl mx-auto">{error}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Подсчёт: всего групп и всего ссылок
|
||||||
|
const totalGroups = groups.length
|
||||||
|
const totalLinks = groups.reduce((sum, grp) => sum + grp.links.length, 0)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pb-8">
|
||||||
|
{user && (
|
||||||
|
<ProfileCard
|
||||||
|
avatar={user.avatar}
|
||||||
|
full_name={user.full_name}
|
||||||
|
email={user.email}
|
||||||
|
bio={user.bio}
|
||||||
|
last_login={user.last_login}
|
||||||
|
date_joined={user.date_joined}
|
||||||
|
totalGroups={totalGroups}
|
||||||
|
totalLinks={totalLinks}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section className="mt-5 container">
|
||||||
|
<div className="card shadow">
|
||||||
|
<div className="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<h5 className="mb-0">Группы ссылок</h5>
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-success"
|
||||||
|
onClick={() => {
|
||||||
|
/* open “add group” modal */
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<i className="bi bi-plus-lg"></i> Добавить группу
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="list-group list-group-flush">
|
||||||
|
{groups.map((group) => (
|
||||||
|
<Fragment key={group.id}>
|
||||||
|
<div className="list-group-item d-flex justify-content-between align-items-center">
|
||||||
|
<div
|
||||||
|
className="d-flex align-items-center"
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
onClick={() =>
|
||||||
|
setExpandedGroup(expandedGroup === group.id ? null : group.id)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{group.icon && (
|
||||||
|
<img
|
||||||
|
src={`${process.env.NEXT_PUBLIC_API_URL}${group.icon}`}
|
||||||
|
width={32}
|
||||||
|
height={32}
|
||||||
|
className="me-2 rounded"
|
||||||
|
alt={group.name}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<strong className="me-2">{group.name}</strong>
|
||||||
|
<span className="badge bg-secondary rounded-pill">
|
||||||
|
{group.links.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="btn-group btn-group-sm">
|
||||||
|
<button className="btn btn-outline-primary">
|
||||||
|
<i className="bi bi-link-45deg"></i>
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-outline-secondary">
|
||||||
|
<i className="bi bi-pencil"></i>
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-outline-danger">
|
||||||
|
<i className="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{expandedGroup === group.id && (
|
||||||
|
<div className="list-group-item bg-light">
|
||||||
|
<ul className="mb-0 ps-3">
|
||||||
|
{group.links.map((link) => (
|
||||||
|
<li
|
||||||
|
key={link.id}
|
||||||
|
className="d-flex justify-content-between align-items-center mb-2"
|
||||||
|
>
|
||||||
|
<div className="d-flex align-items-center">
|
||||||
|
{link.icon && (
|
||||||
|
<img
|
||||||
|
src={link.icon.startsWith('http')
|
||||||
|
? link.icon
|
||||||
|
: `${process.env.NEXT_PUBLIC_API_URL}${link.icon}`}
|
||||||
|
width={24}
|
||||||
|
height={24}
|
||||||
|
className="me-2"
|
||||||
|
alt={link.title}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<a
|
||||||
|
href={link.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
{link.title}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div className="btn-group btn-group-sm">
|
||||||
|
<button className="btn btn-outline-secondary">
|
||||||
|
<i className="bi bi-pencil-fill"></i>
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-outline-danger">
|
||||||
|
<i className="bi bi-trash-fill"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React, { useEffect, useState, Fragment } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { ProfileCard } from '../../components/ProfileCard'
|
||||||
|
|
||||||
|
interface UserProfile {
|
||||||
|
id: number
|
||||||
|
username: string
|
||||||
|
email: string
|
||||||
|
full_name: string
|
||||||
|
bio?: string
|
||||||
|
avatar: string
|
||||||
|
last_login: string
|
||||||
|
date_joined: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LinkItem {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
url: string
|
||||||
|
icon?: string
|
||||||
|
group: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Group {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
icon?: string
|
||||||
|
// we'll always fill this in as an array:
|
||||||
|
links: LinkItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const [user, setUser] = useState<UserProfile | null>(null)
|
||||||
|
const [groups, setGroups] = useState<Group[]>([])
|
||||||
|
const [expandedGroup, setExpandedGroup] = useState<number | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
if (!token) {
|
||||||
|
router.push('/auth/login')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const API = process.env.NEXT_PUBLIC_API_URL ?? ''
|
||||||
|
|
||||||
|
// 1) fetch profile
|
||||||
|
// 2) fetch all groups
|
||||||
|
// 3) fetch all links
|
||||||
|
Promise.all([
|
||||||
|
fetch(`${API}/api/auth/user/`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
}),
|
||||||
|
fetch(`${API}/api/groups/`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
}),
|
||||||
|
fetch(`${API}/api/links/`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
.then(async ([uRes, gRes, lRes]) => {
|
||||||
|
if (!uRes.ok) throw new Error('Не удалось получить профиль')
|
||||||
|
if (!gRes.ok) throw new Error('Не удалось загрузить группы')
|
||||||
|
if (!lRes.ok) throw new Error('Не удалось загрузить ссылки')
|
||||||
|
|
||||||
|
const userData = await uRes.json()
|
||||||
|
const groupsData = await gRes.json()
|
||||||
|
const linksData = await lRes.json()
|
||||||
|
|
||||||
|
// group.links might not exist on the payload, so we build it:
|
||||||
|
const enrichedGroups: Group[] = groupsData.map((grp: any) => ({
|
||||||
|
...grp,
|
||||||
|
links: linksData.filter((link: LinkItem) => link.group === grp.id)
|
||||||
|
}))
|
||||||
|
|
||||||
|
setUser(userData)
|
||||||
|
setGroups(enrichedGroups)
|
||||||
|
})
|
||||||
|
.catch(err => setError((err as Error).message))
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [router])
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="flex items-center justify-center h-screen">Загрузка...</div>
|
||||||
|
}
|
||||||
|
if (error) {
|
||||||
|
return <div className="p-6 text-red-600 max-w-xl mx-auto">{error}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Подсчёт: всего групп и всего ссылок
|
||||||
|
const totalGroups = groups.length
|
||||||
|
const totalLinks = groups.reduce((sum, grp) => sum + grp.links.length, 0)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pb-8">
|
||||||
|
{user && (
|
||||||
|
<ProfileCard
|
||||||
|
avatar={user.avatar}
|
||||||
|
full_name={user.full_name}
|
||||||
|
email={user.email}
|
||||||
|
bio={user.bio}
|
||||||
|
last_login={user.last_login}
|
||||||
|
date_joined={user.date_joined}
|
||||||
|
totalGroups={totalGroups}
|
||||||
|
totalLinks={totalLinks}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section className="mt-5 container">
|
||||||
|
<div className="card shadow">
|
||||||
|
<div className="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<h5 className="mb-0">Группы ссылок</h5>
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-success"
|
||||||
|
onClick={() => {
|
||||||
|
/* open “add group” modal */
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<i className="bi bi-plus-lg"></i> Добавить группу
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="list-group list-group-flush">
|
||||||
|
{groups.map((group) => (
|
||||||
|
<Fragment key={group.id}>
|
||||||
|
<div className="list-group-item d-flex justify-content-between align-items-center">
|
||||||
|
<div
|
||||||
|
className="d-flex align-items-center"
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
onClick={() =>
|
||||||
|
setExpandedGroup(expandedGroup === group.id ? null : group.id)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{group.icon && (
|
||||||
|
<img
|
||||||
|
src={`${process.env.NEXT_PUBLIC_API_URL}${group.icon}`}
|
||||||
|
width={32}
|
||||||
|
height={32}
|
||||||
|
className="me-2 rounded"
|
||||||
|
alt={group.name}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<strong className="me-2">{group.name}</strong>
|
||||||
|
<span className="badge bg-secondary rounded-pill">
|
||||||
|
{group.links.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="btn-group btn-group-sm">
|
||||||
|
<button className="btn btn-outline-primary">
|
||||||
|
<i className="bi bi-link-45deg"></i>
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-outline-secondary">
|
||||||
|
<i className="bi bi-pencil"></i>
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-outline-danger">
|
||||||
|
<i className="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{expandedGroup === group.id && (
|
||||||
|
<div className="list-group-item bg-light">
|
||||||
|
<ul className="mb-0 ps-3">
|
||||||
|
{group.links.map((link) => (
|
||||||
|
<li
|
||||||
|
key={link.id}
|
||||||
|
className="d-flex justify-content-between align-items-center mb-2"
|
||||||
|
>
|
||||||
|
<div className="d-flex align-items-center">
|
||||||
|
{link.icon && (
|
||||||
|
<img
|
||||||
|
src={link.icon.startsWith('http')
|
||||||
|
? link.icon
|
||||||
|
: `${process.env.NEXT_PUBLIC_API_URL}${link.icon}`}
|
||||||
|
width={24}
|
||||||
|
height={24}
|
||||||
|
className="me-2"
|
||||||
|
alt={link.title}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<a
|
||||||
|
href={link.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
{link.title}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div className="btn-group btn-group-sm">
|
||||||
|
<button className="btn btn-outline-secondary">
|
||||||
|
<i className="bi bi-pencil-fill"></i>
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-outline-danger">
|
||||||
|
<i className="bi bi-trash-fill"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,390 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React, { useEffect, useState, Fragment } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import Image from 'next/image'
|
||||||
|
import { ProfileCard } from '../../components/ProfileCard'
|
||||||
|
|
||||||
|
interface UserProfile {
|
||||||
|
id: number
|
||||||
|
username: string
|
||||||
|
email: string
|
||||||
|
full_name: string
|
||||||
|
bio?: string
|
||||||
|
avatar: string
|
||||||
|
last_login: string
|
||||||
|
date_joined: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LinkItem {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
url: string
|
||||||
|
icon?: string
|
||||||
|
group: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Group {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
icon?: string
|
||||||
|
links: LinkItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const [user, setUser] = useState<UserProfile | null>(null)
|
||||||
|
const [groups, setGroups] = useState<Group[]>([])
|
||||||
|
const [expandedGroup, setExpandedGroup] = useState<number | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// === Для модалок групп ===
|
||||||
|
const [showGroupModal, setShowGroupModal] = useState(false)
|
||||||
|
const [groupModalMode, setGroupModalMode] = useState<'add' | 'edit'>('add')
|
||||||
|
const [editingGroup, setEditingGroup] = useState<Group | null>(null)
|
||||||
|
const [groupForm, setGroupForm] = useState<{ name: string; iconFile: File | null }>({
|
||||||
|
name: '',
|
||||||
|
iconFile: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
// === Для модалок ссылок ===
|
||||||
|
const [showLinkModal, setShowLinkModal] = useState(false)
|
||||||
|
const [linkModalMode, setLinkModalMode] = useState<'add' | 'edit'>('add')
|
||||||
|
const [editingLink, setEditingLink] = useState<LinkItem | null>(null)
|
||||||
|
const [currentGroupIdForLink, setCurrentGroupIdForLink] = useState<number | null>(null)
|
||||||
|
const [linkForm, setLinkForm] = useState<{ title: string; url: string; iconFile: File | null }>({
|
||||||
|
title: '',
|
||||||
|
url: '',
|
||||||
|
iconFile: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const API = process.env.NEXT_PUBLIC_API_URL ?? ''
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
if (!token) {
|
||||||
|
router.push('/auth/login')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// загружаем профиль, группы и ссылки
|
||||||
|
Promise.all([
|
||||||
|
fetch(`${API}/api/auth/user/`, { headers: { Authorization: `Bearer ${token}` } }),
|
||||||
|
fetch(`${API}/api/groups/`, { headers: { Authorization: `Bearer ${token}` } }),
|
||||||
|
fetch(`${API}/api/links/`, { headers: { Authorization: `Bearer ${token}` } }),
|
||||||
|
])
|
||||||
|
.then(async ([uRes, gRes, lRes]) => {
|
||||||
|
if (!uRes.ok) throw new Error('Не удалось получить профиль')
|
||||||
|
if (!gRes.ok) throw new Error('Не удалось загрузить группы')
|
||||||
|
if (!lRes.ok) throw new Error('Не удалось загрузить ссылки')
|
||||||
|
const userData = await uRes.json()
|
||||||
|
const groupsData = await gRes.json()
|
||||||
|
const linksData = await lRes.json()
|
||||||
|
|
||||||
|
// «привязываем» ссылки к группам
|
||||||
|
const enrichedGroups: Group[] = groupsData.map((grp: any) => ({
|
||||||
|
...grp,
|
||||||
|
links: linksData.filter((link: LinkItem) => link.group === grp.id),
|
||||||
|
}))
|
||||||
|
setUser(userData)
|
||||||
|
setGroups(enrichedGroups)
|
||||||
|
})
|
||||||
|
.catch(err => setError((err as Error).message))
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [router])
|
||||||
|
|
||||||
|
// Перезагрузка списка групп и ссылок
|
||||||
|
async function reloadData() {
|
||||||
|
const token = localStorage.getItem('token')!
|
||||||
|
const [gRes, lRes] = await Promise.all([
|
||||||
|
fetch(`${API}/api/groups/`, { headers: { Authorization: `Bearer ${token}` } }),
|
||||||
|
fetch(`${API}/api/links/`, { headers: { Authorization: `Bearer ${token}` } }),
|
||||||
|
])
|
||||||
|
const groupsData = await gRes.json()
|
||||||
|
const linksData = await lRes.json()
|
||||||
|
const enrichedGroups: Group[] = groupsData.map((grp: any) => ({
|
||||||
|
...grp,
|
||||||
|
links: linksData.filter((link: LinkItem) => link.group === grp.id),
|
||||||
|
}))
|
||||||
|
setGroups(enrichedGroups)
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Обработчики групп ===
|
||||||
|
function openAddGroup() {
|
||||||
|
setGroupModalMode('add')
|
||||||
|
setGroupForm({ name: '', iconFile: null })
|
||||||
|
setShowGroupModal(true)
|
||||||
|
}
|
||||||
|
function openEditGroup(grp: Group) {
|
||||||
|
setGroupModalMode('edit')
|
||||||
|
setEditingGroup(grp)
|
||||||
|
setGroupForm({ name: grp.name, iconFile: null })
|
||||||
|
setShowGroupModal(true)
|
||||||
|
}
|
||||||
|
async function handleGroupSubmit() {
|
||||||
|
const token = localStorage.getItem('token')!
|
||||||
|
const fd = new FormData()
|
||||||
|
fd.append('name', groupForm.name)
|
||||||
|
if (groupForm.iconFile) fd.append('icon', groupForm.iconFile)
|
||||||
|
const url = groupModalMode === 'add'
|
||||||
|
? `${API}/api/groups/`
|
||||||
|
: `${API}/api/groups/${editingGroup?.id}/`
|
||||||
|
const method = groupModalMode === 'add' ? 'POST' : 'PATCH'
|
||||||
|
await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
body: fd,
|
||||||
|
})
|
||||||
|
setShowGroupModal(false)
|
||||||
|
await reloadData()
|
||||||
|
}
|
||||||
|
async function handleDeleteGroup(grp: Group) {
|
||||||
|
if (!confirm(`Удалить группу "${grp.name}"?`)) return
|
||||||
|
const token = localStorage.getItem('token')!
|
||||||
|
await fetch(`${API}/api/groups/${grp.id}/`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
await reloadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Обработчики ссылок ===
|
||||||
|
function openAddLink(grp: Group) {
|
||||||
|
setLinkModalMode('add')
|
||||||
|
setCurrentGroupIdForLink(grp.id)
|
||||||
|
setLinkForm({ title: '', url: '', iconFile: null })
|
||||||
|
setShowLinkModal(true)
|
||||||
|
}
|
||||||
|
function openEditLink(link: LinkItem) {
|
||||||
|
setLinkModalMode('edit')
|
||||||
|
setEditingLink(link)
|
||||||
|
setCurrentGroupIdForLink(link.group)
|
||||||
|
setLinkForm({ title: link.title, url: link.url, iconFile: null })
|
||||||
|
setShowLinkModal(true)
|
||||||
|
}
|
||||||
|
async function handleLinkSubmit() {
|
||||||
|
const token = localStorage.getItem('token')!
|
||||||
|
const fd = new FormData()
|
||||||
|
fd.append('title', linkForm.title)
|
||||||
|
fd.append('url', linkForm.url)
|
||||||
|
if (linkForm.iconFile) fd.append('icon', linkForm.iconFile)
|
||||||
|
fd.append('group', String(currentGroupIdForLink))
|
||||||
|
const url = linkModalMode === 'add'
|
||||||
|
? `${API}/api/links/`
|
||||||
|
: `${API}/api/links/${editingLink?.id}/`
|
||||||
|
const method = linkModalMode === 'add' ? 'POST' : 'PATCH'
|
||||||
|
await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
body: fd,
|
||||||
|
})
|
||||||
|
setShowLinkModal(false)
|
||||||
|
await reloadData()
|
||||||
|
}
|
||||||
|
async function handleDeleteLink(link: LinkItem) {
|
||||||
|
if (!confirm(`Удалить ссылку "${link.title}"?`)) return
|
||||||
|
const token = localStorage.getItem('token')!
|
||||||
|
await fetch(`${API}/api/links/${link.id}/`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
await reloadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) return <div className="flex items-center justify-center h-screen">Загрузка...</div>
|
||||||
|
if (error) return <div className="p-6 text-red-600 max-w-xl mx-auto">{error}</div>
|
||||||
|
|
||||||
|
const totalGroups = groups.length
|
||||||
|
const totalLinks = groups.reduce((sum, grp) => sum + grp.links.length, 0)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pb-8">
|
||||||
|
{user && (
|
||||||
|
<ProfileCard
|
||||||
|
avatar={user.avatar}
|
||||||
|
full_name={user.full_name}
|
||||||
|
email={user.email}
|
||||||
|
bio={user.bio}
|
||||||
|
last_login={user.last_login}
|
||||||
|
date_joined={user.date_joined}
|
||||||
|
totalGroups={totalGroups}
|
||||||
|
totalLinks={totalLinks}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section className="mt-5 container">
|
||||||
|
<div className="card shadow">
|
||||||
|
<div className="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<h5 className="mb-0">Группы ссылок</h5>
|
||||||
|
<button className="btn btn-sm btn-success" onClick={openAddGroup}>
|
||||||
|
<i className="bi bi-plus-lg"></i> Добавить группу
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="list-group list-group-flush">
|
||||||
|
{groups.map(group => (
|
||||||
|
<Fragment key={group.id}>
|
||||||
|
<div className="list-group-item d-flex justify-content-between align-items-center">
|
||||||
|
<div
|
||||||
|
className="d-flex align-items-center"
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
onClick={() =>
|
||||||
|
setExpandedGroup(expandedGroup === group.id ? null : group.id)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{group.icon && (
|
||||||
|
<img
|
||||||
|
src={`${API}${group.icon}`}
|
||||||
|
width={32}
|
||||||
|
height={32}
|
||||||
|
className="me-2 rounded"
|
||||||
|
alt={group.name}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<strong className="me-2">{group.name}</strong>
|
||||||
|
<span className="badge bg-secondary rounded-pill">
|
||||||
|
{group.links.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="btn-group btn-group-sm">
|
||||||
|
<button onClick={() => openAddLink(group)} className="btn btn-outline-primary">
|
||||||
|
<i className="bi bi-link-45deg"></i>
|
||||||
|
</button>
|
||||||
|
<button onClick={() => openEditGroup(group)} className="btn btn-outline-secondary">
|
||||||
|
<i className="bi bi-pencil"></i>
|
||||||
|
</button>
|
||||||
|
<button onClick={() => handleDeleteGroup(group)} className="btn btn-outline-danger">
|
||||||
|
<i className="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{expandedGroup === group.id && (
|
||||||
|
<div className="list-group-item bg-light">
|
||||||
|
<ul className="mb-0 ps-3">
|
||||||
|
{group.links.map(link => (
|
||||||
|
<li
|
||||||
|
key={link.id}
|
||||||
|
className="d-flex justify-content-between align-items-center mb-2"
|
||||||
|
>
|
||||||
|
<div className="d-flex align-items-center">
|
||||||
|
{link.icon && (
|
||||||
|
<img
|
||||||
|
src={link.icon.startsWith('http')
|
||||||
|
? link.icon
|
||||||
|
: `${API}${link.icon}`}
|
||||||
|
width={24}
|
||||||
|
height={24}
|
||||||
|
className="me-2"
|
||||||
|
alt={link.title}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<a href={link.url} target="_blank" rel="noopener noreferrer">
|
||||||
|
{link.title}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div className="btn-group btn-group-sm">
|
||||||
|
<button onClick={() => openEditLink(link)} className="btn btn-outline-secondary">
|
||||||
|
<i className="bi bi-pencil-fill"></i>
|
||||||
|
</button>
|
||||||
|
<button onClick={() => handleDeleteLink(link)} className="btn btn-outline-danger">
|
||||||
|
<i className="bi bi-trash-fill"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Модалка добавления/редактирования группы */}
|
||||||
|
<div className={`modal ${showGroupModal ? 'd-block' : 'd-none'}`} tabIndex={-1}>
|
||||||
|
<div className="modal-dialog">
|
||||||
|
<div className="modal-content">
|
||||||
|
<div className="modal-header">
|
||||||
|
<h5 className="modal-title">{groupModalMode === 'add' ? 'Добавить группу' : 'Редактировать группу'}</h5>
|
||||||
|
<button type="button" className="btn-close" onClick={() => setShowGroupModal(false)} />
|
||||||
|
</div>
|
||||||
|
<div className="modal-body">
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">Название</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
value={groupForm.name}
|
||||||
|
onChange={e => setGroupForm(f => ({ ...f, name: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">Иконка (опционально)</label>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
className="form-control"
|
||||||
|
onChange={e => setGroupForm(f => ({ ...f, iconFile: e.target.files?.[0] || null }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="modal-footer">
|
||||||
|
<button className="btn btn-secondary" onClick={() => setShowGroupModal(false)}>Отмена</button>
|
||||||
|
<button className="btn btn-primary" onClick={handleGroupSubmit}>
|
||||||
|
Сохранить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Модалка добавления/редактирования ссылки */}
|
||||||
|
<div className={`modal ${showLinkModal ? 'd-block' : 'd-none'}`} tabIndex={-1}>
|
||||||
|
<div className="modal-dialog">
|
||||||
|
<div className="modal-content">
|
||||||
|
<div className="modal-header">
|
||||||
|
<h5 className="modal-title">{linkModalMode === 'add' ? 'Добавить ссылку' : 'Редактировать ссылку'}</h5>
|
||||||
|
<button type="button" className="btn-close" onClick={() => setShowLinkModal(false)} />
|
||||||
|
</div>
|
||||||
|
<div className="modal-body">
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">Заголовок</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
value={linkForm.title}
|
||||||
|
onChange={e => setLinkForm(f => ({ ...f, title: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">URL</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
className="form-control"
|
||||||
|
value={linkForm.url}
|
||||||
|
onChange={e => setLinkForm(f => ({ ...f, url: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">Иконка (опционально)</label>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
className="form-control"
|
||||||
|
onChange={e => setLinkForm(f => ({ ...f, iconFile: e.target.files?.[0] || null }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="modal-footer">
|
||||||
|
<button className="btn btn-secondary" onClick={() => setShowLinkModal(false)}>Отмена</button>
|
||||||
|
<button className="btn btn-primary" onClick={handleLinkSubmit}>
|
||||||
|
Сохранить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
// src/app/(protected)/layout.tsx
|
||||||
|
"use client"
|
||||||
|
import { ReactNode } from "react"
|
||||||
|
import { Header } from "../components/Header"
|
||||||
|
import { Footer } from "../components/Footer"
|
||||||
|
|
||||||
|
export default function ProtectedLayout({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header />
|
||||||
|
<main className="mt-4">{children}</main>
|
||||||
|
<Footer />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
// src/app/layout.tsx
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
|
import "./globals.css";
|
||||||
|
import { ReactNode } from "react";
|
||||||
|
import Script from "next/script";
|
||||||
|
import Link from "next/link";
|
||||||
|
import Image from "next/image";
|
||||||
|
|
||||||
|
const geistSans = Geist({
|
||||||
|
variable: "--font-geist-sans",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const geistMono = Geist_Mono({
|
||||||
|
variable: "--font-geist-mono",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "CatLink",
|
||||||
|
description: "Ваши ссылки в одном месте",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charSet="utf-8" />
|
||||||
|
<link
|
||||||
|
rel="apple-touch-icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="180x180"
|
||||||
|
href="/assets/img/apple-touch-icon.png?h=0f5e29c1169e75a7003e818478b67caa"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="96x96"
|
||||||
|
href="/assets/img/favicon-96x96.png?h=c8792ce927e01a494c4af48bed5dc5e3"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="192x192"
|
||||||
|
href="/assets/img/web-app-manifest-192x192.png?h=c8792ce927e01a494c4af48bed5dc5e3"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="512x512"
|
||||||
|
href="/assets/img/web-app-manifest-512x512.png?h=c8792ce927e01a494c4af48bed5dc5e3"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="/assets/bootstrap/css/bootstrap.min.css?h=608a9825a1f76f674715160908e57785"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="/assets/css/Lato.css?h=8253736d3a23b522f64b7e7d96d1d8ff"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="manifest"
|
||||||
|
href="/manifest.json?h=457941ffad3c027c946331c09a4d7d2f"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css"
|
||||||
|
/>
|
||||||
|
<link rel="manifest" href="/manifest.json" />
|
||||||
|
<meta name="theme-color" content="#ffffff" />
|
||||||
|
|
||||||
|
</head>
|
||||||
|
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
|
||||||
|
{/* Header (fixed to top) */}
|
||||||
|
<nav className="navbar navbar-expand bg-light fixed-top shadow-sm">
|
||||||
|
<div className="container">
|
||||||
|
<Link href="/" className="navbar-brand d-flex align-items-center">
|
||||||
|
<Image
|
||||||
|
src="/assets/img/CAT.png"
|
||||||
|
alt="CatLink"
|
||||||
|
width={89}
|
||||||
|
height={89}
|
||||||
|
/>
|
||||||
|
<span className="ms-2">CatLink</span>
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
className="navbar-toggler"
|
||||||
|
type="button"
|
||||||
|
data-bs-toggle="collapse"
|
||||||
|
data-bs-target="#navcol-1"
|
||||||
|
/>
|
||||||
|
<div className="collapse navbar-collapse" id="navcol-1">
|
||||||
|
<Link href="/auth/login" className="btn btn-primary ms-auto">
|
||||||
|
<i className="fa fa-user"></i>
|
||||||
|
<span className="d-none d-sm-inline"> Вход</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Main content: добавляем дополнительный отступ сверху */}
|
||||||
|
<main
|
||||||
|
style={{
|
||||||
|
paddingTop: "100px", // <-- тут увеличиваем интервал
|
||||||
|
paddingBottom: "200px"
|
||||||
|
}}
|
||||||
|
// или через класс bootstrap: className="pt-5 pb-5"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
{/* Footer (fixed to bottom) */}
|
||||||
|
<footer className="bg-light footer fixed-bottom border-top">
|
||||||
|
<div className="container py-2">
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-lg-6 text-center text-lg-start mb-2 mb-lg-0">
|
||||||
|
<ul className="list-inline mb-1">
|
||||||
|
<li className="list-inline-item">
|
||||||
|
<Link href="#">About</Link>
|
||||||
|
</li>
|
||||||
|
<li className="list-inline-item">⋅</li>
|
||||||
|
<li className="list-inline-item">
|
||||||
|
<Link href="#">Contact</Link>
|
||||||
|
</li>
|
||||||
|
<li className="list-inline-item">⋅</li>
|
||||||
|
<li className="list-inline-item">
|
||||||
|
<Link href="#">Terms of Use</Link>
|
||||||
|
</li>
|
||||||
|
<li className="list-inline-item">⋅</li>
|
||||||
|
<li className="list-inline-item">
|
||||||
|
<Link href="#">Privacy Policy</Link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p className="text-muted small mb-0">© CatLink 2025</p>
|
||||||
|
</div>
|
||||||
|
<div className="col-lg-6 text-center text-lg-end">
|
||||||
|
<ul className="list-inline mb-0">
|
||||||
|
<li className="list-inline-item">
|
||||||
|
<Link href="#">
|
||||||
|
<i className="fa fa-facebook fa-lg fa-fw"></i>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li className="list-inline-item">
|
||||||
|
<Link href="#">
|
||||||
|
<i className="fa fa-twitter fa-lg fa-fw"></i>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li className="list-inline-item">
|
||||||
|
<Link href="#">
|
||||||
|
<i className="fa fa-instagram fa-lg fa-fw"></i>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
{/* Scripts */}
|
||||||
|
<Script
|
||||||
|
src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.0/jquery.min.js"
|
||||||
|
strategy="beforeInteractive"
|
||||||
|
/>
|
||||||
|
<Script
|
||||||
|
src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
|
||||||
|
strategy="beforeInteractive"
|
||||||
|
/>
|
||||||
|
<Script src="/assets/js/bs-init.js" strategy="lazyOnload" />
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
// src/app/layout.tsx
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
|
import "../globals.css";
|
||||||
|
import { ReactNode } from "react";
|
||||||
|
import Script from "next/script";
|
||||||
|
import Link from "next/link";
|
||||||
|
import Image from "next/image";
|
||||||
|
|
||||||
|
const geistSans = Geist({
|
||||||
|
variable: "--font-geist-sans",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const geistMono = Geist_Mono({
|
||||||
|
variable: "--font-geist-mono",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "CatLink",
|
||||||
|
description: "Ваши ссылки в одном месте",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charSet="utf-8" />
|
||||||
|
<link
|
||||||
|
rel="apple-touch-icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="180x180"
|
||||||
|
href="/assets/img/apple-touch-icon.png?h=0f5e29c1169e75a7003e818478b67caa"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="96x96"
|
||||||
|
href="/assets/img/favicon-96x96.png?h=c8792ce927e01a494c4af48bed5dc5e3"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="192x192"
|
||||||
|
href="/assets/img/web-app-manifest-192x192.png?h=c8792ce927e01a494c4af48bed5dc5e3"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="512x512"
|
||||||
|
href="/assets/img/web-app-manifest-512x512.png?h=c8792ce927e01a494c4af48bed5dc5e3"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="/assets/bootstrap/css/bootstrap.min.css?h=608a9825a1f76f674715160908e57785"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="/assets/css/Lato.css?h=8253736d3a23b522f64b7e7d96d1d8ff"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="manifest"
|
||||||
|
href="/manifest.json?h=457941ffad3c027c946331c09a4d7d2f"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css"
|
||||||
|
/>
|
||||||
|
<link rel="manifest" href="/manifest.json" />
|
||||||
|
<meta name="theme-color" content="#ffffff" />
|
||||||
|
|
||||||
|
</head>
|
||||||
|
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
|
||||||
|
{/* Header (fixed to top) */}
|
||||||
|
<nav className="navbar navbar-expand bg-light fixed-top shadow-sm">
|
||||||
|
<div className="container">
|
||||||
|
<Link href="/" className="navbar-brand d-flex align-items-center">
|
||||||
|
<Image
|
||||||
|
src="/assets/img/CAT.png"
|
||||||
|
alt="CatLink"
|
||||||
|
width={89}
|
||||||
|
height={89}
|
||||||
|
/>
|
||||||
|
<span className="ms-2">CatLink</span>
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
className="navbar-toggler"
|
||||||
|
type="button"
|
||||||
|
data-bs-toggle="collapse"
|
||||||
|
data-bs-target="#navcol-1"
|
||||||
|
/>
|
||||||
|
<div className="collapse navbar-collapse" id="navcol-1">
|
||||||
|
<Link href="/auth/login" className="btn btn-primary ms-auto">
|
||||||
|
<i className="fa fa-user"></i>
|
||||||
|
<span className="d-none d-sm-inline"> Вход</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Main content: добавляем дополнительный отступ сверху */}
|
||||||
|
<main
|
||||||
|
style={{
|
||||||
|
paddingTop: "100px", // <-- тут увеличиваем интервал
|
||||||
|
paddingBottom: "200px"
|
||||||
|
}}
|
||||||
|
// или через класс bootstrap: className="pt-5 pb-5"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
{/* Footer (fixed to bottom) */}
|
||||||
|
<footer className="bg-light footer fixed-bottom border-top">
|
||||||
|
<div className="container py-2">
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-lg-6 text-center text-lg-start mb-2 mb-lg-0">
|
||||||
|
<ul className="list-inline mb-1">
|
||||||
|
<li className="list-inline-item">
|
||||||
|
<Link href="#">About</Link>
|
||||||
|
</li>
|
||||||
|
<li className="list-inline-item">⋅</li>
|
||||||
|
<li className="list-inline-item">
|
||||||
|
<Link href="#">Contact</Link>
|
||||||
|
</li>
|
||||||
|
<li className="list-inline-item">⋅</li>
|
||||||
|
<li className="list-inline-item">
|
||||||
|
<Link href="#">Terms of Use</Link>
|
||||||
|
</li>
|
||||||
|
<li className="list-inline-item">⋅</li>
|
||||||
|
<li className="list-inline-item">
|
||||||
|
<Link href="#">Privacy Policy</Link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p className="text-muted small mb-0">© CatLink 2025</p>
|
||||||
|
</div>
|
||||||
|
<div className="col-lg-6 text-center text-lg-end">
|
||||||
|
<ul className="list-inline mb-0">
|
||||||
|
<li className="list-inline-item">
|
||||||
|
<Link href="#">
|
||||||
|
<i className="fa fa-facebook fa-lg fa-fw"></i>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li className="list-inline-item">
|
||||||
|
<Link href="#">
|
||||||
|
<i className="fa fa-twitter fa-lg fa-fw"></i>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li className="list-inline-item">
|
||||||
|
<Link href="#">
|
||||||
|
<i className="fa fa-instagram fa-lg fa-fw"></i>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
{/* Scripts */}
|
||||||
|
<Script
|
||||||
|
src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.0/jquery.min.js"
|
||||||
|
strategy="beforeInteractive"
|
||||||
|
/>
|
||||||
|
<Script
|
||||||
|
src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
|
||||||
|
strategy="beforeInteractive"
|
||||||
|
/>
|
||||||
|
<Script src="/assets/js/bs-init.js" strategy="lazyOnload" />
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
// src/app/layout.tsx
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
|
import "../globals.css";
|
||||||
|
import { ReactNode } from "react";
|
||||||
|
import Script from "next/script";
|
||||||
|
import Link from "next/link";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { LayoutWrapper } from '../components/LayoutWrapper'
|
||||||
|
|
||||||
|
const geistSans = Geist({
|
||||||
|
variable: "--font-geist-sans",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const geistMono = Geist_Mono({
|
||||||
|
variable: "--font-geist-mono",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "CatLink",
|
||||||
|
description: "Ваши ссылки в одном месте",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charSet="utf-8" />
|
||||||
|
<link
|
||||||
|
rel="apple-touch-icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="180x180"
|
||||||
|
href="/assets/img/apple-touch-icon.png?h=0f5e29c1169e75a7003e818478b67caa"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="96x96"
|
||||||
|
href="/assets/img/favicon-96x96.png?h=c8792ce927e01a494c4af48bed5dc5e3"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="192x192"
|
||||||
|
href="/assets/img/web-app-manifest-192x192.png?h=c8792ce927e01a494c4af48bed5dc5e3"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="512x512"
|
||||||
|
href="/assets/img/web-app-manifest-512x512.png?h=c8792ce927e01a494c4af48bed5dc5e3"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="/assets/bootstrap/css/bootstrap.min.css?h=608a9825a1f76f674715160908e57785"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="/assets/css/Lato.css?h=8253736d3a23b522f64b7e7d96d1d8ff"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="manifest"
|
||||||
|
href="/manifest.json?h=457941ffad3c027c946331c09a4d7d2f"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css"
|
||||||
|
/>
|
||||||
|
<link rel="manifest" href="/manifest.json" />
|
||||||
|
<meta name="theme-color" content="#ffffff" />
|
||||||
|
|
||||||
|
</head>
|
||||||
|
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
|
||||||
|
{/* Header (fixed to top) */}
|
||||||
|
<nav className="navbar navbar-expand bg-light fixed-top shadow-sm">
|
||||||
|
<div className="container">
|
||||||
|
<Link href="/" className="navbar-brand d-flex align-items-center">
|
||||||
|
<Image
|
||||||
|
src="/assets/img/CAT.png"
|
||||||
|
alt="CatLink"
|
||||||
|
width={89}
|
||||||
|
height={89}
|
||||||
|
/>
|
||||||
|
<span className="ms-2">CatLink</span>
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
className="navbar-toggler"
|
||||||
|
type="button"
|
||||||
|
data-bs-toggle="collapse"
|
||||||
|
data-bs-target="#navcol-1"
|
||||||
|
/>
|
||||||
|
<div className="collapse navbar-collapse" id="navcol-1">
|
||||||
|
<Link href="/auth/login" className="btn btn-primary ms-auto">
|
||||||
|
<i className="fa fa-user"></i>
|
||||||
|
<span className="d-none d-sm-inline"> Вход</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Main content: добавляем дополнительный отступ сверху */}
|
||||||
|
<main
|
||||||
|
style={{
|
||||||
|
paddingTop: "100px", // <-- тут увеличиваем интервал
|
||||||
|
paddingBottom: "200px"
|
||||||
|
}}
|
||||||
|
// или через класс bootstrap: className="pt-5 pb-5"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
{/* Footer (fixed to bottom) */}
|
||||||
|
<footer className="bg-light footer fixed-bottom border-top">
|
||||||
|
<div className="container py-2">
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-lg-6 text-center text-lg-start mb-2 mb-lg-0">
|
||||||
|
<ul className="list-inline mb-1">
|
||||||
|
<li className="list-inline-item">
|
||||||
|
<Link href="#">About</Link>
|
||||||
|
</li>
|
||||||
|
<li className="list-inline-item">⋅</li>
|
||||||
|
<li className="list-inline-item">
|
||||||
|
<Link href="#">Contact</Link>
|
||||||
|
</li>
|
||||||
|
<li className="list-inline-item">⋅</li>
|
||||||
|
<li className="list-inline-item">
|
||||||
|
<Link href="#">Terms of Use</Link>
|
||||||
|
</li>
|
||||||
|
<li className="list-inline-item">⋅</li>
|
||||||
|
<li className="list-inline-item">
|
||||||
|
<Link href="#">Privacy Policy</Link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p className="text-muted small mb-0">© CatLink 2025</p>
|
||||||
|
</div>
|
||||||
|
<div className="col-lg-6 text-center text-lg-end">
|
||||||
|
<ul className="list-inline mb-0">
|
||||||
|
<li className="list-inline-item">
|
||||||
|
<Link href="#">
|
||||||
|
<i className="fa fa-facebook fa-lg fa-fw"></i>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li className="list-inline-item">
|
||||||
|
<Link href="#">
|
||||||
|
<i className="fa fa-twitter fa-lg fa-fw"></i>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li className="list-inline-item">
|
||||||
|
<Link href="#">
|
||||||
|
<i className="fa fa-instagram fa-lg fa-fw"></i>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
{/* Scripts */}
|
||||||
|
<Script
|
||||||
|
src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.0/jquery.min.js"
|
||||||
|
strategy="beforeInteractive"
|
||||||
|
/>
|
||||||
|
<Script
|
||||||
|
src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
|
||||||
|
strategy="beforeInteractive"
|
||||||
|
/>
|
||||||
|
<Script src="/assets/js/bs-init.js" strategy="lazyOnload" />
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
// src/app/layout.tsx
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
|
import "../globals.css";
|
||||||
|
import { ReactNode } from "react";
|
||||||
|
import Script from "next/script";
|
||||||
|
import Link from "next/link";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { LayoutWrapper } from '../components/LayoutWrapper'
|
||||||
|
|
||||||
|
const geistSans = Geist({
|
||||||
|
variable: "--font-geist-sans",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const geistMono = Geist_Mono({
|
||||||
|
variable: "--font-geist-mono",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "CatLink",
|
||||||
|
description: "Ваши ссылки в одном месте",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charSet="utf-8" />
|
||||||
|
<link
|
||||||
|
rel="apple-touch-icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="180x180"
|
||||||
|
href="/assets/img/apple-touch-icon.png?h=0f5e29c1169e75a7003e818478b67caa"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="96x96"
|
||||||
|
href="/assets/img/favicon-96x96.png?h=c8792ce927e01a494c4af48bed5dc5e3"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="192x192"
|
||||||
|
href="/assets/img/web-app-manifest-192x192.png?h=c8792ce927e01a494c4af48bed5dc5e3"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="512x512"
|
||||||
|
href="/assets/img/web-app-manifest-512x512.png?h=c8792ce927e01a494c4af48bed5dc5e3"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="/assets/bootstrap/css/bootstrap.min.css?h=608a9825a1f76f674715160908e57785"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="/assets/css/Lato.css?h=8253736d3a23b522f64b7e7d96d1d8ff"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="manifest"
|
||||||
|
href="/manifest.json?h=457941ffad3c027c946331c09a4d7d2f"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css"
|
||||||
|
/>
|
||||||
|
<link rel="manifest" href="/manifest.json" />
|
||||||
|
<meta name="theme-color" content="#ffffff" />
|
||||||
|
|
||||||
|
</head>
|
||||||
|
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
|
||||||
|
{/* Header (fixed to top) */}
|
||||||
|
<nav className="navbar navbar-expand bg-light fixed-top shadow-sm">
|
||||||
|
<div className="container">
|
||||||
|
<Link href="/" className="navbar-brand d-flex align-items-center">
|
||||||
|
<Image
|
||||||
|
src="/assets/img/CAT.png"
|
||||||
|
alt="CatLink"
|
||||||
|
width={89}
|
||||||
|
height={89}
|
||||||
|
/>
|
||||||
|
<span className="ms-2">CatLink</span>
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
className="navbar-toggler"
|
||||||
|
type="button"
|
||||||
|
data-bs-toggle="collapse"
|
||||||
|
data-bs-target="#navcol-1"
|
||||||
|
/>
|
||||||
|
<div className="collapse navbar-collapse" id="navcol-1">
|
||||||
|
<Link href="/auth/login" className="btn btn-primary ms-auto">
|
||||||
|
<i className="fa fa-user"></i>
|
||||||
|
<span className="d-none d-sm-inline"> Вход</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Main content: добавляем дополнительный отступ сверху */}
|
||||||
|
<main
|
||||||
|
style={{
|
||||||
|
paddingTop: "100px", // <-- тут увеличиваем интервал
|
||||||
|
paddingBottom: "200px"
|
||||||
|
}}
|
||||||
|
// или через класс bootstrap: className="pt-5 pb-5"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
{/* Footer (fixed to bottom) */}
|
||||||
|
<footer className="bg-light footer fixed-bottom border-top">
|
||||||
|
<div className="container py-2">
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-lg-6 text-center text-lg-start mb-2 mb-lg-0">
|
||||||
|
<ul className="list-inline mb-1">
|
||||||
|
<li className="list-inline-item">
|
||||||
|
<Link href="#">About</Link>
|
||||||
|
</li>
|
||||||
|
<li className="list-inline-item">⋅</li>
|
||||||
|
<li className="list-inline-item">
|
||||||
|
<Link href="#">Contact</Link>
|
||||||
|
</li>
|
||||||
|
<li className="list-inline-item">⋅</li>
|
||||||
|
<li className="list-inline-item">
|
||||||
|
<Link href="#">Terms of Use</Link>
|
||||||
|
</li>
|
||||||
|
<li className="list-inline-item">⋅</li>
|
||||||
|
<li className="list-inline-item">
|
||||||
|
<Link href="#">Privacy Policy</Link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p className="text-muted small mb-0">© CatLink 2025</p>
|
||||||
|
</div>
|
||||||
|
<div className="col-lg-6 text-center text-lg-end">
|
||||||
|
<ul className="list-inline mb-0">
|
||||||
|
<li className="list-inline-item">
|
||||||
|
<Link href="#">
|
||||||
|
<i className="fa fa-facebook fa-lg fa-fw"></i>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li className="list-inline-item">
|
||||||
|
<Link href="#">
|
||||||
|
<i className="fa fa-twitter fa-lg fa-fw"></i>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li className="list-inline-item">
|
||||||
|
<Link href="#">
|
||||||
|
<i className="fa fa-instagram fa-lg fa-fw"></i>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
{/* Scripts */}
|
||||||
|
<Script
|
||||||
|
src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.0/jquery.min.js"
|
||||||
|
strategy="beforeInteractive"
|
||||||
|
/>
|
||||||
|
<Script
|
||||||
|
src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
|
||||||
|
strategy="beforeInteractive"
|
||||||
|
/>
|
||||||
|
<Script src="/assets/js/bs-init.js" strategy="lazyOnload" />
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
// src/app/layout.tsx
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
|
import "../globals.css";
|
||||||
|
import { ReactNode } from "react";
|
||||||
|
import Script from "next/script";
|
||||||
|
import Link from "next/link";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { LayoutWrapper } from '../components/LayoutWrapper'
|
||||||
|
|
||||||
|
const geistSans = Geist({
|
||||||
|
variable: "--font-geist-sans",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const geistMono = Geist_Mono({
|
||||||
|
variable: "--font-geist-mono",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "CatLink",
|
||||||
|
description: "Ваши ссылки в одном месте",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charSet="utf-8" />
|
||||||
|
<link
|
||||||
|
rel="apple-touch-icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="180x180"
|
||||||
|
href="/assets/img/apple-touch-icon.png?h=0f5e29c1169e75a7003e818478b67caa"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="96x96"
|
||||||
|
href="/assets/img/favicon-96x96.png?h=c8792ce927e01a494c4af48bed5dc5e3"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="192x192"
|
||||||
|
href="/assets/img/web-app-manifest-192x192.png?h=c8792ce927e01a494c4af48bed5dc5e3"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="512x512"
|
||||||
|
href="/assets/img/web-app-manifest-512x512.png?h=c8792ce927e01a494c4af48bed5dc5e3"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="/assets/bootstrap/css/bootstrap.min.css?h=608a9825a1f76f674715160908e57785"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="/assets/css/Lato.css?h=8253736d3a23b522f64b7e7d96d1d8ff"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="manifest"
|
||||||
|
href="/manifest.json?h=457941ffad3c027c946331c09a4d7d2f"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css"
|
||||||
|
/>
|
||||||
|
<link rel="manifest" href="/manifest.json" />
|
||||||
|
<meta name="theme-color" content="#ffffff" />
|
||||||
|
|
||||||
|
</head>
|
||||||
|
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
|
||||||
|
{/* Header (fixed to top) */}
|
||||||
|
<nav className="navbar navbar-expand bg-light fixed-top shadow-sm">
|
||||||
|
<div className="container">
|
||||||
|
<Link href="/" className="navbar-brand d-flex align-items-center">
|
||||||
|
<Image
|
||||||
|
src="/assets/img/CAT.png"
|
||||||
|
alt="CatLink"
|
||||||
|
width={89}
|
||||||
|
height={89}
|
||||||
|
/>
|
||||||
|
<span className="ms-2">CatLink</span>
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
className="navbar-toggler"
|
||||||
|
type="button"
|
||||||
|
data-bs-toggle="collapse"
|
||||||
|
data-bs-target="#navcol-1"
|
||||||
|
/>
|
||||||
|
<div className="collapse navbar-collapse" id="navcol-1">
|
||||||
|
<Link href="/auth/login" className="btn btn-primary ms-auto">
|
||||||
|
<i className="fa fa-user"></i>
|
||||||
|
<span className="d-none d-sm-inline"> Вход</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Main content: добавляем дополнительный отступ сверху */}
|
||||||
|
<main
|
||||||
|
style={{
|
||||||
|
paddingTop: "100px", // <-- тут увеличиваем интервал
|
||||||
|
paddingBottom: "200px"
|
||||||
|
}}
|
||||||
|
// или через класс bootstrap: className="pt-5 pb-5"
|
||||||
|
>
|
||||||
|
<LayoutWrapper>{children}</LayoutWrapper>
|
||||||
|
</main>
|
||||||
|
{/* Footer (fixed to bottom) */}
|
||||||
|
<footer className="bg-light footer fixed-bottom border-top">
|
||||||
|
<div className="container py-2">
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-lg-6 text-center text-lg-start mb-2 mb-lg-0">
|
||||||
|
<ul className="list-inline mb-1">
|
||||||
|
<li className="list-inline-item">
|
||||||
|
<Link href="#">About</Link>
|
||||||
|
</li>
|
||||||
|
<li className="list-inline-item">⋅</li>
|
||||||
|
<li className="list-inline-item">
|
||||||
|
<Link href="#">Contact</Link>
|
||||||
|
</li>
|
||||||
|
<li className="list-inline-item">⋅</li>
|
||||||
|
<li className="list-inline-item">
|
||||||
|
<Link href="#">Terms of Use</Link>
|
||||||
|
</li>
|
||||||
|
<li className="list-inline-item">⋅</li>
|
||||||
|
<li className="list-inline-item">
|
||||||
|
<Link href="#">Privacy Policy</Link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p className="text-muted small mb-0">© CatLink 2025</p>
|
||||||
|
</div>
|
||||||
|
<div className="col-lg-6 text-center text-lg-end">
|
||||||
|
<ul className="list-inline mb-0">
|
||||||
|
<li className="list-inline-item">
|
||||||
|
<Link href="#">
|
||||||
|
<i className="fa fa-facebook fa-lg fa-fw"></i>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li className="list-inline-item">
|
||||||
|
<Link href="#">
|
||||||
|
<i className="fa fa-twitter fa-lg fa-fw"></i>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li className="list-inline-item">
|
||||||
|
<Link href="#">
|
||||||
|
<i className="fa fa-instagram fa-lg fa-fw"></i>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
{/* Scripts */}
|
||||||
|
<Script
|
||||||
|
src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.0/jquery.min.js"
|
||||||
|
strategy="beforeInteractive"
|
||||||
|
/>
|
||||||
|
<Script
|
||||||
|
src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
|
||||||
|
strategy="beforeInteractive"
|
||||||
|
/>
|
||||||
|
<Script src="/assets/js/bs-init.js" strategy="lazyOnload" />
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
// src/app/layout.tsx
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
|
import "../globals.css";
|
||||||
|
import { ReactNode } from "react";
|
||||||
|
import Script from "next/script";
|
||||||
|
import Link from "next/link";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { LayoutWrapper } from '../components/LayoutWrapper'
|
||||||
|
|
||||||
|
const geistSans = Geist({
|
||||||
|
variable: "--font-geist-sans",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const geistMono = Geist_Mono({
|
||||||
|
variable: "--font-geist-mono",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "CatLink",
|
||||||
|
description: "Ваши ссылки в одном месте",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
|
||||||
|
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
|
||||||
|
{/* Header (fixed to top) */}
|
||||||
|
<nav className="navbar navbar-expand bg-light fixed-top shadow-sm">
|
||||||
|
<div className="container">
|
||||||
|
<Link href="/" className="navbar-brand d-flex align-items-center">
|
||||||
|
<Image
|
||||||
|
src="/assets/img/CAT.png"
|
||||||
|
alt="CatLink"
|
||||||
|
width={89}
|
||||||
|
height={89}
|
||||||
|
/>
|
||||||
|
<span className="ms-2">CatLink</span>
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
className="navbar-toggler"
|
||||||
|
type="button"
|
||||||
|
data-bs-toggle="collapse"
|
||||||
|
data-bs-target="#navcol-1"
|
||||||
|
/>
|
||||||
|
<div className="collapse navbar-collapse" id="navcol-1">
|
||||||
|
<Link href="/auth/login" className="btn btn-primary ms-auto">
|
||||||
|
<i className="fa fa-user"></i>
|
||||||
|
<span className="d-none d-sm-inline"> Вход</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Main content: добавляем дополнительный отступ сверху */}
|
||||||
|
<main
|
||||||
|
style={{
|
||||||
|
paddingTop: "100px", // <-- тут увеличиваем интервал
|
||||||
|
paddingBottom: "200px"
|
||||||
|
}}
|
||||||
|
// или через класс bootstrap: className="pt-5 pb-5"
|
||||||
|
>
|
||||||
|
<LayoutWrapper>{children}</LayoutWrapper>
|
||||||
|
</main>
|
||||||
|
{/* Footer (fixed to bottom) */}
|
||||||
|
<footer className="bg-light footer fixed-bottom border-top">
|
||||||
|
<div className="container py-2">
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-lg-6 text-center text-lg-start mb-2 mb-lg-0">
|
||||||
|
<ul className="list-inline mb-1">
|
||||||
|
<li className="list-inline-item">
|
||||||
|
<Link href="#">About</Link>
|
||||||
|
</li>
|
||||||
|
<li className="list-inline-item">⋅</li>
|
||||||
|
<li className="list-inline-item">
|
||||||
|
<Link href="#">Contact</Link>
|
||||||
|
</li>
|
||||||
|
<li className="list-inline-item">⋅</li>
|
||||||
|
<li className="list-inline-item">
|
||||||
|
<Link href="#">Terms of Use</Link>
|
||||||
|
</li>
|
||||||
|
<li className="list-inline-item">⋅</li>
|
||||||
|
<li className="list-inline-item">
|
||||||
|
<Link href="#">Privacy Policy</Link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p className="text-muted small mb-0">© CatLink 2025</p>
|
||||||
|
</div>
|
||||||
|
<div className="col-lg-6 text-center text-lg-end">
|
||||||
|
<ul className="list-inline mb-0">
|
||||||
|
<li className="list-inline-item">
|
||||||
|
<Link href="#">
|
||||||
|
<i className="fa fa-facebook fa-lg fa-fw"></i>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li className="list-inline-item">
|
||||||
|
<Link href="#">
|
||||||
|
<i className="fa fa-twitter fa-lg fa-fw"></i>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li className="list-inline-item">
|
||||||
|
<Link href="#">
|
||||||
|
<i className="fa fa-instagram fa-lg fa-fw"></i>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
{/* Scripts */}
|
||||||
|
<Script
|
||||||
|
src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.0/jquery.min.js"
|
||||||
|
strategy="beforeInteractive"
|
||||||
|
/>
|
||||||
|
<Script
|
||||||
|
src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
|
||||||
|
strategy="beforeInteractive"
|
||||||
|
/>
|
||||||
|
<Script src="/assets/js/bs-init.js" strategy="lazyOnload" />
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
// src/app/layout.tsx
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
|
import "../globals.css";
|
||||||
|
import { ReactNode } from "react";
|
||||||
|
import Script from "next/script";
|
||||||
|
import Link from "next/link";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { LayoutWrapper } from '../components/LayoutWrapper'
|
||||||
|
|
||||||
|
const geistSans = Geist({
|
||||||
|
variable: "--font-geist-sans",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const geistMono = Geist_Mono({
|
||||||
|
variable: "--font-geist-mono",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "CatLink",
|
||||||
|
description: "Ваши ссылки в одном месте",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<html lang="ru">
|
||||||
|
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
|
||||||
|
{/* Header (fixed to top) */}
|
||||||
|
<nav className="navbar navbar-expand bg-light fixed-top shadow-sm">
|
||||||
|
<div className="container">
|
||||||
|
<Link href="/" className="navbar-brand d-flex align-items-center">
|
||||||
|
<Image
|
||||||
|
src="/assets/img/CAT.png"
|
||||||
|
alt="CatLink"
|
||||||
|
width={89}
|
||||||
|
height={89}
|
||||||
|
/>
|
||||||
|
<span className="ms-2">CatLink</span>
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
className="navbar-toggler"
|
||||||
|
type="button"
|
||||||
|
data-bs-toggle="collapse"
|
||||||
|
data-bs-target="#navcol-1"
|
||||||
|
/>
|
||||||
|
<div className="collapse navbar-collapse" id="navcol-1">
|
||||||
|
<Link href="/auth/login" className="btn btn-primary ms-auto">
|
||||||
|
<i className="fa fa-user"></i>
|
||||||
|
<span className="d-none d-sm-inline"> Вход</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Main content: добавляем дополнительный отступ сверху */}
|
||||||
|
<main
|
||||||
|
style={{
|
||||||
|
paddingTop: "100px", // <-- тут увеличиваем интервал
|
||||||
|
paddingBottom: "200px"
|
||||||
|
}}
|
||||||
|
// или через класс bootstrap: className="pt-5 pb-5"
|
||||||
|
>
|
||||||
|
<LayoutWrapper>{children}</LayoutWrapper>
|
||||||
|
</main>
|
||||||
|
{/* Footer (fixed to bottom) */}
|
||||||
|
<footer className="bg-light footer fixed-bottom border-top">
|
||||||
|
<div className="container py-2">
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-lg-6 text-center text-lg-start mb-2 mb-lg-0">
|
||||||
|
<ul className="list-inline mb-1">
|
||||||
|
<li className="list-inline-item">
|
||||||
|
<Link href="#">About</Link>
|
||||||
|
</li>
|
||||||
|
<li className="list-inline-item">⋅</li>
|
||||||
|
<li className="list-inline-item">
|
||||||
|
<Link href="#">Contact</Link>
|
||||||
|
</li>
|
||||||
|
<li className="list-inline-item">⋅</li>
|
||||||
|
<li className="list-inline-item">
|
||||||
|
<Link href="#">Terms of Use</Link>
|
||||||
|
</li>
|
||||||
|
<li className="list-inline-item">⋅</li>
|
||||||
|
<li className="list-inline-item">
|
||||||
|
<Link href="#">Privacy Policy</Link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p className="text-muted small mb-0">© CatLink 2025</p>
|
||||||
|
</div>
|
||||||
|
<div className="col-lg-6 text-center text-lg-end">
|
||||||
|
<ul className="list-inline mb-0">
|
||||||
|
<li className="list-inline-item">
|
||||||
|
<Link href="#">
|
||||||
|
<i className="fa fa-facebook fa-lg fa-fw"></i>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li className="list-inline-item">
|
||||||
|
<Link href="#">
|
||||||
|
<i className="fa fa-twitter fa-lg fa-fw"></i>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li className="list-inline-item">
|
||||||
|
<Link href="#">
|
||||||
|
<i className="fa fa-instagram fa-lg fa-fw"></i>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
{/* Scripts */}
|
||||||
|
<Script
|
||||||
|
src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.0/jquery.min.js"
|
||||||
|
strategy="beforeInteractive"
|
||||||
|
/>
|
||||||
|
<Script
|
||||||
|
src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
|
||||||
|
strategy="beforeInteractive"
|
||||||
|
/>
|
||||||
|
<Script src="/assets/js/bs-init.js" strategy="lazyOnload" />
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
// src/app/layout.tsx
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
|
import "../globals.css";
|
||||||
|
import { ReactNode } from "react";
|
||||||
|
import Script from "next/script";
|
||||||
|
import Link from "next/link";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { LayoutWrapper } from '../components/LayoutWrapper'
|
||||||
|
|
||||||
|
const geistSans = Geist({
|
||||||
|
variable: "--font-geist-sans",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const geistMono = Geist_Mono({
|
||||||
|
variable: "--font-geist-mono",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "CatLink",
|
||||||
|
description: "Ваши ссылки в одном месте",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charSet="utf-8" />
|
||||||
|
<link
|
||||||
|
rel="apple-touch-icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="180x180"
|
||||||
|
href="/assets/img/apple-touch-icon.png?h=0f5e29c1169e75a7003e818478b67caa"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="96x96"
|
||||||
|
href="/assets/img/favicon-96x96.png?h=c8792ce927e01a494c4af48bed5dc5e3"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="192x192"
|
||||||
|
href="/assets/img/web-app-manifest-192x192.png?h=c8792ce927e01a494c4af48bed5dc5e3"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="512x512"
|
||||||
|
href="/assets/img/web-app-manifest-512x512.png?h=c8792ce927e01a494c4af48bed5dc5e3"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="/assets/bootstrap/css/bootstrap.min.css?h=608a9825a1f76f674715160908e57785"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="/assets/css/Lato.css?h=8253736d3a23b522f64b7e7d96d1d8ff"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="manifest"
|
||||||
|
href="/manifest.json?h=457941ffad3c027c946331c09a4d7d2f"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css"
|
||||||
|
/>
|
||||||
|
<link rel="manifest" href="/manifest.json" />
|
||||||
|
<meta name="theme-color" content="#ffffff" />
|
||||||
|
|
||||||
|
</head>
|
||||||
|
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
|
||||||
|
{/* Header (fixed to top) */}
|
||||||
|
<nav className="navbar navbar-expand bg-light fixed-top shadow-sm">
|
||||||
|
<div className="container">
|
||||||
|
<Link href="/" className="navbar-brand d-flex align-items-center">
|
||||||
|
<Image
|
||||||
|
src="/assets/img/CAT.png"
|
||||||
|
alt="CatLink"
|
||||||
|
width={89}
|
||||||
|
height={89}
|
||||||
|
/>
|
||||||
|
<span className="ms-2">CatLink</span>
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
className="navbar-toggler"
|
||||||
|
type="button"
|
||||||
|
data-bs-toggle="collapse"
|
||||||
|
data-bs-target="#navcol-1"
|
||||||
|
/>
|
||||||
|
<div className="collapse navbar-collapse" id="navcol-1">
|
||||||
|
<Link href="/auth/login" className="btn btn-primary ms-auto">
|
||||||
|
<i className="fa fa-user"></i>
|
||||||
|
<span className="d-none d-sm-inline"> Вход</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Main content: добавляем дополнительный отступ сверху */}
|
||||||
|
<main
|
||||||
|
style={{
|
||||||
|
paddingTop: "100px", // <-- тут увеличиваем интервал
|
||||||
|
paddingBottom: "200px"
|
||||||
|
}}
|
||||||
|
// или через класс bootstrap: className="pt-5 pb-5"
|
||||||
|
>
|
||||||
|
<LayoutWrapper>{children}</LayoutWrapper>
|
||||||
|
</main>
|
||||||
|
{/* Footer (fixed to bottom) */}
|
||||||
|
<footer className="bg-light footer fixed-bottom border-top">
|
||||||
|
<div className="container py-2">
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-lg-6 text-center text-lg-start mb-2 mb-lg-0">
|
||||||
|
<ul className="list-inline mb-1">
|
||||||
|
<li className="list-inline-item">
|
||||||
|
<Link href="#">About</Link>
|
||||||
|
</li>
|
||||||
|
<li className="list-inline-item">⋅</li>
|
||||||
|
<li className="list-inline-item">
|
||||||
|
<Link href="#">Contact</Link>
|
||||||
|
</li>
|
||||||
|
<li className="list-inline-item">⋅</li>
|
||||||
|
<li className="list-inline-item">
|
||||||
|
<Link href="#">Terms of Use</Link>
|
||||||
|
</li>
|
||||||
|
<li className="list-inline-item">⋅</li>
|
||||||
|
<li className="list-inline-item">
|
||||||
|
<Link href="#">Privacy Policy</Link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p className="text-muted small mb-0">© CatLink 2025</p>
|
||||||
|
</div>
|
||||||
|
<div className="col-lg-6 text-center text-lg-end">
|
||||||
|
<ul className="list-inline mb-0">
|
||||||
|
<li className="list-inline-item">
|
||||||
|
<Link href="#">
|
||||||
|
<i className="fa fa-facebook fa-lg fa-fw"></i>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li className="list-inline-item">
|
||||||
|
<Link href="#">
|
||||||
|
<i className="fa fa-twitter fa-lg fa-fw"></i>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li className="list-inline-item">
|
||||||
|
<Link href="#">
|
||||||
|
<i className="fa fa-instagram fa-lg fa-fw"></i>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
{/* Scripts */}
|
||||||
|
<Script
|
||||||
|
src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.0/jquery.min.js"
|
||||||
|
strategy="beforeInteractive"
|
||||||
|
/>
|
||||||
|
<Script
|
||||||
|
src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
|
||||||
|
strategy="beforeInteractive"
|
||||||
|
/>
|
||||||
|
<Script src="/assets/js/bs-init.js" strategy="lazyOnload" />
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
// src/app/layout.tsx
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
|
import "../globals.css";
|
||||||
|
import { ReactNode } from "react";
|
||||||
|
import Script from "next/script";
|
||||||
|
import Link from "next/link";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { LayoutWrapper } from "../components/LayoutWrapper";
|
||||||
|
|
||||||
|
const geistSans = Geist({
|
||||||
|
variable: "--font-geist-sans",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const geistMono = Geist_Mono({
|
||||||
|
variable: "--font-geist-mono",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "CatLink",
|
||||||
|
description: "Ваши ссылки в одном месте",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charSet="utf-8" />
|
||||||
|
<link
|
||||||
|
rel="apple-touch-icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="180x180"
|
||||||
|
href="/assets/img/apple-touch-icon.png?h=0f5e29c1169e75a7003e818478b67caa"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="96x96"
|
||||||
|
href="/assets/img/favicon-96x96.png?h=c8792ce927e01a494c4af48bed5dc5e3"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="192x192"
|
||||||
|
href="/assets/img/web-app-manifest-192x192.png?h=c8792ce927e01a494c4af48bed5dc5e3"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="512x512"
|
||||||
|
href="/assets/img/web-app-manifest-512x512.png?h=c8792ce927e01a494c4af48bed5dc5e3"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="/assets/bootstrap/css/bootstrap.min.css?h=608a9825a1f76f674715160908e57785"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="/assets/css/Lato.css?h=8253736d3a23b522f64b7e7d96d1d8ff"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="manifest"
|
||||||
|
href="/manifest.json?h=457941ffad3c027c946331c09a4d7d2f"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css"
|
||||||
|
/>
|
||||||
|
<link rel="manifest" href="/manifest.json" />
|
||||||
|
<meta name="theme-color" content="#ffffff" />
|
||||||
|
|
||||||
|
</head>
|
||||||
|
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
|
||||||
|
{/* Header (fixed to top) */}
|
||||||
|
<nav className="navbar navbar-expand bg-light fixed-top shadow-sm">
|
||||||
|
<div className="container">
|
||||||
|
<Link href="/" className="navbar-brand d-flex align-items-center">
|
||||||
|
<Image
|
||||||
|
src="/assets/img/CAT.png"
|
||||||
|
alt="CatLink"
|
||||||
|
width={89}
|
||||||
|
height={89}
|
||||||
|
/>
|
||||||
|
<span className="ms-2">CatLink</span>
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
className="navbar-toggler"
|
||||||
|
type="button"
|
||||||
|
data-bs-toggle="collapse"
|
||||||
|
data-bs-target="#navcol-1"
|
||||||
|
/>
|
||||||
|
<div className="collapse navbar-collapse" id="navcol-1">
|
||||||
|
<Link href="/auth/login" className="btn btn-primary ms-auto">
|
||||||
|
<i className="fa fa-user"></i>
|
||||||
|
<span className="d-none d-sm-inline"> Вход</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Main content: добавляем дополнительный отступ сверху */}
|
||||||
|
<main
|
||||||
|
style={{
|
||||||
|
paddingTop: "100px", // <-- тут увеличиваем интервал
|
||||||
|
paddingBottom: "200px"
|
||||||
|
}}
|
||||||
|
// или через класс bootstrap: className="pt-5 pb-5"
|
||||||
|
>
|
||||||
|
<LayoutWrapper>{children}</LayoutWrapper>
|
||||||
|
</main>
|
||||||
|
{/* Footer (fixed to bottom) */}
|
||||||
|
<footer className="bg-light footer fixed-bottom border-top">
|
||||||
|
<div className="container py-2">
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-lg-6 text-center text-lg-start mb-2 mb-lg-0">
|
||||||
|
<ul className="list-inline mb-1">
|
||||||
|
<li className="list-inline-item">
|
||||||
|
<Link href="#">About</Link>
|
||||||
|
</li>
|
||||||
|
<li className="list-inline-item">⋅</li>
|
||||||
|
<li className="list-inline-item">
|
||||||
|
<Link href="#">Contact</Link>
|
||||||
|
</li>
|
||||||
|
<li className="list-inline-item">⋅</li>
|
||||||
|
<li className="list-inline-item">
|
||||||
|
<Link href="#">Terms of Use</Link>
|
||||||
|
</li>
|
||||||
|
<li className="list-inline-item">⋅</li>
|
||||||
|
<li className="list-inline-item">
|
||||||
|
<Link href="#">Privacy Policy</Link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p className="text-muted small mb-0">© CatLink 2025</p>
|
||||||
|
</div>
|
||||||
|
<div className="col-lg-6 text-center text-lg-end">
|
||||||
|
<ul className="list-inline mb-0">
|
||||||
|
<li className="list-inline-item">
|
||||||
|
<Link href="#">
|
||||||
|
<i className="fa fa-facebook fa-lg fa-fw"></i>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li className="list-inline-item">
|
||||||
|
<Link href="#">
|
||||||
|
<i className="fa fa-twitter fa-lg fa-fw"></i>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li className="list-inline-item">
|
||||||
|
<Link href="#">
|
||||||
|
<i className="fa fa-instagram fa-lg fa-fw"></i>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
{/* Scripts */}
|
||||||
|
<Script
|
||||||
|
src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.0/jquery.min.js"
|
||||||
|
strategy="beforeInteractive"
|
||||||
|
/>
|
||||||
|
<Script
|
||||||
|
src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
|
||||||
|
strategy="beforeInteractive"
|
||||||
|
/>
|
||||||
|
<Script src="/assets/js/bs-init.js" strategy="lazyOnload" />
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
// src/app/(protected)/layout.tsx
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { ReactNode, useEffect } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { LayoutWrapper } from '../components/LayoutWrapper'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProtectedLayout({ children }: Props) {
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
if (!token) {
|
||||||
|
// если нет токена, редиректим на логин
|
||||||
|
router.replace('/auth/login')
|
||||||
|
}
|
||||||
|
}, [router])
|
||||||
|
|
||||||
|
// пока идёт редирект, ничего не рендерим
|
||||||
|
// (можно поставить спиннер, если хотите)
|
||||||
|
if (typeof window !== 'undefined' && !localStorage.getItem('token')) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LayoutWrapper>
|
||||||
|
{children}
|
||||||
|
</LayoutWrapper>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
// src/app/layout.tsx
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
|
import "../globals.css";
|
||||||
|
import { ReactNode } from "react";
|
||||||
|
import Script from "next/script";
|
||||||
|
import Link from "next/link";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { LayoutWrapper } from "../components/LayoutWrapper";
|
||||||
|
|
||||||
|
const geistSans = Geist({
|
||||||
|
variable: "--font-geist-sans",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const geistMono = Geist_Mono({
|
||||||
|
variable: "--font-geist-mono",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "CatLink",
|
||||||
|
description: "Ваши ссылки в одном месте",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charSet="utf-8" />
|
||||||
|
<link
|
||||||
|
rel="apple-touch-icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="180x180"
|
||||||
|
href="/assets/img/apple-touch-icon.png?h=0f5e29c1169e75a7003e818478b67caa"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="96x96"
|
||||||
|
href="/assets/img/favicon-96x96.png?h=c8792ce927e01a494c4af48bed5dc5e3"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="192x192"
|
||||||
|
href="/assets/img/web-app-manifest-192x192.png?h=c8792ce927e01a494c4af48bed5dc5e3"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="512x512"
|
||||||
|
href="/assets/img/web-app-manifest-512x512.png?h=c8792ce927e01a494c4af48bed5dc5e3"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="/assets/bootstrap/css/bootstrap.min.css?h=608a9825a1f76f674715160908e57785"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="/assets/css/Lato.css?h=8253736d3a23b522f64b7e7d96d1d8ff"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="manifest"
|
||||||
|
href="/manifest.json?h=457941ffad3c027c946331c09a4d7d2f"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css"
|
||||||
|
/>
|
||||||
|
<link rel="manifest" href="/manifest.json" />
|
||||||
|
<meta name="theme-color" content="#ffffff" />
|
||||||
|
|
||||||
|
</head>
|
||||||
|
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
|
||||||
|
{/* Header (fixed to top) */}
|
||||||
|
<nav className="navbar navbar-expand bg-light fixed-top shadow-sm">
|
||||||
|
<div className="container">
|
||||||
|
<Link href="/" className="navbar-brand d-flex align-items-center">
|
||||||
|
<Image
|
||||||
|
src="/assets/img/CAT.png"
|
||||||
|
alt="CatLink"
|
||||||
|
width={89}
|
||||||
|
height={89}
|
||||||
|
/>
|
||||||
|
<span className="ms-2">CatLink</span>
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
className="navbar-toggler"
|
||||||
|
type="button"
|
||||||
|
data-bs-toggle="collapse"
|
||||||
|
data-bs-target="#navcol-1"
|
||||||
|
/>
|
||||||
|
<div className="collapse navbar-collapse" id="navcol-1">
|
||||||
|
<Link href="/auth/login" className="btn btn-primary ms-auto">
|
||||||
|
<i className="fa fa-user"></i>
|
||||||
|
<span className="d-none d-sm-inline"> Вход</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Main content: добавляем дополнительный отступ сверху */}
|
||||||
|
<main
|
||||||
|
style={{
|
||||||
|
paddingTop: "100px", // <-- тут увеличиваем интервал
|
||||||
|
paddingBottom: "200px"
|
||||||
|
}}
|
||||||
|
// или через класс bootstrap: className="pt-5 pb-5"
|
||||||
|
>
|
||||||
|
<LayoutWrapper>{children}</LayoutWrapper>
|
||||||
|
</main>
|
||||||
|
{/* Footer (fixed to bottom) */}
|
||||||
|
<footer className="bg-light footer fixed-bottom border-top">
|
||||||
|
<div className="container py-2">
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-lg-6 text-center text-lg-start mb-2 mb-lg-0">
|
||||||
|
<ul className="list-inline mb-1">
|
||||||
|
<li className="list-inline-item">
|
||||||
|
<Link href="#">About</Link>
|
||||||
|
</li>
|
||||||
|
<li className="list-inline-item">⋅</li>
|
||||||
|
<li className="list-inline-item">
|
||||||
|
<Link href="#">Contact</Link>
|
||||||
|
</li>
|
||||||
|
<li className="list-inline-item">⋅</li>
|
||||||
|
<li className="list-inline-item">
|
||||||
|
<Link href="#">Terms of Use</Link>
|
||||||
|
</li>
|
||||||
|
<li className="list-inline-item">⋅</li>
|
||||||
|
<li className="list-inline-item">
|
||||||
|
<Link href="#">Privacy Policy</Link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p className="text-muted small mb-0">© CatLink 2025</p>
|
||||||
|
</div>
|
||||||
|
<div className="col-lg-6 text-center text-lg-end">
|
||||||
|
<ul className="list-inline mb-0">
|
||||||
|
<li className="list-inline-item">
|
||||||
|
<Link href="#">
|
||||||
|
<i className="fa fa-facebook fa-lg fa-fw"></i>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li className="list-inline-item">
|
||||||
|
<Link href="#">
|
||||||
|
<i className="fa fa-twitter fa-lg fa-fw"></i>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li className="list-inline-item">
|
||||||
|
<Link href="#">
|
||||||
|
<i className="fa fa-instagram fa-lg fa-fw"></i>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
{/* Scripts */}
|
||||||
|
<Script
|
||||||
|
src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.0/jquery.min.js"
|
||||||
|
strategy="beforeInteractive"
|
||||||
|
/>
|
||||||
|
<Script
|
||||||
|
src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
|
||||||
|
strategy="beforeInteractive"
|
||||||
|
/>
|
||||||
|
<Script src="/assets/js/bs-init.js" strategy="lazyOnload" />
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
// src/app/layout.tsx
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
|
import "../globals.css";
|
||||||
|
import { ReactNode } from "react";
|
||||||
|
import Script from "next/script";
|
||||||
|
import Link from "next/link";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { LayoutWrapper } from "../components/LayoutWrapper";
|
||||||
|
|
||||||
|
const geistSans = Geist({
|
||||||
|
variable: "--font-geist-sans",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const geistMono = Geist_Mono({
|
||||||
|
variable: "--font-geist-mono",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "CatLink",
|
||||||
|
description: "Ваши ссылки в одном месте",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<head>
|
||||||
|
<meta charSet="utf-8" />
|
||||||
|
<link
|
||||||
|
rel="apple-touch-icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="180x180"
|
||||||
|
href="/assets/img/apple-touch-icon.png?h=0f5e29c1169e75a7003e818478b67caa"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="96x96"
|
||||||
|
href="/assets/img/favicon-96x96.png?h=c8792ce927e01a494c4af48bed5dc5e3"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="192x192"
|
||||||
|
href="/assets/img/web-app-manifest-192x192.png?h=c8792ce927e01a494c4af48bed5dc5e3"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="512x512"
|
||||||
|
href="/assets/img/web-app-manifest-512x512.png?h=c8792ce927e01a494c4af48bed5dc5e3"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="/assets/bootstrap/css/bootstrap.min.css?h=608a9825a1f76f674715160908e57785"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="/assets/css/Lato.css?h=8253736d3a23b522f64b7e7d96d1d8ff"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="manifest"
|
||||||
|
href="/manifest.json?h=457941ffad3c027c946331c09a4d7d2f"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css"
|
||||||
|
/>
|
||||||
|
<link rel="manifest" href="/manifest.json" />
|
||||||
|
<meta name="theme-color" content="#ffffff" />
|
||||||
|
|
||||||
|
</head>
|
||||||
|
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
|
||||||
|
{/* Header (fixed to top) */}
|
||||||
|
<nav className="navbar navbar-expand bg-light fixed-top shadow-sm">
|
||||||
|
<div className="container">
|
||||||
|
<Link href="/" className="navbar-brand d-flex align-items-center">
|
||||||
|
<Image
|
||||||
|
src="/assets/img/CAT.png"
|
||||||
|
alt="CatLink"
|
||||||
|
width={89}
|
||||||
|
height={89}
|
||||||
|
/>
|
||||||
|
<span className="ms-2">CatLink</span>
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
className="navbar-toggler"
|
||||||
|
type="button"
|
||||||
|
data-bs-toggle="collapse"
|
||||||
|
data-bs-target="#navcol-1"
|
||||||
|
/>
|
||||||
|
<div className="collapse navbar-collapse" id="navcol-1">
|
||||||
|
<Link href="/auth/login" className="btn btn-primary ms-auto">
|
||||||
|
<i className="fa fa-user"></i>
|
||||||
|
<span className="d-none d-sm-inline"> Вход</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Main content: добавляем дополнительный отступ сверху */}
|
||||||
|
<main
|
||||||
|
style={{
|
||||||
|
paddingTop: "100px", // <-- тут увеличиваем интервал
|
||||||
|
paddingBottom: "200px"
|
||||||
|
}}
|
||||||
|
// или через класс bootstrap: className="pt-5 pb-5"
|
||||||
|
>
|
||||||
|
<LayoutWrapper>{children}</LayoutWrapper>
|
||||||
|
</main>
|
||||||
|
{/* Footer (fixed to bottom) */}
|
||||||
|
<footer className="bg-light footer fixed-bottom border-top">
|
||||||
|
<div className="container py-2">
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-lg-6 text-center text-lg-start mb-2 mb-lg-0">
|
||||||
|
<ul className="list-inline mb-1">
|
||||||
|
<li className="list-inline-item">
|
||||||
|
<Link href="#">About</Link>
|
||||||
|
</li>
|
||||||
|
<li className="list-inline-item">⋅</li>
|
||||||
|
<li className="list-inline-item">
|
||||||
|
<Link href="#">Contact</Link>
|
||||||
|
</li>
|
||||||
|
<li className="list-inline-item">⋅</li>
|
||||||
|
<li className="list-inline-item">
|
||||||
|
<Link href="#">Terms of Use</Link>
|
||||||
|
</li>
|
||||||
|
<li className="list-inline-item">⋅</li>
|
||||||
|
<li className="list-inline-item">
|
||||||
|
<Link href="#">Privacy Policy</Link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p className="text-muted small mb-0">© CatLink 2025</p>
|
||||||
|
</div>
|
||||||
|
<div className="col-lg-6 text-center text-lg-end">
|
||||||
|
<ul className="list-inline mb-0">
|
||||||
|
<li className="list-inline-item">
|
||||||
|
<Link href="#">
|
||||||
|
<i className="fa fa-facebook fa-lg fa-fw"></i>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li className="list-inline-item">
|
||||||
|
<Link href="#">
|
||||||
|
<i className="fa fa-twitter fa-lg fa-fw"></i>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li className="list-inline-item">
|
||||||
|
<Link href="#">
|
||||||
|
<i className="fa fa-instagram fa-lg fa-fw"></i>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
{/* Scripts */}
|
||||||
|
<Script
|
||||||
|
src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.0/jquery.min.js"
|
||||||
|
strategy="beforeInteractive"
|
||||||
|
/>
|
||||||
|
<Script
|
||||||
|
src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
|
||||||
|
strategy="beforeInteractive"
|
||||||
|
/>
|
||||||
|
<Script src="/assets/js/bs-init.js" strategy="lazyOnload" />
|
||||||
|
</body>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
// src/app/layout.tsx
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
|
import "../globals.css";
|
||||||
|
import { ReactNode } from "react";
|
||||||
|
import Script from "next/script";
|
||||||
|
import Link from "next/link";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { LayoutWrapper } from "../components/LayoutWrapper";
|
||||||
|
|
||||||
|
const geistSans = Geist({
|
||||||
|
variable: "--font-geist-sans",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const geistMono = Geist_Mono({
|
||||||
|
variable: "--font-geist-mono",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "CatLink",
|
||||||
|
description: "Ваши ссылки в одном месте",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charSet="utf-8" />
|
||||||
|
<link
|
||||||
|
rel="apple-touch-icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="180x180"
|
||||||
|
href="/assets/img/apple-touch-icon.png?h=0f5e29c1169e75a7003e818478b67caa"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="96x96"
|
||||||
|
href="/assets/img/favicon-96x96.png?h=c8792ce927e01a494c4af48bed5dc5e3"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="192x192"
|
||||||
|
href="/assets/img/web-app-manifest-192x192.png?h=c8792ce927e01a494c4af48bed5dc5e3"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="512x512"
|
||||||
|
href="/assets/img/web-app-manifest-512x512.png?h=c8792ce927e01a494c4af48bed5dc5e3"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="/assets/bootstrap/css/bootstrap.min.css?h=608a9825a1f76f674715160908e57785"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="/assets/css/Lato.css?h=8253736d3a23b522f64b7e7d96d1d8ff"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="manifest"
|
||||||
|
href="/manifest.json?h=457941ffad3c027c946331c09a4d7d2f"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css"
|
||||||
|
/>
|
||||||
|
<link rel="manifest" href="/manifest.json" />
|
||||||
|
<meta name="theme-color" content="#ffffff" />
|
||||||
|
|
||||||
|
</head>
|
||||||
|
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
|
||||||
|
{/* Header (fixed to top) */}
|
||||||
|
<nav className="navbar navbar-expand bg-light fixed-top shadow-sm">
|
||||||
|
<div className="container">
|
||||||
|
<Link href="/" className="navbar-brand d-flex align-items-center">
|
||||||
|
<Image
|
||||||
|
src="/assets/img/CAT.png"
|
||||||
|
alt="CatLink"
|
||||||
|
width={89}
|
||||||
|
height={89}
|
||||||
|
/>
|
||||||
|
<span className="ms-2">CatLink</span>
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
className="navbar-toggler"
|
||||||
|
type="button"
|
||||||
|
data-bs-toggle="collapse"
|
||||||
|
data-bs-target="#navcol-1"
|
||||||
|
/>
|
||||||
|
<div className="collapse navbar-collapse" id="navcol-1">
|
||||||
|
<Link href="/auth/login" className="btn btn-primary ms-auto">
|
||||||
|
<i className="fa fa-user"></i>
|
||||||
|
<span className="d-none d-sm-inline"> Вход</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Main content: добавляем дополнительный отступ сверху */}
|
||||||
|
<main
|
||||||
|
style={{
|
||||||
|
paddingTop: "100px", // <-- тут увеличиваем интервал
|
||||||
|
paddingBottom: "200px"
|
||||||
|
}}
|
||||||
|
// или через класс bootstrap: className="pt-5 pb-5"
|
||||||
|
>
|
||||||
|
<LayoutWrapper>{children}</LayoutWrapper>
|
||||||
|
</main>
|
||||||
|
{/* Footer (fixed to bottom) */}
|
||||||
|
<footer className="bg-light footer fixed-bottom border-top">
|
||||||
|
<div className="container py-2">
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-lg-6 text-center text-lg-start mb-2 mb-lg-0">
|
||||||
|
<ul className="list-inline mb-1">
|
||||||
|
<li className="list-inline-item">
|
||||||
|
<Link href="#">About</Link>
|
||||||
|
</li>
|
||||||
|
<li className="list-inline-item">⋅</li>
|
||||||
|
<li className="list-inline-item">
|
||||||
|
<Link href="#">Contact</Link>
|
||||||
|
</li>
|
||||||
|
<li className="list-inline-item">⋅</li>
|
||||||
|
<li className="list-inline-item">
|
||||||
|
<Link href="#">Terms of Use</Link>
|
||||||
|
</li>
|
||||||
|
<li className="list-inline-item">⋅</li>
|
||||||
|
<li className="list-inline-item">
|
||||||
|
<Link href="#">Privacy Policy</Link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p className="text-muted small mb-0">© CatLink 2025</p>
|
||||||
|
</div>
|
||||||
|
<div className="col-lg-6 text-center text-lg-end">
|
||||||
|
<ul className="list-inline mb-0">
|
||||||
|
<li className="list-inline-item">
|
||||||
|
<Link href="#">
|
||||||
|
<i className="fa fa-facebook fa-lg fa-fw"></i>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li className="list-inline-item">
|
||||||
|
<Link href="#">
|
||||||
|
<i className="fa fa-twitter fa-lg fa-fw"></i>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li className="list-inline-item">
|
||||||
|
<Link href="#">
|
||||||
|
<i className="fa fa-instagram fa-lg fa-fw"></i>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
{/* Scripts */}
|
||||||
|
<Script
|
||||||
|
src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.0/jquery.min.js"
|
||||||
|
strategy="beforeInteractive"
|
||||||
|
/>
|
||||||
|
<Script
|
||||||
|
src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
|
||||||
|
strategy="beforeInteractive"
|
||||||
|
/>
|
||||||
|
<Script src="/assets/js/bs-init.js" strategy="lazyOnload" />
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
// src/app/(protected)/layout.tsx
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
import { LayoutWrapper } from '../components/LayoutWrapper'
|
||||||
|
|
||||||
|
export default function ProtectedLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
// Корневой layout уже выдал <html><head><body>…
|
||||||
|
// Здесь просто оборачиваем содержимое в свой Wrapper
|
||||||
|
return (
|
||||||
|
<LayoutWrapper>
|
||||||
|
{children}
|
||||||
|
</LayoutWrapper>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
// src/app/layout.tsx
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
|
import "./globals.css";
|
||||||
|
import { ReactNode } from "react";
|
||||||
|
import { LayoutWrapper } from "../components/LayoutWrapper";
|
||||||
|
|
||||||
|
const geistSans = Geist({
|
||||||
|
variable: "--font-geist-sans",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
const geistMono = Geist_Mono({
|
||||||
|
variable: "--font-geist-mono",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "CatLink",
|
||||||
|
description: "Ваши ссылки в одном месте",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charSet="utf-8" />
|
||||||
|
<link
|
||||||
|
rel="apple-touch-icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="180x180"
|
||||||
|
href="/assets/img/apple-touch-icon.png?h=0f5e29c1169e75a7003e818478b67caa"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="96x96"
|
||||||
|
href="/assets/img/favicon-96x96.png?h=c8792ce927e01a494c4af48bed5dc5e3"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="192x192"
|
||||||
|
href="/assets/img/web-app-manifest-192x192.png?h=c8792ce927e01a494c4af48bed5dc5e3"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="512x512"
|
||||||
|
href="/assets/img/web-app-manifest-512x512.png?h=c8792ce927e01a494c4af48bed5dc5e3"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Bootstrap & Lato & Icons */}
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="/assets/bootstrap/css/bootstrap.min.css?h=608a9825a1f76f674715160908e57785"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="/assets/css/Lato.css?h=8253736d3a23b522f64b7e7d96d1d8ff"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<link rel="manifest" href="/manifest.json?h=457941ffad3c027c946331c09a4d7d2f" />
|
||||||
|
<meta name="theme-color" content="#ffffff" />
|
||||||
|
</head>
|
||||||
|
<body
|
||||||
|
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||||
|
>
|
||||||
|
<LayoutWrapper>{children}</LayoutWrapper>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
// src/app/layout.tsx
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
|
import "../../globals.css";
|
||||||
|
import { ReactNode } from "react";
|
||||||
|
import { LayoutWrapper } from "../components/LayoutWrapper";
|
||||||
|
|
||||||
|
const geistSans = Geist({
|
||||||
|
variable: "--font-geist-sans",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
const geistMono = Geist_Mono({
|
||||||
|
variable: "--font-geist-mono",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "CatLink",
|
||||||
|
description: "Ваши ссылки в одном месте",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charSet="utf-8" />
|
||||||
|
<link
|
||||||
|
rel="apple-touch-icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="180x180"
|
||||||
|
href="/assets/img/apple-touch-icon.png?h=0f5e29c1169e75a7003e818478b67caa"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="96x96"
|
||||||
|
href="/assets/img/favicon-96x96.png?h=c8792ce927e01a494c4af48bed5dc5e3"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="192x192"
|
||||||
|
href="/assets/img/web-app-manifest-192x192.png?h=c8792ce927e01a494c4af48bed5dc5e3"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="512x512"
|
||||||
|
href="/assets/img/web-app-manifest-512x512.png?h=c8792ce927e01a494c4af48bed5dc5e3"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Bootstrap & Lato & Icons */}
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="/assets/bootstrap/css/bootstrap.min.css?h=608a9825a1f76f674715160908e57785"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="/assets/css/Lato.css?h=8253736d3a23b522f64b7e7d96d1d8ff"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<link rel="manifest" href="/manifest.json?h=457941ffad3c027c946331c09a4d7d2f" />
|
||||||
|
<meta name="theme-color" content="#ffffff" />
|
||||||
|
</head>
|
||||||
|
<body
|
||||||
|
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||||
|
>
|
||||||
|
<LayoutWrapper>{children}</LayoutWrapper>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
// src/app/layout.tsx
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
|
import "../globals.css";
|
||||||
|
import { ReactNode } from "react";
|
||||||
|
import { LayoutWrapper } from "../components/LayoutWrapper";
|
||||||
|
|
||||||
|
const geistSans = Geist({
|
||||||
|
variable: "--font-geist-sans",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
const geistMono = Geist_Mono({
|
||||||
|
variable: "--font-geist-mono",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "CatLink",
|
||||||
|
description: "Ваши ссылки в одном месте",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charSet="utf-8" />
|
||||||
|
<link
|
||||||
|
rel="apple-touch-icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="180x180"
|
||||||
|
href="/assets/img/apple-touch-icon.png?h=0f5e29c1169e75a7003e818478b67caa"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="96x96"
|
||||||
|
href="/assets/img/favicon-96x96.png?h=c8792ce927e01a494c4af48bed5dc5e3"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="192x192"
|
||||||
|
href="/assets/img/web-app-manifest-192x192.png?h=c8792ce927e01a494c4af48bed5dc5e3"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="512x512"
|
||||||
|
href="/assets/img/web-app-manifest-512x512.png?h=c8792ce927e01a494c4af48bed5dc5e3"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Bootstrap & Lato & Icons */}
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="/assets/bootstrap/css/bootstrap.min.css?h=608a9825a1f76f674715160908e57785"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="/assets/css/Lato.css?h=8253736d3a23b522f64b7e7d96d1d8ff"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<link rel="manifest" href="/manifest.json?h=457941ffad3c027c946331c09a4d7d2f" />
|
||||||
|
<meta name="theme-color" content="#ffffff" />
|
||||||
|
</head>
|
||||||
|
<body
|
||||||
|
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||||
|
>
|
||||||
|
<LayoutWrapper>{children}</LayoutWrapper>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
// src/app/page.tsx
|
||||||
|
export default function HomePage() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<header
|
||||||
|
className="text-center text-white masthead"
|
||||||
|
style={{
|
||||||
|
background: "url('/assets/img/bg-masthead.jpg') no-repeat center center",
|
||||||
|
backgroundSize: 'cover',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="overlay"></div>
|
||||||
|
<div className="container py-5">
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-xl-9 mx-auto">
|
||||||
|
<h1 className="mb-5">Ваши ссылки. Ваш стиль. Ваш CatLink.</h1>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-10 col-lg-8 col-xl-7 mx-auto">
|
||||||
|
<form className="d-flex">
|
||||||
|
<input
|
||||||
|
className="form-control form-control-lg me-2"
|
||||||
|
type="email"
|
||||||
|
placeholder="Введите электронную почту"
|
||||||
|
/>
|
||||||
|
<button className="btn btn-primary btn-lg" type="submit">
|
||||||
|
Начать
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="text-center bg-light features-icons py-5">
|
||||||
|
<div className="container">
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-lg-4 mb-4">
|
||||||
|
<div className="features-icons-item">
|
||||||
|
<div className="features-icons-icon mb-3">
|
||||||
|
<img src="/assets/img/CAT.png" alt="CatLink" width={89} height={89} />
|
||||||
|
</div>
|
||||||
|
<h3>Публикация</h3>
|
||||||
|
<p className="lead mb-0">
|
||||||
|
Делитесь единой ссылкой catlinks.kr/ваше-имя в био, мессенджерах и письмах.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-lg-4 mb-4">
|
||||||
|
<div className="features-icons-item">
|
||||||
|
<div className="features-icons-icon mb-3">
|
||||||
|
<img src="/assets/img/CAT.png" alt="CatLink" width={89} height={89} />
|
||||||
|
</div>
|
||||||
|
<h3>Почему CatLink?</h3>
|
||||||
|
<p className="lead mb-0">
|
||||||
|
Повяжите свои миры одной «хвостовой» ссылкой.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-lg-4 mb-4">
|
||||||
|
<div className="features-icons-item">
|
||||||
|
<div className="features-icons-icon mb-3">
|
||||||
|
<img src="/assets/img/CAT.png" alt="CatLink" width={89} height={89} />
|
||||||
|
</div>
|
||||||
|
<h3>Разместите всё важное на одной ссылке</h3>
|
||||||
|
<p className="lead mb-0">
|
||||||
|
Идите дальше, как кошка: легко и грациозно.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
// src/app/[username]/layout.tsx
|
||||||
|
import { ReactNode } from 'react'
|
||||||
|
|
||||||
|
export const runtime = 'edge' // по желанию, можно убрать
|
||||||
|
|
||||||
|
/**
|
||||||
|
* В этом layout мы просто рендерим дочернюю страницу без
|
||||||
|
* общего Header/Footer из корневого layout.tsx
|
||||||
|
*/
|
||||||
|
export default function UserLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: ReactNode
|
||||||
|
}) {
|
||||||
|
return <>{children}</>
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
// src/app/[username]/layout.tsx
|
||||||
|
import { ReactNode } from 'react'
|
||||||
|
|
||||||
|
export const runtime = 'edge' // по желанию, можно убрать
|
||||||
|
|
||||||
|
/**
|
||||||
|
* В этом layout мы просто рендерим дочернюю страницу без
|
||||||
|
* общего Header/Footer из корневого layout.tsx
|
||||||
|
*/
|
||||||
|
export default function UserLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: ReactNode
|
||||||
|
}) {
|
||||||
|
return <>{children}</>
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
// src/app/[username]/layout.tsx
|
||||||
|
import { ReactNode } from "react";
|
||||||
|
|
||||||
|
/** Ничего не рендерим, просто передаём children */
|
||||||
|
export default function UserLayout({ children }: { children: ReactNode }) {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
// src/app/[username]/layout.tsx
|
||||||
|
import { ReactNode } from "react";
|
||||||
|
|
||||||
|
/** Ничего не рендерим, просто передаём children */
|
||||||
|
export default function UserLayout({ children }: { children: ReactNode }) {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
// src/app/[username]/layout.tsx
|
||||||
|
import { ReactNode } from "react";
|
||||||
|
|
||||||
|
/** Ничего не рендерим, просто передаём children */
|
||||||
|
export default function UserLayout({ children }: { children: ReactNode }) {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
// src/app/[username]/layout.tsx
|
||||||
|
import { ReactNode } from "react";
|
||||||
|
|
||||||
|
/** Ничего не рендерим, просто передаём children */
|
||||||
|
export default function UserLayout({ children }: { children: ReactNode }) {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
// src/app/[username]/layout.tsx
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { ReactNode } from 'react'
|
||||||
|
import Script from 'next/script'
|
||||||
|
|
||||||
|
export default function UserLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Здесь рендерим только public-children без глобального Header/Footer */}
|
||||||
|
{children}
|
||||||
|
|
||||||
|
{/* Bootstrap JS bundle (Popper+Bootstrap) */}
|
||||||
|
<Script
|
||||||
|
src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
|
||||||
|
strategy="beforeInteractive"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
// src/app/[username]/layout.tsx
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { ReactNode } from 'react'
|
||||||
|
import Script from 'next/script'
|
||||||
|
|
||||||
|
export default function UserLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Здесь рендерим только public-children без глобального Header/Footer */}
|
||||||
|
{children}
|
||||||
|
|
||||||
|
{/* Bootstrap JS bundle (Popper+Bootstrap) */}
|
||||||
|
<Script
|
||||||
|
src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
|
||||||
|
strategy="beforeInteractive"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
// src/app/[username]/layout.tsx
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { ReactNode } from 'react'
|
||||||
|
import Script from 'next/script'
|
||||||
|
import "../globals.css";
|
||||||
|
export default function UserLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Здесь рендерим только public-children без глобального Header/Footer */}
|
||||||
|
{children}
|
||||||
|
|
||||||
|
{/* Bootstrap JS bundle (Popper+Bootstrap) */}
|
||||||
|
<Script
|
||||||
|
src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
|
||||||
|
strategy="beforeInteractive"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
// src/app/[username]/layout.tsx
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { ReactNode } from 'react'
|
||||||
|
import Script from 'next/script'
|
||||||
|
import "../globals.css";
|
||||||
|
export default function UserLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Здесь рендерим только public-children без глобального Header/Footer */}
|
||||||
|
{children}
|
||||||
|
|
||||||
|
{/* Bootstrap JS bundle (Popper+Bootstrap) */}
|
||||||
|
<Script
|
||||||
|
src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
|
||||||
|
strategy="beforeInteractive"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
// src/app/[username]/layout.tsx
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { ReactNode } from 'react'
|
||||||
|
import Script from 'next/script'
|
||||||
|
import "../globals.css";
|
||||||
|
export default function UserLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<> <head>
|
||||||
|
<meta charSet="utf-8" />
|
||||||
|
<link
|
||||||
|
rel="apple-touch-icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="180x180"
|
||||||
|
href="/assets/img/apple-touch-icon.png?h=0f5e29c1169e75a7003e818478b67caa"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="96x96"
|
||||||
|
href="/assets/img/favicon-96x96.png?h=c8792ce927e01a494c4af48bed5dc5e3"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="192x192"
|
||||||
|
href="/assets/img/web-app-manifest-192x192.png?h=c8792ce927e01a494c4af48bed5dc5e3"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="512x512"
|
||||||
|
href="/assets/img/web-app-manifest-512x512.png?h=c8792ce927e01a494c4af48bed5dc5e3"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="/assets/bootstrap/css/bootstrap.min.css?h=608a9825a1f76f674715160908e57785"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="/assets/css/Lato.css?h=8253736d3a23b522f64b7e7d96d1d8ff"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="manifest"
|
||||||
|
href="/manifest.json?h=457941ffad3c027c946331c09a4d7d2f"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css"
|
||||||
|
/>
|
||||||
|
<link rel="manifest" href="/manifest.json" />
|
||||||
|
<meta name="theme-color" content="#ffffff" />
|
||||||
|
|
||||||
|
</head>
|
||||||
|
{/* Здесь рендерим только public-children без глобального Header/Footer */}
|
||||||
|
{children}
|
||||||
|
|
||||||
|
{/* Bootstrap JS bundle (Popper+Bootstrap) */}
|
||||||
|
<Script
|
||||||
|
src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
|
||||||
|
strategy="beforeInteractive"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
// src/app/[username]/layout.tsx
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { ReactNode } from 'react'
|
||||||
|
import Script from 'next/script'
|
||||||
|
import "../globals.css";
|
||||||
|
export default function UserLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<> <head>
|
||||||
|
<meta charSet="utf-8" />
|
||||||
|
<link
|
||||||
|
rel="apple-touch-icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="180x180"
|
||||||
|
href="/assets/img/apple-touch-icon.png?h=0f5e29c1169e75a7003e818478b67caa"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="96x96"
|
||||||
|
href="/assets/img/favicon-96x96.png?h=c8792ce927e01a494c4af48bed5dc5e3"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="192x192"
|
||||||
|
href="/assets/img/web-app-manifest-192x192.png?h=c8792ce927e01a494c4af48bed5dc5e3"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="512x512"
|
||||||
|
href="/assets/img/web-app-manifest-512x512.png?h=c8792ce927e01a494c4af48bed5dc5e3"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="/assets/bootstrap/css/bootstrap.min.css?h=608a9825a1f76f674715160908e57785"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="/assets/css/Lato.css?h=8253736d3a23b522f64b7e7d96d1d8ff"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="manifest"
|
||||||
|
href="/manifest.json?h=457941ffad3c027c946331c09a4d7d2f"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css"
|
||||||
|
/>
|
||||||
|
<link rel="manifest" href="/manifest.json" />
|
||||||
|
<meta name="theme-color" content="#ffffff" />
|
||||||
|
|
||||||
|
</head>
|
||||||
|
{/* Здесь рендерим только public-children без глобального Header/Footer */}
|
||||||
|
{children}
|
||||||
|
|
||||||
|
{/* Bootstrap JS bundle (Popper+Bootstrap) */}
|
||||||
|
<Script
|
||||||
|
src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
|
||||||
|
strategy="beforeInteractive"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
// src/app/[username]/page.tsx
|
||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
import Image from 'next/image';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
interface LinkItem {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
icon?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Group {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
icon?: string;
|
||||||
|
links: LinkItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserGroupsData {
|
||||||
|
username: string;
|
||||||
|
groups: Group[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function UserPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: { username: string }; // уже не Promise
|
||||||
|
}) {
|
||||||
|
const { username } = params;
|
||||||
|
const API = process.env.NEXT_PUBLIC_API_URL!;
|
||||||
|
|
||||||
|
const res = await fetch(`${API}/api/users/${username}/public/`, {
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
if (res.status === 404) return notFound();
|
||||||
|
if (!res.ok) throw new Error('Ошибка загрузки публичных данных');
|
||||||
|
|
||||||
|
const data: UserGroupsData = await res.json();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<h2 className="text-center mb-4">{data.username}</h2>
|
||||||
|
<div className="accordion" id="groupsAccordion">
|
||||||
|
{data.groups.map((group) => (
|
||||||
|
<div className="accordion-item" key={group.id}>
|
||||||
|
<h2 className="accordion-header" id={`heading${group.id}`}>
|
||||||
|
<button
|
||||||
|
className="accordion-button collapsed"
|
||||||
|
type="button"
|
||||||
|
data-bs-toggle="collapse"
|
||||||
|
data-bs-target={`#collapse${group.id}`}
|
||||||
|
aria-expanded="false"
|
||||||
|
aria-controls={`collapse${group.id}`}
|
||||||
|
>
|
||||||
|
{group.icon && (
|
||||||
|
<Image
|
||||||
|
src={group.icon.startsWith('http')
|
||||||
|
? group.icon
|
||||||
|
: `${API}${group.icon}`}
|
||||||
|
alt={group.name}
|
||||||
|
width={24}
|
||||||
|
height={24}
|
||||||
|
className="me-2"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="flex-grow-1">{group.name}</span>
|
||||||
|
<span className="badge bg-primary ms-2">
|
||||||
|
{group.links.length}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</h2>
|
||||||
|
<div
|
||||||
|
id={`collapse${group.id}`}
|
||||||
|
className="accordion-collapse collapse"
|
||||||
|
aria-labelledby={`heading${group.id}`}
|
||||||
|
data-bs-parent="#groupsAccordion"
|
||||||
|
>
|
||||||
|
<div className="accordion-body">
|
||||||
|
<ul className="list-unstyled mb-0">
|
||||||
|
{group.links.map((link) => (
|
||||||
|
<li
|
||||||
|
key={link.id}
|
||||||
|
className="d-flex align-items-center mb-2"
|
||||||
|
>
|
||||||
|
{link.icon && (
|
||||||
|
<Image
|
||||||
|
src={link.icon.startsWith('http')
|
||||||
|
? link.icon
|
||||||
|
: `${API}${link.icon}`}
|
||||||
|
alt={link.title}
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
|
className="me-2"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Link
|
||||||
|
href={link.url}
|
||||||
|
target="_blank"
|
||||||
|
className="stretched-link"
|
||||||
|
>
|
||||||
|
{link.title}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
// src/app/[username]/page.tsx
|
||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
import Image from 'next/image';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
interface LinkItem {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
icon?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Group {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
icon?: string;
|
||||||
|
links: LinkItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserGroupsData {
|
||||||
|
username: string;
|
||||||
|
groups: Group[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function UserPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: { username: string }; // уже не Promise
|
||||||
|
}) {
|
||||||
|
const { username } = params;
|
||||||
|
const API = process.env.NEXT_PUBLIC_API_URL!;
|
||||||
|
|
||||||
|
const res = await fetch(`${API}/api/users/${username}/public/`, {
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
if (res.status === 404) return notFound();
|
||||||
|
if (!res.ok) throw new Error('Ошибка загрузки публичных данных');
|
||||||
|
|
||||||
|
const data: UserGroupsData = await res.json();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<h2 className="text-center mb-4">{data.username}</h2>
|
||||||
|
<div className="accordion" id="groupsAccordion">
|
||||||
|
{data.groups.map((group) => (
|
||||||
|
<div className="accordion-item" key={group.id}>
|
||||||
|
<h2 className="accordion-header" id={`heading${group.id}`}>
|
||||||
|
<button
|
||||||
|
className="accordion-button collapsed"
|
||||||
|
type="button"
|
||||||
|
data-bs-toggle="collapse"
|
||||||
|
data-bs-target={`#collapse${group.id}`}
|
||||||
|
aria-expanded="false"
|
||||||
|
aria-controls={`collapse${group.id}`}
|
||||||
|
>
|
||||||
|
{group.icon && (
|
||||||
|
<Image
|
||||||
|
src={group.icon.startsWith('http')
|
||||||
|
? group.icon
|
||||||
|
: `${API}${group.icon}`}
|
||||||
|
alt={group.name}
|
||||||
|
width={24}
|
||||||
|
height={24}
|
||||||
|
className="me-2"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="flex-grow-1">{group.name}</span>
|
||||||
|
<span className="badge bg-primary ms-2">
|
||||||
|
{group.links.length}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</h2>
|
||||||
|
<div
|
||||||
|
id={`collapse${group.id}`}
|
||||||
|
className="accordion-collapse collapse"
|
||||||
|
aria-labelledby={`heading${group.id}`}
|
||||||
|
data-bs-parent="#groupsAccordion"
|
||||||
|
>
|
||||||
|
<div className="accordion-body">
|
||||||
|
<ul className="list-unstyled mb-0">
|
||||||
|
{group.links.map((link) => (
|
||||||
|
<li
|
||||||
|
key={link.id}
|
||||||
|
className="d-flex align-items-center mb-2"
|
||||||
|
>
|
||||||
|
{link.icon && (
|
||||||
|
<Image
|
||||||
|
src={link.icon.startsWith('http')
|
||||||
|
? link.icon
|
||||||
|
: `${API}${link.icon}`}
|
||||||
|
alt={link.title}
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
|
className="me-2"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Link
|
||||||
|
href={link.url}
|
||||||
|
target="_blank"
|
||||||
|
className="stretched-link"
|
||||||
|
>
|
||||||
|
{link.title}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
// src/app/layout.tsx
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
|
import "../globals.css";
|
||||||
|
import { ReactNode } from "react";
|
||||||
|
import Script from "next/script";
|
||||||
|
import Link from "next/link";
|
||||||
|
import Image from "next/image";
|
||||||
|
|
||||||
|
const geistSans = Geist({
|
||||||
|
variable: "--font-geist-sans",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const geistMono = Geist_Mono({
|
||||||
|
variable: "--font-geist-mono",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "CatLink",
|
||||||
|
description: "Ваши ссылки в одном месте",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charSet="utf-8" />
|
||||||
|
<link
|
||||||
|
rel="apple-touch-icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="180x180"
|
||||||
|
href="/assets/img/apple-touch-icon.png?h=0f5e29c1169e75a7003e818478b67caa"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="96x96"
|
||||||
|
href="/assets/img/favicon-96x96.png?h=c8792ce927e01a494c4af48bed5dc5e3"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="192x192"
|
||||||
|
href="/assets/img/web-app-manifest-192x192.png?h=c8792ce927e01a494c4af48bed5dc5e3"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="512x512"
|
||||||
|
href="/assets/img/web-app-manifest-512x512.png?h=c8792ce927e01a494c4af48bed5dc5e3"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="/assets/bootstrap/css/bootstrap.min.css?h=608a9825a1f76f674715160908e57785"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="/assets/css/Lato.css?h=8253736d3a23b522f64b7e7d96d1d8ff"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="manifest"
|
||||||
|
href="/manifest.json?h=457941ffad3c027c946331c09a4d7d2f"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css"
|
||||||
|
/>
|
||||||
|
<link rel="manifest" href="/manifest.json" />
|
||||||
|
<meta name="theme-color" content="#ffffff" />
|
||||||
|
|
||||||
|
</head>
|
||||||
|
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
|
||||||
|
{/* Header (fixed to top) */}
|
||||||
|
<nav className="navbar navbar-expand bg-light fixed-top shadow-sm">
|
||||||
|
<div className="container">
|
||||||
|
<Link href="/" className="navbar-brand d-flex align-items-center">
|
||||||
|
<Image
|
||||||
|
src="/assets/img/CAT.png"
|
||||||
|
alt="CatLink"
|
||||||
|
width={89}
|
||||||
|
height={89}
|
||||||
|
/>
|
||||||
|
<span className="ms-2">CatLink</span>
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
className="navbar-toggler"
|
||||||
|
type="button"
|
||||||
|
data-bs-toggle="collapse"
|
||||||
|
data-bs-target="#navcol-1"
|
||||||
|
/>
|
||||||
|
<div className="collapse navbar-collapse" id="navcol-1">
|
||||||
|
<Link href="/auth/login" className="btn btn-primary ms-auto">
|
||||||
|
<i className="fa fa-user"></i>
|
||||||
|
<span className="d-none d-sm-inline"> Вход</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Main content: добавляем дополнительный отступ сверху */}
|
||||||
|
<main
|
||||||
|
style={{
|
||||||
|
paddingTop: "100px", // <-- тут увеличиваем интервал
|
||||||
|
paddingBottom: "200px"
|
||||||
|
}}
|
||||||
|
// или через класс bootstrap: className="pt-5 pb-5"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
{/* Footer (fixed to bottom) */}
|
||||||
|
<footer className="bg-light footer fixed-bottom border-top">
|
||||||
|
<div className="container py-2">
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-lg-6 text-center text-lg-start mb-2 mb-lg-0">
|
||||||
|
<ul className="list-inline mb-1">
|
||||||
|
<li className="list-inline-item">
|
||||||
|
<Link href="#">About</Link>
|
||||||
|
</li>
|
||||||
|
<li className="list-inline-item">⋅</li>
|
||||||
|
<li className="list-inline-item">
|
||||||
|
<Link href="#">Contact</Link>
|
||||||
|
</li>
|
||||||
|
<li className="list-inline-item">⋅</li>
|
||||||
|
<li className="list-inline-item">
|
||||||
|
<Link href="#">Terms of Use</Link>
|
||||||
|
</li>
|
||||||
|
<li className="list-inline-item">⋅</li>
|
||||||
|
<li className="list-inline-item">
|
||||||
|
<Link href="#">Privacy Policy</Link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p className="text-muted small mb-0">© CatLink 2025</p>
|
||||||
|
</div>
|
||||||
|
<div className="col-lg-6 text-center text-lg-end">
|
||||||
|
<ul className="list-inline mb-0">
|
||||||
|
<li className="list-inline-item">
|
||||||
|
<Link href="#">
|
||||||
|
<i className="fa fa-facebook fa-lg fa-fw"></i>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li className="list-inline-item">
|
||||||
|
<Link href="#">
|
||||||
|
<i className="fa fa-twitter fa-lg fa-fw"></i>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li className="list-inline-item">
|
||||||
|
<Link href="#">
|
||||||
|
<i className="fa fa-instagram fa-lg fa-fw"></i>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
{/* Scripts */}
|
||||||
|
<Script
|
||||||
|
src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.0/jquery.min.js"
|
||||||
|
strategy="beforeInteractive"
|
||||||
|
/>
|
||||||
|
<Script
|
||||||
|
src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
|
||||||
|
strategy="beforeInteractive"
|
||||||
|
/>
|
||||||
|
<Script src="/assets/js/bs-init.js" strategy="lazyOnload" />
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
// src/app/layout.tsx
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
|
import "../globals.css";
|
||||||
|
import { ReactNode } from "react";
|
||||||
|
import Script from "next/script";
|
||||||
|
import Link from "next/link";
|
||||||
|
import Image from "next/image";
|
||||||
|
|
||||||
|
const geistSans = Geist({
|
||||||
|
variable: "--font-geist-sans",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const geistMono = Geist_Mono({
|
||||||
|
variable: "--font-geist-mono",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "CatLink",
|
||||||
|
description: "Ваши ссылки в одном месте",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charSet="utf-8" />
|
||||||
|
<link
|
||||||
|
rel="apple-touch-icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="180x180"
|
||||||
|
href="/assets/img/apple-touch-icon.png?h=0f5e29c1169e75a7003e818478b67caa"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="96x96"
|
||||||
|
href="/assets/img/favicon-96x96.png?h=c8792ce927e01a494c4af48bed5dc5e3"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="192x192"
|
||||||
|
href="/assets/img/web-app-manifest-192x192.png?h=c8792ce927e01a494c4af48bed5dc5e3"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="512x512"
|
||||||
|
href="/assets/img/web-app-manifest-512x512.png?h=c8792ce927e01a494c4af48bed5dc5e3"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="/assets/bootstrap/css/bootstrap.min.css?h=608a9825a1f76f674715160908e57785"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="/assets/css/Lato.css?h=8253736d3a23b522f64b7e7d96d1d8ff"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="manifest"
|
||||||
|
href="/manifest.json?h=457941ffad3c027c946331c09a4d7d2f"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css"
|
||||||
|
/>
|
||||||
|
<link rel="manifest" href="/manifest.json" />
|
||||||
|
<meta name="theme-color" content="#ffffff" />
|
||||||
|
|
||||||
|
</head>
|
||||||
|
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
|
||||||
|
{/* Header (fixed to top) */}
|
||||||
|
<nav className="navbar navbar-expand bg-light fixed-top shadow-sm">
|
||||||
|
<div className="container">
|
||||||
|
<Link href="/" className="navbar-brand d-flex align-items-center">
|
||||||
|
<Image
|
||||||
|
src="/assets/img/CAT.png"
|
||||||
|
alt="CatLink"
|
||||||
|
width={89}
|
||||||
|
height={89}
|
||||||
|
/>
|
||||||
|
<span className="ms-2">CatLink</span>
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
className="navbar-toggler"
|
||||||
|
type="button"
|
||||||
|
data-bs-toggle="collapse"
|
||||||
|
data-bs-target="#navcol-1"
|
||||||
|
/>
|
||||||
|
<div className="collapse navbar-collapse" id="navcol-1">
|
||||||
|
<Link href="/auth/login" className="btn btn-primary ms-auto">
|
||||||
|
<i className="fa fa-user"></i>
|
||||||
|
<span className="d-none d-sm-inline"> Вход</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Main content: добавляем дополнительный отступ сверху */}
|
||||||
|
<main
|
||||||
|
style={{
|
||||||
|
paddingTop: "100px", // <-- тут увеличиваем интервал
|
||||||
|
paddingBottom: "200px"
|
||||||
|
}}
|
||||||
|
// или через класс bootstrap: className="pt-5 pb-5"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
{/* Footer (fixed to bottom) */}
|
||||||
|
<footer className="bg-light footer fixed-bottom border-top">
|
||||||
|
<div className="container py-2">
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-lg-6 text-center text-lg-start mb-2 mb-lg-0">
|
||||||
|
<ul className="list-inline mb-1">
|
||||||
|
<li className="list-inline-item">
|
||||||
|
<Link href="#">About</Link>
|
||||||
|
</li>
|
||||||
|
<li className="list-inline-item">⋅</li>
|
||||||
|
<li className="list-inline-item">
|
||||||
|
<Link href="#">Contact</Link>
|
||||||
|
</li>
|
||||||
|
<li className="list-inline-item">⋅</li>
|
||||||
|
<li className="list-inline-item">
|
||||||
|
<Link href="#">Terms of Use</Link>
|
||||||
|
</li>
|
||||||
|
<li className="list-inline-item">⋅</li>
|
||||||
|
<li className="list-inline-item">
|
||||||
|
<Link href="#">Privacy Policy</Link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p className="text-muted small mb-0">© CatLink 2025</p>
|
||||||
|
</div>
|
||||||
|
<div className="col-lg-6 text-center text-lg-end">
|
||||||
|
<ul className="list-inline mb-0">
|
||||||
|
<li className="list-inline-item">
|
||||||
|
<Link href="#">
|
||||||
|
<i className="fa fa-facebook fa-lg fa-fw"></i>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li className="list-inline-item">
|
||||||
|
<Link href="#">
|
||||||
|
<i className="fa fa-twitter fa-lg fa-fw"></i>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li className="list-inline-item">
|
||||||
|
<Link href="#">
|
||||||
|
<i className="fa fa-instagram fa-lg fa-fw"></i>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
{/* Scripts */}
|
||||||
|
<Script
|
||||||
|
src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.0/jquery.min.js"
|
||||||
|
strategy="beforeInteractive"
|
||||||
|
/>
|
||||||
|
<Script
|
||||||
|
src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
|
||||||
|
strategy="beforeInteractive"
|
||||||
|
/>
|
||||||
|
<Script src="/assets/js/bs-init.js" strategy="lazyOnload" />
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
// src/app/layout.tsx
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
|
import "../globals.css";
|
||||||
|
import { ReactNode } from "react";
|
||||||
|
import Script from "next/script";
|
||||||
|
import Link from "next/link";
|
||||||
|
import Image from "next/image";
|
||||||
|
|
||||||
|
const geistSans = Geist({
|
||||||
|
variable: "--font-geist-sans",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const geistMono = Geist_Mono({
|
||||||
|
variable: "--font-geist-mono",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "CatLink",
|
||||||
|
description: "Ваши ссылки в одном месте",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charSet="utf-8" />
|
||||||
|
<link
|
||||||
|
rel="apple-touch-icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="180x180"
|
||||||
|
href="/assets/img/apple-touch-icon.png?h=0f5e29c1169e75a7003e818478b67caa"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="96x96"
|
||||||
|
href="/assets/img/favicon-96x96.png?h=c8792ce927e01a494c4af48bed5dc5e3"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="192x192"
|
||||||
|
href="/assets/img/web-app-manifest-192x192.png?h=c8792ce927e01a494c4af48bed5dc5e3"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="512x512"
|
||||||
|
href="/assets/img/web-app-manifest-512x512.png?h=c8792ce927e01a494c4af48bed5dc5e3"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="/assets/bootstrap/css/bootstrap.min.css?h=608a9825a1f76f674715160908e57785"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="/assets/css/Lato.css?h=8253736d3a23b522f64b7e7d96d1d8ff"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="manifest"
|
||||||
|
href="/manifest.json?h=457941ffad3c027c946331c09a4d7d2f"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css"
|
||||||
|
/>
|
||||||
|
<link rel="manifest" href="/manifest.json" />
|
||||||
|
<meta name="theme-color" content="#ffffff" />
|
||||||
|
|
||||||
|
</head>
|
||||||
|
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
|
||||||
|
{/* Header (fixed to top) */}
|
||||||
|
<nav className="navbar navbar-expand bg-light fixed-top shadow-sm">
|
||||||
|
<div className="container">
|
||||||
|
<Link href="/" className="navbar-brand d-flex align-items-center">
|
||||||
|
<Image
|
||||||
|
src="/assets/img/CAT.png"
|
||||||
|
alt="CatLink"
|
||||||
|
width={89}
|
||||||
|
height={89}
|
||||||
|
/>
|
||||||
|
<span className="ms-2">CatLink</span>
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
className="navbar-toggler"
|
||||||
|
type="button"
|
||||||
|
data-bs-toggle="collapse"
|
||||||
|
data-bs-target="#navcol-1"
|
||||||
|
/>
|
||||||
|
<div className="collapse navbar-collapse" id="navcol-1">
|
||||||
|
<Link href="/auth/login" className="btn btn-primary ms-auto">
|
||||||
|
<i className="fa fa-user"></i>
|
||||||
|
<span className="d-none d-sm-inline"> Вход</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Main content: добавляем дополнительный отступ сверху */}
|
||||||
|
<main
|
||||||
|
style={{
|
||||||
|
paddingTop: "100px", // <-- тут увеличиваем интервал
|
||||||
|
paddingBottom: "200px"
|
||||||
|
}}
|
||||||
|
// или через класс bootstrap: className="pt-5 pb-5"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
|
||||||
|
{/* Scripts */}
|
||||||
|
<Script
|
||||||
|
src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.0/jquery.min.js"
|
||||||
|
strategy="beforeInteractive"
|
||||||
|
/>
|
||||||
|
<Script
|
||||||
|
src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
|
||||||
|
strategy="beforeInteractive"
|
||||||
|
/>
|
||||||
|
<Script src="/assets/js/bs-init.js" strategy="lazyOnload" />
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
// src/app/layout.tsx
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
|
import "../globals.css";
|
||||||
|
import { ReactNode } from "react";
|
||||||
|
import Script from "next/script";
|
||||||
|
import Link from "next/link";
|
||||||
|
import Image from "next/image";
|
||||||
|
|
||||||
|
const geistSans = Geist({
|
||||||
|
variable: "--font-geist-sans",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const geistMono = Geist_Mono({
|
||||||
|
variable: "--font-geist-mono",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "CatLink",
|
||||||
|
description: "Ваши ссылки в одном месте",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charSet="utf-8" />
|
||||||
|
<link
|
||||||
|
rel="apple-touch-icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="180x180"
|
||||||
|
href="/assets/img/apple-touch-icon.png?h=0f5e29c1169e75a7003e818478b67caa"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="96x96"
|
||||||
|
href="/assets/img/favicon-96x96.png?h=c8792ce927e01a494c4af48bed5dc5e3"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="192x192"
|
||||||
|
href="/assets/img/web-app-manifest-192x192.png?h=c8792ce927e01a494c4af48bed5dc5e3"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="512x512"
|
||||||
|
href="/assets/img/web-app-manifest-512x512.png?h=c8792ce927e01a494c4af48bed5dc5e3"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="/assets/bootstrap/css/bootstrap.min.css?h=608a9825a1f76f674715160908e57785"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="/assets/css/Lato.css?h=8253736d3a23b522f64b7e7d96d1d8ff"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="manifest"
|
||||||
|
href="/manifest.json?h=457941ffad3c027c946331c09a4d7d2f"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css"
|
||||||
|
/>
|
||||||
|
<link rel="manifest" href="/manifest.json" />
|
||||||
|
<meta name="theme-color" content="#ffffff" />
|
||||||
|
|
||||||
|
</head>
|
||||||
|
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
|
||||||
|
{/* Header (fixed to top) */}
|
||||||
|
<nav className="navbar navbar-expand bg-light fixed-top shadow-sm">
|
||||||
|
<div className="container">
|
||||||
|
<Link href="/" className="navbar-brand d-flex align-items-center">
|
||||||
|
<Image
|
||||||
|
src="/assets/img/CAT.png"
|
||||||
|
alt="CatLink"
|
||||||
|
width={89}
|
||||||
|
height={89}
|
||||||
|
/>
|
||||||
|
<span className="ms-2">CatLink</span>
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
className="navbar-toggler"
|
||||||
|
type="button"
|
||||||
|
data-bs-toggle="collapse"
|
||||||
|
data-bs-target="#navcol-1"
|
||||||
|
/>
|
||||||
|
<div className="collapse navbar-collapse" id="navcol-1">
|
||||||
|
<Link href="/auth/login" className="btn btn-primary ms-auto">
|
||||||
|
<i className="fa fa-user"></i>
|
||||||
|
<span className="d-none d-sm-inline"> Вход</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Main content: добавляем дополнительный отступ сверху */}
|
||||||
|
<main
|
||||||
|
style={{
|
||||||
|
paddingTop: "100px", // <-- тут увеличиваем интервал
|
||||||
|
paddingBottom: "200px"
|
||||||
|
}}
|
||||||
|
// или через класс bootstrap: className="pt-5 pb-5"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
|
||||||
|
{/* Scripts */}
|
||||||
|
<Script
|
||||||
|
src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.0/jquery.min.js"
|
||||||
|
strategy="beforeInteractive"
|
||||||
|
/>
|
||||||
|
<Script
|
||||||
|
src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
|
||||||
|
strategy="beforeInteractive"
|
||||||
|
/>
|
||||||
|
<Script src="/assets/js/bs-init.js" strategy="lazyOnload" />
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user