From 202ce22102d66b422a620ba5802c8c53fd211b63 Mon Sep 17 00:00:00 2001 From: Brunobrno Date: Mon, 18 May 2026 02:25:47 +0200 Subject: [PATCH] added frontend for social + feed partiali working --- .claude/settings.local.json | 12 + .../migrations/0002_customuser_avatar.py | 16 + backend/account/models.py | 4 +- backend/account/serializers.py | 34 +- backend/account/views.py | 23 +- backend/social/chat/consumers.py | 144 ------- backend/social/chat/views.py | 4 +- backend/social/posts/models.py | 13 +- backend/social/posts/serializers.py | 33 +- backend/social/posts/views.py | 96 ++++- backend/vontor_cz/pagination.py | 31 ++ frontend/.env.example | 10 + frontend/package-lock.json | 83 +++- frontend/package.json | 2 + frontend/src/App.tsx | 60 ++- .../models/apiSocialMessagesListParams.ts | 8 +- .../models/apiSocialPostsFeedListParams.ts | 31 ++ .../models/apiSocialPostsMediaCreateBody.ts | 9 + .../generated/private/models/authorMinimal.ts | 20 + .../generated/private/models/customUser.ts | 2 + .../src/api/generated/private/models/index.ts | 3 + .../private/models/paginatedMessageList.ts | 1 - .../private/models/patchedCustomUser.ts | 2 + .../generated/private/models/patchedPost.ts | 5 + .../src/api/generated/private/models/post.ts | 5 + .../private/models/userRegistration.ts | 18 +- .../src/api/generated/private/posts/posts.ts | 253 ++++++++++++ .../generated/public/models/authorMinimal.ts | 20 + .../api/generated/public/models/customUser.ts | 2 + .../src/api/generated/public/models/index.ts | 1 + .../public/models/paginatedMessageList.ts | 1 - .../public/models/patchedCustomUser.ts | 2 + .../generated/public/models/patchedPost.ts | 5 + .../src/api/generated/public/models/post.ts | 5 + .../public/models/userRegistration.ts | 18 +- frontend/src/api/social/feed.ts | 75 ++++ frontend/src/api/social/ws.ts | 23 ++ .../src/components/home/navbar/SiteNav.tsx | 120 +++--- .../components/home/navbar/navbar.module.css | 2 + .../components/social/chat/ChatSidebar.tsx | 86 ++++ .../src/components/social/chat/Message.tsx | 87 ++++ .../social/chat/MessageComposer.tsx | 112 +++++ .../components/social/posts/MediaGallery.tsx | 46 +++ frontend/src/components/social/posts/Post.tsx | 178 +++++++- .../components/social/posts/PostActions.tsx | 136 ++++++ .../components/social/posts/PostComposer.tsx | 190 +++++++++ .../components/social/posts/SharePopup.tsx | 101 +++++ frontend/src/components/ui/Avatar.tsx | 35 ++ frontend/src/components/ui/Button.tsx | 65 +++ frontend/src/components/ui/Card.tsx | 28 ++ frontend/src/components/ui/Checkbox.tsx | 40 ++ frontend/src/components/ui/EmptyState.tsx | 28 ++ .../src/components/ui/FormErrorBanner.tsx | 23 ++ frontend/src/components/ui/IconButton.tsx | 34 ++ frontend/src/components/ui/Input.tsx | 42 ++ frontend/src/components/ui/Spinner.tsx | 15 + frontend/src/components/ui/Textarea.tsx | 40 ++ frontend/src/context/AuthContext.tsx | 27 +- frontend/src/hooks/useChatSocket.ts | 122 ++++++ frontend/src/hooks/useInfiniteMessages.ts | 39 ++ frontend/src/hooks/useInfinitePosts.ts | 59 +++ frontend/src/hooks/useIntersectionLoader.ts | 39 ++ frontend/src/hooks/usePermissions.ts | 60 +++ frontend/src/i18n/index.ts | 24 ++ frontend/src/i18n/locales/cs/auth.json | 54 +++ frontend/src/i18n/locales/cs/common.json | 16 + frontend/src/i18n/locales/cs/social.json | 95 +++++ frontend/src/layouts/social/Chat.tsx | 16 +- frontend/src/layouts/social/SocialLayout.tsx | 97 +++++ frontend/src/main.tsx | 1 + frontend/src/pages/social/FeedPage.tsx | 84 ++++ frontend/src/pages/social/HubPage.tsx | 66 +++ frontend/src/pages/social/HubsPage.tsx | 54 +++ frontend/src/pages/social/PostPage.tsx | 126 ++++++ frontend/src/pages/social/ProfilePage.tsx | 50 +++ frontend/src/pages/social/UserProfilePage.tsx | 126 ++++++ .../pages/social/account/AccountSettings.tsx | 351 ++++++++-------- frontend/src/pages/social/account/Login.tsx | 178 ++++---- frontend/src/pages/social/account/Logout.tsx | 13 +- .../social/account/PasswordResetPage.tsx | 26 ++ .../src/pages/social/account/Register.tsx | 391 +++++++++++------- .../src/pages/social/chat/ChatRoomPage.tsx | 205 +++++++++ frontend/src/pages/social/chat/ChatsPage.tsx | 15 + frontend/src/routes/PrivateRoute.tsx | 14 +- frontend/src/routes/PublicOnlyRoute.tsx | 21 + frontend/src/utils/formErrors.ts | 105 +++++ frontend/src/utils/relativeTime.ts | 16 + frontend/tsconfig.app.json | 1 + 88 files changed, 4236 insertions(+), 737 deletions(-) create mode 100644 .claude/settings.local.json create mode 100644 backend/account/migrations/0002_customuser_avatar.py create mode 100644 backend/vontor_cz/pagination.py create mode 100644 frontend/.env.example create mode 100644 frontend/src/api/generated/private/models/apiSocialPostsFeedListParams.ts create mode 100644 frontend/src/api/generated/private/models/apiSocialPostsMediaCreateBody.ts create mode 100644 frontend/src/api/generated/private/models/authorMinimal.ts create mode 100644 frontend/src/api/generated/public/models/authorMinimal.ts create mode 100644 frontend/src/api/social/feed.ts create mode 100644 frontend/src/api/social/ws.ts create mode 100644 frontend/src/components/social/chat/ChatSidebar.tsx create mode 100644 frontend/src/components/social/chat/MessageComposer.tsx create mode 100644 frontend/src/components/social/posts/MediaGallery.tsx create mode 100644 frontend/src/components/social/posts/PostActions.tsx create mode 100644 frontend/src/components/social/posts/PostComposer.tsx create mode 100644 frontend/src/components/social/posts/SharePopup.tsx create mode 100644 frontend/src/components/ui/Avatar.tsx create mode 100644 frontend/src/components/ui/Button.tsx create mode 100644 frontend/src/components/ui/Card.tsx create mode 100644 frontend/src/components/ui/Checkbox.tsx create mode 100644 frontend/src/components/ui/EmptyState.tsx create mode 100644 frontend/src/components/ui/FormErrorBanner.tsx create mode 100644 frontend/src/components/ui/IconButton.tsx create mode 100644 frontend/src/components/ui/Input.tsx create mode 100644 frontend/src/components/ui/Spinner.tsx create mode 100644 frontend/src/components/ui/Textarea.tsx create mode 100644 frontend/src/hooks/useChatSocket.ts create mode 100644 frontend/src/hooks/useInfiniteMessages.ts create mode 100644 frontend/src/hooks/useInfinitePosts.ts create mode 100644 frontend/src/hooks/useIntersectionLoader.ts create mode 100644 frontend/src/hooks/usePermissions.ts create mode 100644 frontend/src/i18n/index.ts create mode 100644 frontend/src/i18n/locales/cs/auth.json create mode 100644 frontend/src/i18n/locales/cs/common.json create mode 100644 frontend/src/i18n/locales/cs/social.json create mode 100644 frontend/src/layouts/social/SocialLayout.tsx create mode 100644 frontend/src/pages/social/FeedPage.tsx create mode 100644 frontend/src/pages/social/HubPage.tsx create mode 100644 frontend/src/pages/social/HubsPage.tsx create mode 100644 frontend/src/pages/social/PostPage.tsx create mode 100644 frontend/src/pages/social/ProfilePage.tsx create mode 100644 frontend/src/pages/social/UserProfilePage.tsx create mode 100644 frontend/src/pages/social/account/PasswordResetPage.tsx create mode 100644 frontend/src/pages/social/chat/ChatRoomPage.tsx create mode 100644 frontend/src/pages/social/chat/ChatsPage.tsx create mode 100644 frontend/src/routes/PublicOnlyRoute.tsx create mode 100644 frontend/src/utils/formErrors.ts create mode 100644 frontend/src/utils/relativeTime.ts diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..55e3565 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,12 @@ +{ + "permissions": { + "allow": [ + "Bash(Get-ChildItem -Path \"c:\\\\Users\\\\bruno\\\\Documents\\\\GitHub\\\\vontor-cz\\\\backend\\\\social\\\\\" -Recurse -Filter \"*.py\" | Select-Object -ExpandProperty FullName | head -50)", + "Bash(dir /s /b)", + "Bash(xargs head -5)", + "Bash(npm install *)", + "Bash(npx tsc *)", + "Bash(npx eslint *)" + ] + } +} diff --git a/backend/account/migrations/0002_customuser_avatar.py b/backend/account/migrations/0002_customuser_avatar.py new file mode 100644 index 0000000..0cfa061 --- /dev/null +++ b/backend/account/migrations/0002_customuser_avatar.py @@ -0,0 +1,16 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('account', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='customuser', + name='avatar', + field=models.ImageField(blank=True, null=True, upload_to='avatars/'), + ), + ] diff --git a/backend/account/models.py b/backend/account/models.py index 9fcbc11..d4ddf2a 100644 --- a/backend/account/models.py +++ b/backend/account/models.py @@ -78,6 +78,8 @@ class CustomUser(SoftDeleteModel, AbstractUser): street_number = models.PositiveIntegerField(null=True, blank=True) country = models.CharField(null=True, blank=True, max_length=100) + avatar = models.ImageField(upload_to='avatars/', null=True, blank=True) + # firemní fakturační údaje company_name = models.CharField(max_length=255, blank=True) ico = models.CharField(max_length=20, blank=True) @@ -136,8 +138,6 @@ class CustomUser(SoftDeleteModel, AbstractUser): group, _ = Group.objects.get_or_create(name=self.role) # Use add/set now that PK exists self.groups.set([group]) - - return super().save(*args, **kwargs) def generate_email_verification_token(self, length: int = 48, save: bool = True) -> str: token = get_random_string(length=length) diff --git a/backend/account/serializers.py b/backend/account/serializers.py index 7a1ceb5..d714216 100644 --- a/backend/account/serializers.py +++ b/backend/account/serializers.py @@ -17,6 +17,14 @@ from rest_framework.exceptions import PermissionDenied User = get_user_model() +class PublicUserSerializer(serializers.ModelSerializer): + """Minimal read-only profile returned to non-owner authenticated users.""" + class Meta: + model = User + fields = ['id', 'username', 'first_name', 'last_name', 'avatar', 'city', 'role', 'create_time'] + read_only_fields = ['id', 'username', 'first_name', 'last_name', 'avatar', 'city', 'role', 'create_time'] + + class CustomUserSerializer(serializers.ModelSerializer): class Meta: model = User @@ -35,6 +43,7 @@ class CustomUserSerializer(serializers.ModelSerializer): "postal_code", "gdpr", "is_active", + "avatar", ] read_only_fields = ["id", "create_time", "gdpr", "username"] # <-- removed "account_type" @@ -89,17 +98,18 @@ class UserRegistrationSerializer(serializers.ModelSerializer): class Meta: model = User fields = [ - 'first_name', 'last_name', 'email', 'phone_number', 'password', + 'username', 'first_name', 'last_name', 'email', 'phone_number', 'password', 'city', 'street', 'postal_code', 'gdpr' ] extra_kwargs = { - 'first_name': {'required': True, 'help_text': 'Křestní jméno uživatele'}, - 'last_name': {'required': True, 'help_text': 'Příjmení uživatele'}, + 'username': {'required': False, 'allow_blank': True, 'help_text': 'Užívatelské jméno'}, + 'first_name': {'required': False, 'allow_blank': True, 'help_text': 'Křestní jméno uživatele'}, + 'last_name': {'required': False, 'allow_blank': True, 'help_text': 'Příjmení uživatele'}, 'email': {'required': True, 'help_text': 'Emailová adresa uživatele'}, - 'phone_number': {'required': True, 'help_text': 'Telefonní číslo uživatele'}, - 'city': {'required': True, 'help_text': 'Město uživatele'}, - 'street': {'required': True, 'help_text': 'Ulice uživatele'}, - 'postal_code': {'required': True, 'help_text': 'PSČ uživatele'}, + 'phone_number': {'required': False, 'allow_null': True, 'allow_blank': True, 'help_text': 'Telefonní číslo uživatele'}, + 'city': {'required': False, 'allow_blank': True, 'allow_null': True, 'help_text': 'Město uživatele'}, + 'street': {'required': False, 'allow_blank': True, 'allow_null': True, 'help_text': 'Ulice uživatele'}, + 'postal_code': {'required': False, 'allow_blank': True, 'allow_null': True, 'help_text': 'PSČ uživatele'}, 'gdpr': {'required': True, 'help_text': 'Souhlas se zpracováním osobních údajů'}, } @@ -117,9 +127,9 @@ class UserRegistrationSerializer(serializers.ModelSerializer): def validate(self, data): email = data.get("email") phone = data.get("phone_number") - dgpr = data.get("GDPR") - if not dgpr: - raise serializers.ValidationError({"GDPR": "You must agree to the GDPR to register."}) + gdpr = data.get("gdpr") + if not gdpr: + raise serializers.ValidationError({"gdpr": "You must agree to the GDPR to register."}) if User.objects.filter(email=email).exists(): raise serializers.ValidationError({"email": "Account with this email already exists."}) @@ -131,10 +141,8 @@ class UserRegistrationSerializer(serializers.ModelSerializer): def create(self, validated_data): password = validated_data.pop("password") - username = validated_data.get("username", "") user = User.objects.create( - username=username, - is_active=False, #uživatel je defaultně deaktivovaný + is_active=True, #uživatel je defaultně aktivní **validated_data ) user.set_password(password) diff --git a/backend/account/views.py b/backend/account/views.py index b28cbfe..35f451d 100644 --- a/backend/account/views.py +++ b/backend/account/views.py @@ -250,21 +250,20 @@ class UserView(viewsets.ModelViewSet): # Fallback - deny access (prevents AttributeError for AnonymousUser) return [OnlyRolesAllowed("admin")()] - # Users can only view their own profile, admins can view any profile + # Any authenticated user can retrieve a profile (serializer limits fields for non-owner/non-admin) elif self.action == 'retrieve': - user = getattr(self, 'request', None) and getattr(self.request, 'user', None) - # Admins can view any user profile - if user and getattr(user, 'is_authenticated', False) and getattr(user, 'role', None) == 'admin': - return [IsAuthenticated()] - - # Users can view their own profile - if user and getattr(user, 'is_authenticated', False) and self.kwargs.get('pk') and str(getattr(user, 'id', '')) == self.kwargs['pk']: - return [IsAuthenticated()] - - # Deny access to other users' profiles - return [OnlyRolesAllowed("admin")()] + return [IsAuthenticated()] return super().get_permissions() + + def get_serializer_class(self): + user = getattr(self.request, 'user', None) + pk = self.kwargs.get('pk') + is_self = pk and user and str(getattr(user, 'id', '')) == str(pk) + is_admin = user and (getattr(user, 'role', None) == 'admin' or getattr(user, 'is_superuser', False)) + if self.action == 'retrieve' and not is_self and not is_admin: + return PublicUserSerializer + return CustomUserSerializer diff --git a/backend/social/chat/consumers.py b/backend/social/chat/consumers.py index 62a7ee1..4358d84 100644 --- a/backend/social/chat/consumers.py +++ b/backend/social/chat/consumers.py @@ -179,147 +179,3 @@ def _create_message(chat_id, sender, content, reply_to_id=None): def _toggle_reaction(message_id, user, emoji): message = Message.objects.get(pk=message_id) return message.toggle_reaction(user, emoji) - - - -class ChatConsumer(AsyncWebsocketConsumer): - # -- CONNECT -- - async def connect(self): - self.chat_id = self.scope["url_route"]["kwargs"]["chat_id"] - self.chat_name = f"chat_{self.chat_id}" - - user = self.scope["user"] - - if not user.is_authenticated: - await self.close(code=4401) # unauthorized - return - - #join chat group - async_to_sync(self.channel_layer.group_add)( - self.chat_name, - ) - - - - await self.accept() - - # -- DISCONNECT -- - async def disconnect(self, close_code): - async_to_sync(self.channel_layer.group_discard)( - self.chat_name - ) - - self.disconnect() - pass - - # -- RECIVE -- - async def receive(self, data): - if data["type"] == "new_chat_message": - - message = data["message"] - - # Send message to room group - async_to_sync(self.channel_layer.group_send)( - self.chat_name, {"type": "chat.message", "message": message} - ) - - elif data["type"] == "new_reply_chat_message": - message = data["message"] - reply_to_id = data["reply_to_id"] - - # Send message to room group - async_to_sync(self.channel_layer.group_send)( - self.chat_name, {"type": "reply.chat.message", "message": message, "reply_to_id": reply_to_id} - ) - - elif data["type"] == "edit_chat_message": - message = data["message"] - - # Send message to room group - async_to_sync(self.channel_layer.group_send)( - self.chat_name, {"type": "edit.message", "message": message} - ) - - elif data["type"] == "delete_chat_message": - message_id = data["message_id"] - - # Send message to room group - async_to_sync(self.channel_layer.group_send)( - self.chat_name, {"type": "delete.message", "message_id": message_id} - ) - - elif data["type"] == "typing": - is_typing = data["is_typing"] - - # Send typing status to room group - async_to_sync(self.channel_layer.group_send)( - self.chat_name, {"type": "typing.status", "user": self.scope["user"].username, "is_typing": is_typing} - ) - - elif data["type"] == "stop_typing": - # Send stop typing status to room group - async_to_sync(self.channel_layer.group_send)( - self.chat_name, {"type": "stop.typing", "user": self.scope["user"].username} - ) - - elif data["type"] == "reaction": - message_id = data["message_id"] - emoji = data["emoji"] - - # Send reaction to room group - async_to_sync(self.channel_layer.group_send)( - self.chat_name, {"type": "message.reaction", "message_id": message_id, "emoji": emoji, "user": self.scope["user"].username} - ) - - elif data["type"] == "unreaction": - message_id = data["message_id"] - emoji = data["emoji"] - - # Send unreaction to room group - async_to_sync(self.channel_layer.group_send)( - self.chat_name, {"type": "message.unreaction", "message_id": message_id, "emoji": emoji, "user": self.scope["user"].username} - ) - - else: - self.close(reason="Unsupported message type") - - - # -- CUSTOM METHODS -- - - def send_message_to_chat_group(self, event): - message = event["message"] - create_new_message() - self.send(text_data=json.dumps({"message": message})) - - def edit_message_in_chat_group(self, event): - message = event["message"] - self.send(text_data=json.dumps({"message": message})) - - - -# -- MESSAGES -- -@database_sync_to_async -def create_new_message(): - return None - -@database_sync_to_async -def create_new_reply_message(): - return None - -@database_sync_to_async -def edit_message(): - return None - -@database_sync_to_async -def delete_message(): - return None - - -# -- REACTIONS -- -@database_sync_to_async -def react_to_message(): - return None - -@database_sync_to_async -def unreact_to_message(): - return None \ No newline at end of file diff --git a/backend/social/chat/views.py b/backend/social/chat/views.py index 69dfcd4..cbadbe3 100644 --- a/backend/social/chat/views.py +++ b/backend/social/chat/views.py @@ -8,6 +8,7 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from drf_spectacular.utils import extend_schema, extend_schema_view +from vontor_cz.pagination import CreatedCursorPagination from .models import Chat, Message, MessageFile from .permissions import CanDeleteMessage, CanManageChat, IsChatMember, IsMessageSenderOnly from .serializers import ChatMemberSerializer, ChatSerializer, MessageSendSerializer, MessageSerializer @@ -155,10 +156,11 @@ class ChatViewSet(viewsets.ModelViewSet): ) class MessageViewSet(viewsets.ModelViewSet): serializer_class = MessageSerializer + pagination_class = CreatedCursorPagination filterset_fields = ['chat', 'sender', 'reply_to'] search_fields = ['content'] ordering_fields = ['created_at'] - ordering = ['created_at'] + ordering = ['-created_at'] # Standard create is disabled — use POST /messages/send which handles files + WS broadcast http_method_names = ['get', 'patch', 'put', 'delete', 'post', 'head', 'options'] diff --git a/backend/social/posts/models.py b/backend/social/posts/models.py index a340b5e..8e572fb 100644 --- a/backend/social/posts/models.py +++ b/backend/social/posts/models.py @@ -67,7 +67,18 @@ class PostContent(SoftDeleteModel): def save(self, *args, **kwargs): if self.file and not self.file._committed: - self.mime_type = mime.from_buffer(self.file.read(1024)) + if mime is not None: + data = self.file.read(1024) + self.file.seek(0) + self.mime_type = mime.from_buffer(data) + else: + import mimetypes + content_type = getattr(self.file, 'content_type', None) + self.mime_type = ( + content_type + or mimetypes.guess_type(self.file.name)[0] + or 'application/octet-stream' + ) return super().save(*args, **kwargs) diff --git a/backend/social/posts/serializers.py b/backend/social/posts/serializers.py index ba1dc36..6e0207b 100644 --- a/backend/social/posts/serializers.py +++ b/backend/social/posts/serializers.py @@ -1,7 +1,18 @@ +from django.contrib.auth import get_user_model from rest_framework import serializers from .models import Post, PostContent, PostVote from social.hubs.serializers import TagsSerializer +User = get_user_model() + + +class AuthorMinimalSerializer(serializers.ModelSerializer): + avatar = serializers.ImageField(read_only=True) + + class Meta: + model = User + fields = ['id', 'username', 'first_name', 'last_name', 'avatar'] + class PostContentSerializer(serializers.ModelSerializer): class Meta: @@ -13,12 +24,32 @@ class PostContentSerializer(serializers.ModelSerializer): class PostSerializer(serializers.ModelSerializer): contents = PostContentSerializer(many=True, read_only=True) tags = TagsSerializer(many=True, read_only=True) + author_detail = AuthorMinimalSerializer(source='author', read_only=True) + vote_score = serializers.SerializerMethodField() + user_vote = serializers.SerializerMethodField() + reply_count = serializers.IntegerField(read_only=True, default=0) class Meta: model = Post - fields = ['id', 'content', 'created_at', 'updated_at', 'author', 'hub', 'reply_to', 'tags', 'contents'] + fields = [ + 'id', 'content', 'created_at', 'updated_at', + 'author', 'author_detail', + 'hub', 'reply_to', + 'tags', 'contents', + 'vote_score', 'user_vote', 'reply_count', + ] read_only_fields = ['author', 'created_at', 'updated_at'] + def get_vote_score(self, obj): + return sum(v.vote for v in obj.votes.all()) + + def get_user_vote(self, obj): + request = self.context.get('request') + if not request or not request.user.is_authenticated: + return 0 + vote = obj.votes.filter(user=request.user).first() + return vote.vote if vote else 0 + class PostVoteSerializer(serializers.ModelSerializer): class Meta: diff --git a/backend/social/posts/views.py b/backend/social/posts/views.py index 5167f5b..83f3e49 100644 --- a/backend/social/posts/views.py +++ b/backend/social/posts/views.py @@ -1,14 +1,17 @@ +from django.db.models import Count, Q from rest_framework import status, viewsets from rest_framework.decorators import action from rest_framework.exceptions import PermissionDenied, ValidationError +from rest_framework.parsers import MultiPartParser from rest_framework.response import Response -from drf_spectacular.utils import extend_schema, extend_schema_view +from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiParameter from rest_framework.permissions import IsAuthenticated from social.hubs.models import Tags -from .models import Post, PostVote +from vontor_cz.pagination import CreatedCursorPagination +from .models import Post, PostContent, PostVote from .permissions import CanDeletePost, IsPostAuthorOnly -from .serializers import PostSerializer, PostVoteSerializer, TagAttachSerializer +from .serializers import PostSerializer, PostContentSerializer, PostVoteSerializer, TagAttachSerializer # --------------------------------------------------------------------------- @@ -61,7 +64,12 @@ class PostViewSet(viewsets.ModelViewSet): return [IsAuthenticated()] def get_queryset(self): - qs = Post.objects.select_related('author', 'hub').prefetch_related('tags', 'contents') + qs = ( + Post.objects + .select_related('author', 'hub') + .prefetch_related('tags', 'contents', 'votes') + .annotate(reply_count=Count('replies', distinct=True)) + ) hub_id = self.request.query_params.get('hub') if hub_id: qs = qs.filter(hub_id=hub_id) @@ -70,6 +78,33 @@ class PostViewSet(viewsets.ModelViewSet): def perform_create(self, serializer): serializer.save(author=self.request.user) + # ------------------------------------------------------------------ + # Media upload action + # ------------------------------------------------------------------ + + @extend_schema( + tags=["posts"], + summary="Upload media to a post", + description="Attach an image or video file to a post. Only the post author can upload.", + request={'multipart/form-data': { + 'type': 'object', + 'properties': {'file': {'type': 'string', 'format': 'binary'}}, + 'required': ['file'], + }}, + responses={201: PostContentSerializer}, + ) + @action(detail=True, methods=['post'], url_path='media', parser_classes=[MultiPartParser]) + def upload_media(self, request, pk=None): + post = self.get_object() + if post.author != request.user: + raise PermissionDenied('Only the post author can upload media.') + file = request.FILES.get('file') + if not file: + raise ValidationError({'file': 'No file provided.'}) + content = PostContent(post=post, file=file) + content.save() + return Response(PostContentSerializer(content).data, status=status.HTTP_201_CREATED) + # ------------------------------------------------------------------ # Tag attachment actions # ------------------------------------------------------------------ @@ -132,6 +167,58 @@ class PostViewSet(viewsets.ModelViewSet): post.tags.remove(tag) return Response(PostSerializer(post, context={'request': request}).data) + # ------------------------------------------------------------------ + # Feed action (cursor-paginated aggregated feed for the user) + # ------------------------------------------------------------------ + + @extend_schema( + tags=["posts"], + summary="Get the user's post feed", + description=( + "Returns a cursor-paginated stream of top-level posts (excluding replies) " + "aggregated from the user's joined hubs, public hubs, and hub-less posts. " + "Pass `feed_strategy` to switch between ranking algorithms (currently only " + "`recent` is implemented; reserved for future custom algorithms)." + ), + parameters=[ + OpenApiParameter(name='feed_strategy', required=False, type=str, + description="Algorithm key, default `recent`."), + OpenApiParameter(name='cursor', required=False, type=str, + description="Opaque pagination cursor."), + ], + responses={200: PostSerializer(many=True)}, + ) + @action(detail=False, methods=['get'], url_path='feed') + def feed(self, request): + user = request.user + strategy = request.query_params.get('feed_strategy', 'recent') + + base_qs = ( + Post.objects + .select_related('author', 'hub') + .prefetch_related('tags', 'contents', 'votes') + .annotate(reply_count=Count('replies', distinct=True)) + .filter(reply_to__isnull=True) + ) + + joined_hub_ids = list(user.hubs.values_list('id', flat=True)) if user.is_authenticated else [] + visibility_filter = ( + Q(hub__isnull=True) + | Q(hub__is_public=True) + | Q(hub_id__in=joined_hub_ids) + ) + qs = base_qs.filter(visibility_filter).distinct() + + if strategy == 'recent': + qs = qs.order_by('-created_at') + else: + qs = qs.order_by('-created_at') + + paginator = CreatedCursorPagination() + page = paginator.paginate_queryset(qs, request, view=self) + ser = PostSerializer(page, many=True, context={'request': request}) + return paginator.get_paginated_response(ser.data) + # ------------------------------------------------------------------ # Vote action # ------------------------------------------------------------------ @@ -154,4 +241,3 @@ class PostViewSet(viewsets.ModelViewSet): defaults={'vote': ser.validated_data['vote']}, ) return Response(PostVoteSerializer(vote_obj).data) - diff --git a/backend/vontor_cz/pagination.py b/backend/vontor_cz/pagination.py new file mode 100644 index 0000000..790370d --- /dev/null +++ b/backend/vontor_cz/pagination.py @@ -0,0 +1,31 @@ +"""Shared pagination classes. + +`CreatedCursorPagination` is the canonical choice for infinite-scroll feeds +(posts feed, chat message history). Cursor pagination keeps a stable view of +results even when new items are created at the head, which page/offset +pagination does not. +""" + +from rest_framework.pagination import CursorPagination + + +class CreatedCursorPagination(CursorPagination): + """Cursor pagination ordered by `-created_at` (newest first).""" + page_size = 20 + ordering = '-created_at' + cursor_query_param = 'cursor' + page_size_query_param = 'page_size' + max_page_size = 100 + + +class CreatedAscCursorPagination(CursorPagination): + """Cursor pagination ordered by `created_at` (oldest first). + + Used for chat history scroll-back where messages are displayed oldest -> newest + and pagination walks backwards in time from the most recent. + """ + page_size = 30 + ordering = '-created_at' # backend orders newest-first; client reverses for display + cursor_query_param = 'cursor' + page_size_query_param = 'page_size' + max_page_size = 100 diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..1a6940c --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,10 @@ +# Base URL of the Django backend (must include /api/ if your axios baseURL expects it). +VITE_BACKEND_URL="http://localhost:8000/api/" + +# Optional override for the WebSocket base. If unset, derived from VITE_BACKEND_URL +# (the `/api` suffix is stripped automatically; only the host is used). +# VITE_WS_URL="ws://localhost:8000" + +# Auth endpoints (defaults match Django routes; only override if you changed them). +# VITE_API_REFRESH_URL=/api/token/refresh/ +# VITE_LOGIN_PATH=/social/login diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5aebee3..2f08f24 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -24,9 +24,11 @@ "axios": "^1.13.0", "dayjs": "^1.11.19", "framer-motion": "^12.25.0", + "i18next": "^26.2.0", "react": "^19.1.1", "react-dom": "^19.1.1", "react-hook-form": "^7.70.0", + "react-i18next": "^17.0.8", "react-icons": "^5.5.0", "react-router-dom": "^7.8.1", "react-toastify": "^11.0.5", @@ -3589,9 +3591,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001734", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001734.tgz", - "integrity": "sha512-uhE1Ye5vgqju6OI71HTQqcBCZrvHugk0MjLak7Q+HfoBgoq5Bi+5YnwjP4fjDgrtYr/l8MVRBvzz9dPD4KyK0A==", + "version": "1.0.30001793", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", + "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", "dev": true, "funding": [ { @@ -4668,6 +4670,15 @@ "node": ">= 0.4" } }, + "node_modules/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "license": "MIT", + "dependencies": { + "void-elements": "3.1.0" + } + }, "node_modules/human-signals": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz", @@ -4678,6 +4689,34 @@ "node": ">=18.18.0" } }, + "node_modules/i18next": { + "version": "26.2.0", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.2.0.tgz", + "integrity": "sha512-zwBHldHdTmwN7r6UNc7lC6GWNN+YYg3DrRSeHR5PRRBf5QnJZcYHrQc0uaU26qZeYxR7iFZD+Y315dPnKP47wA==", + "funding": [ + { + "type": "individual", + "url": "https://www.locize.com/i18next" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + }, + { + "type": "individual", + "url": "https://www.locize.com" + } + ], + "license": "MIT", + "peerDependencies": { + "typescript": "^5 || ^6" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -5900,6 +5939,33 @@ "react": "^16.8.0 || ^17 || ^18 || ^19" } }, + "node_modules/react-i18next": { + "version": "17.0.8", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.8.tgz", + "integrity": "sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "html-parse-stringify": "^3.0.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "i18next": ">= 26.2.0", + "react": ">= 16.8.0", + "typescript": "^5 || ^6" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/react-icons": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz", @@ -6589,7 +6655,7 @@ "version": "5.8.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -6901,6 +6967,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 1b64e53..b2b404a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -27,9 +27,11 @@ "axios": "^1.13.0", "dayjs": "^1.11.19", "framer-motion": "^12.25.0", + "i18next": "^26.2.0", "react": "^19.1.1", "react-dom": "^19.1.1", "react-hook-form": "^7.70.0", + "react-i18next": "^17.0.8", "react-icons": "^5.5.0", "react-router-dom": "^7.8.1", "react-toastify": "^11.0.5", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 56d234f..df53d5b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,13 +1,14 @@ -import { BrowserRouter as Router, Routes, Route } from "react-router-dom"; +import { BrowserRouter as Router, Routes, Route, Navigate } from "react-router-dom"; import HomeLayout from "./layouts/HomeLayout"; +import SocialLayout from "./layouts/social/SocialLayout"; import ChatLayout from "./layouts/social/Chat"; import Downloader from "./pages/downloader/Downloader"; import Home from "./pages/home/home"; import DroneServisSection from "./pages/home/components/Services/droneServis"; - import PrivateRoute from "./routes/PrivateRoute"; +import PublicOnlyRoute from "./routes/PublicOnlyRoute"; // Pages import PortfolioPage from "./pages/portfolio/PortfolioPage"; @@ -17,44 +18,65 @@ import ScrollToTop from "./components/common/ScrollToTop"; import LogoutPage from "./pages/social/account/Logout"; import LoginPage from "./pages/social/account/Login"; import RegisterPage from "./pages/social/account/Register"; +import PasswordResetPage from "./pages/social/account/PasswordResetPage"; import { RetroSoundTest } from "./pages/test/sounds"; +// Social pages +import FeedPage from "./pages/social/FeedPage"; +import PostPage from "./pages/social/PostPage"; +import HubsPage from "./pages/social/HubsPage"; +import HubPage from "./pages/social/HubPage"; +import ProfilePage from "./pages/social/ProfilePage"; +import UserProfilePage from "./pages/social/UserProfilePage"; +import ChatsIndexPage from "./pages/social/chat/ChatsPage"; +import ChatRoomPage from "./pages/social/chat/ChatRoomPage"; export default function App() { return ( - {/* Public routes */} + {/* Public marketing routes */} }> } /> } /> } /> - - {/* APPS */} } /> - - {/* SERVICES */} - } /> + } /> } /> - } /> - - }> + {/* Public-only social auth */} + }> } /> } /> - } /> - + } /> + - {/* Example protected route group (kept for future use) */} - }> - }> - + {/* Authenticated social area */} + }> + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + }> + } /> + } /> - + } /> + + + {/* Legacy /auth redirects */} + } /> + } /> + } /> + ); -} \ No newline at end of file +} diff --git a/frontend/src/api/generated/private/models/apiSocialMessagesListParams.ts b/frontend/src/api/generated/private/models/apiSocialMessagesListParams.ts index 148fc21..4f2c1d7 100644 --- a/frontend/src/api/generated/private/models/apiSocialMessagesListParams.ts +++ b/frontend/src/api/generated/private/models/apiSocialMessagesListParams.ts @@ -5,14 +5,18 @@ */ export type ApiSocialMessagesListParams = { + /** + * The pagination cursor value. + */ + cursor?: string; /** * Which field to use when ordering the results. */ ordering?: string; /** - * A page number within the paginated result set. + * Number of results to return per page. */ - page?: number; + page_size?: number; /** * A search term. */ diff --git a/frontend/src/api/generated/private/models/apiSocialPostsFeedListParams.ts b/frontend/src/api/generated/private/models/apiSocialPostsFeedListParams.ts new file mode 100644 index 0000000..5b07d43 --- /dev/null +++ b/frontend/src/api/generated/private/models/apiSocialPostsFeedListParams.ts @@ -0,0 +1,31 @@ +/** + * Generated by orval v8.8.0 🍺 + * Do not edit manually. + * OpenAPI spec version: 0.0.0 + */ + +export type ApiSocialPostsFeedListParams = { + author?: number; + /** + * Opaque pagination cursor. + */ + cursor?: string; + /** + * Algorithm key, default `recent`. + */ + feed_strategy?: string; + hub?: number; + /** + * Which field to use when ordering the results. + */ + ordering?: string; + /** + * A page number within the paginated result set. + */ + page?: number; + reply_to?: number; + /** + * A search term. + */ + search?: string; +}; diff --git a/frontend/src/api/generated/private/models/apiSocialPostsMediaCreateBody.ts b/frontend/src/api/generated/private/models/apiSocialPostsMediaCreateBody.ts new file mode 100644 index 0000000..3e5258d --- /dev/null +++ b/frontend/src/api/generated/private/models/apiSocialPostsMediaCreateBody.ts @@ -0,0 +1,9 @@ +/** + * Generated by orval v8.8.0 🍺 + * Do not edit manually. + * OpenAPI spec version: 0.0.0 + */ + +export type ApiSocialPostsMediaCreateBody = { + file: Blob; +}; diff --git a/frontend/src/api/generated/private/models/authorMinimal.ts b/frontend/src/api/generated/private/models/authorMinimal.ts new file mode 100644 index 0000000..d9dd031 --- /dev/null +++ b/frontend/src/api/generated/private/models/authorMinimal.ts @@ -0,0 +1,20 @@ +/** + * Generated by orval v8.8.0 🍺 + * Do not edit manually. + * OpenAPI spec version: 0.0.0 + */ + +export interface AuthorMinimal { + readonly id: number; + /** + * Požadováno. 150 znaků nebo méně. Pouze písmena, číslice a znaky @/./+/-/_. + * @maxLength 150 + * @pattern ^[\w.@+-]+$ + */ + username: string; + /** @maxLength 150 */ + first_name?: string; + /** @maxLength 150 */ + last_name?: string; + readonly avatar: string; +} diff --git a/frontend/src/api/generated/private/models/customUser.ts b/frontend/src/api/generated/private/models/customUser.ts index 33d34d2..95aa012 100644 --- a/frontend/src/api/generated/private/models/customUser.ts +++ b/frontend/src/api/generated/private/models/customUser.ts @@ -42,4 +42,6 @@ export interface CustomUser { postal_code?: string | null; readonly gdpr: boolean; is_active?: boolean; + /** @nullable */ + avatar?: string | null; } diff --git a/frontend/src/api/generated/private/models/index.ts b/frontend/src/api/generated/private/models/index.ts index faa1d30..4f4663c 100644 --- a/frontend/src/api/generated/private/models/index.ts +++ b/frontend/src/api/generated/private/models/index.ts @@ -26,8 +26,11 @@ export * from "./apiSocialHubsModeratorsListParams"; export * from "./apiSocialHubsTagsListParams"; export * from "./apiSocialChatsListParams"; export * from "./apiSocialMessagesListParams"; +export * from "./apiSocialPostsFeedListParams"; export * from "./apiSocialPostsListParams"; +export * from "./apiSocialPostsMediaCreateBody"; export * from "./apiZasilkovnaShipmentsListParams"; +export * from "./authorMinimal"; export * from "./callback"; export * from "./carrierRead"; export * from "./cart"; diff --git a/frontend/src/api/generated/private/models/paginatedMessageList.ts b/frontend/src/api/generated/private/models/paginatedMessageList.ts index 0aa2cb2..aa0ceaa 100644 --- a/frontend/src/api/generated/private/models/paginatedMessageList.ts +++ b/frontend/src/api/generated/private/models/paginatedMessageList.ts @@ -6,7 +6,6 @@ import type { Message } from "./message"; export interface PaginatedMessageList { - count: number; /** @nullable */ next?: string | null; /** @nullable */ diff --git a/frontend/src/api/generated/private/models/patchedCustomUser.ts b/frontend/src/api/generated/private/models/patchedCustomUser.ts index 6e86887..becc8f4 100644 --- a/frontend/src/api/generated/private/models/patchedCustomUser.ts +++ b/frontend/src/api/generated/private/models/patchedCustomUser.ts @@ -42,4 +42,6 @@ export interface PatchedCustomUser { postal_code?: string | null; readonly gdpr?: boolean; is_active?: boolean; + /** @nullable */ + avatar?: string | null; } diff --git a/frontend/src/api/generated/private/models/patchedPost.ts b/frontend/src/api/generated/private/models/patchedPost.ts index e61af6e..062ea7e 100644 --- a/frontend/src/api/generated/private/models/patchedPost.ts +++ b/frontend/src/api/generated/private/models/patchedPost.ts @@ -3,6 +3,7 @@ * Do not edit manually. * OpenAPI spec version: 0.0.0 */ +import type { AuthorMinimal } from "./authorMinimal"; import type { PostContent } from "./postContent"; import type { Tags } from "./tags"; @@ -12,10 +13,14 @@ export interface PatchedPost { readonly created_at?: Date; readonly updated_at?: Date; readonly author?: number; + readonly author_detail?: AuthorMinimal; /** @nullable */ hub?: number | null; /** @nullable */ reply_to?: number | null; readonly tags?: readonly Tags[]; readonly contents?: readonly PostContent[]; + readonly vote_score?: string; + readonly user_vote?: string; + readonly reply_count?: number; } diff --git a/frontend/src/api/generated/private/models/post.ts b/frontend/src/api/generated/private/models/post.ts index f386f28..9b5611d 100644 --- a/frontend/src/api/generated/private/models/post.ts +++ b/frontend/src/api/generated/private/models/post.ts @@ -3,6 +3,7 @@ * Do not edit manually. * OpenAPI spec version: 0.0.0 */ +import type { AuthorMinimal } from "./authorMinimal"; import type { PostContent } from "./postContent"; import type { Tags } from "./tags"; @@ -12,10 +13,14 @@ export interface Post { readonly created_at: Date; readonly updated_at: Date; readonly author: number; + readonly author_detail: AuthorMinimal; /** @nullable */ hub?: number | null; /** @nullable */ reply_to?: number | null; readonly tags: readonly Tags[]; readonly contents: readonly PostContent[]; + readonly vote_score: string; + readonly user_vote: string; + readonly reply_count: number; } diff --git a/frontend/src/api/generated/private/models/userRegistration.ts b/frontend/src/api/generated/private/models/userRegistration.ts index 739416b..e2bdc94 100644 --- a/frontend/src/api/generated/private/models/userRegistration.ts +++ b/frontend/src/api/generated/private/models/userRegistration.ts @@ -5,16 +5,22 @@ */ export interface UserRegistration { + /** + * Užívatelské jméno + * @maxLength 150 + * @pattern ^[\w.@+-]+$ + */ + username?: string; /** * Křestní jméno uživatele * @maxLength 150 */ - first_name: string; + first_name?: string; /** * Příjmení uživatele * @maxLength 150 */ - last_name: string; + last_name?: string; /** * Emailová adresa uživatele * @maxLength 254 @@ -26,7 +32,7 @@ export interface UserRegistration { * @nullable * @pattern ^\+?\d{9,15}$ */ - phone_number: string | null; + phone_number?: string | null; /** Heslo musí mít alespoň 8 znaků, obsahovat velká a malá písmena a číslici. */ password: string; /** @@ -34,20 +40,20 @@ export interface UserRegistration { * @maxLength 100 * @nullable */ - city: string | null; + city?: string | null; /** * Ulice uživatele * @maxLength 200 * @nullable */ - street: string | null; + street?: string | null; /** * PSČ uživatele * @maxLength 5 * @nullable * @pattern ^\d{5}$ */ - postal_code: string | null; + postal_code?: string | null; /** Souhlas se zpracováním osobních údajů */ gdpr: boolean; } diff --git a/frontend/src/api/generated/private/posts/posts.ts b/frontend/src/api/generated/private/posts/posts.ts index e6ee632..e56d649 100644 --- a/frontend/src/api/generated/private/posts/posts.ts +++ b/frontend/src/api/generated/private/posts/posts.ts @@ -20,10 +20,13 @@ import type { } from "@tanstack/react-query"; import type { + ApiSocialPostsFeedListParams, ApiSocialPostsListParams, + ApiSocialPostsMediaCreateBody, PaginatedPostList, PatchedPost, Post, + PostContent, PostVote, TagAttach, } from "../models"; @@ -712,6 +715,97 @@ export const useApiSocialPostsDestroy = ( queryClient, ); }; +/** + * Attach an image or video file to a post. Only the post author can upload. + * @summary Upload media to a post + */ +export const apiSocialPostsMediaCreate = ( + id: number, + apiSocialPostsMediaCreateBody: ApiSocialPostsMediaCreateBody, + signal?: AbortSignal, +) => { + const formData = new FormData(); + formData.append(`file`, apiSocialPostsMediaCreateBody.file); + + return privateMutator({ + url: `/api/social/posts/${id}/media/`, + method: "POST", + data: formData, + signal, + }); +}; + +export const getApiSocialPostsMediaCreateMutationOptions = < + TError = unknown, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { id: number; data: ApiSocialPostsMediaCreateBody }, + TContext + >; +}): UseMutationOptions< + Awaited>, + TError, + { id: number; data: ApiSocialPostsMediaCreateBody }, + TContext +> => { + const mutationKey = ["apiSocialPostsMediaCreate"]; + const { mutation: mutationOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey } }; + + const mutationFn: MutationFunction< + Awaited>, + { id: number; data: ApiSocialPostsMediaCreateBody } + > = (props) => { + const { id, data } = props ?? {}; + + return apiSocialPostsMediaCreate(id, data); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type ApiSocialPostsMediaCreateMutationResult = NonNullable< + Awaited> +>; +export type ApiSocialPostsMediaCreateMutationBody = + ApiSocialPostsMediaCreateBody; +export type ApiSocialPostsMediaCreateMutationError = unknown; + +/** + * @summary Upload media to a post + */ +export const useApiSocialPostsMediaCreate = < + TError = unknown, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { id: number; data: ApiSocialPostsMediaCreateBody }, + TContext + >; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + TError, + { id: number; data: ApiSocialPostsMediaCreateBody }, + TContext +> => { + return useMutation( + getApiSocialPostsMediaCreateMutationOptions(options), + queryClient, + ); +}; /** * Attaches an existing hub tag to the post. The tag must belong to the same hub as the post. Any authenticated hub member can attach tags. * @summary Attach a tag to a post @@ -976,3 +1070,162 @@ export const useApiSocialPostsVoteCreate = < queryClient, ); }; +/** + * Returns a cursor-paginated stream of top-level posts (excluding replies) aggregated from the user's joined hubs, public hubs, and hub-less posts. Pass `feed_strategy` to switch between ranking algorithms (currently only `recent` is implemented; reserved for future custom algorithms). + * @summary Get the user's post feed + */ +export const apiSocialPostsFeedList = ( + params?: ApiSocialPostsFeedListParams, + signal?: AbortSignal, +) => { + return privateMutator({ + url: `/api/social/posts/feed/`, + method: "GET", + params, + signal, + }); +}; + +export const getApiSocialPostsFeedListQueryKey = ( + params?: ApiSocialPostsFeedListParams, +) => { + return [`/api/social/posts/feed/`, ...(params ? [params] : [])] as const; +}; + +export const getApiSocialPostsFeedListQueryOptions = < + TData = Awaited>, + TError = unknown, +>( + params?: ApiSocialPostsFeedListParams, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + }, +) => { + const { query: queryOptions } = options ?? {}; + + const queryKey = + queryOptions?.queryKey ?? getApiSocialPostsFeedListQueryKey(params); + + const queryFn: QueryFunction< + Awaited> + > = ({ signal }) => apiSocialPostsFeedList(params, signal); + + return { queryKey, queryFn, ...queryOptions } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: DataTag }; +}; + +export type ApiSocialPostsFeedListQueryResult = NonNullable< + Awaited> +>; +export type ApiSocialPostsFeedListQueryError = unknown; + +export function useApiSocialPostsFeedList< + TData = Awaited>, + TError = unknown, +>( + params: undefined | ApiSocialPostsFeedListParams, + options: { + query: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + > & + Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + }, + queryClient?: QueryClient, +): DefinedUseQueryResult & { + queryKey: DataTag; +}; +export function useApiSocialPostsFeedList< + TData = Awaited>, + TError = unknown, +>( + params?: ApiSocialPostsFeedListParams, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + > & + Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +export function useApiSocialPostsFeedList< + TData = Awaited>, + TError = unknown, +>( + params?: ApiSocialPostsFeedListParams, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +/** + * @summary Get the user's post feed + */ + +export function useApiSocialPostsFeedList< + TData = Awaited>, + TError = unknown, +>( + params?: ApiSocialPostsFeedListParams, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +} { + const queryOptions = getApiSocialPostsFeedListQueryOptions(params, options); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag }; + + return { ...query, queryKey: queryOptions.queryKey }; +} diff --git a/frontend/src/api/generated/public/models/authorMinimal.ts b/frontend/src/api/generated/public/models/authorMinimal.ts new file mode 100644 index 0000000..d9dd031 --- /dev/null +++ b/frontend/src/api/generated/public/models/authorMinimal.ts @@ -0,0 +1,20 @@ +/** + * Generated by orval v8.8.0 🍺 + * Do not edit manually. + * OpenAPI spec version: 0.0.0 + */ + +export interface AuthorMinimal { + readonly id: number; + /** + * Požadováno. 150 znaků nebo méně. Pouze písmena, číslice a znaky @/./+/-/_. + * @maxLength 150 + * @pattern ^[\w.@+-]+$ + */ + username: string; + /** @maxLength 150 */ + first_name?: string; + /** @maxLength 150 */ + last_name?: string; + readonly avatar: string; +} diff --git a/frontend/src/api/generated/public/models/customUser.ts b/frontend/src/api/generated/public/models/customUser.ts index 33d34d2..95aa012 100644 --- a/frontend/src/api/generated/public/models/customUser.ts +++ b/frontend/src/api/generated/public/models/customUser.ts @@ -42,4 +42,6 @@ export interface CustomUser { postal_code?: string | null; readonly gdpr: boolean; is_active?: boolean; + /** @nullable */ + avatar?: string | null; } diff --git a/frontend/src/api/generated/public/models/index.ts b/frontend/src/api/generated/public/models/index.ts index 2d9804b..cbfe4d6 100644 --- a/frontend/src/api/generated/public/models/index.ts +++ b/frontend/src/api/generated/public/models/index.ts @@ -17,6 +17,7 @@ export * from "./apiDownloaderDownloadRetrieveParams"; export * from "./apiChoicesRetrieve200"; export * from "./apiChoicesRetrieve200Item"; export * from "./apiChoicesRetrieveParams"; +export * from "./authorMinimal"; export * from "./callback"; export * from "./carrierRead"; export * from "./cart"; diff --git a/frontend/src/api/generated/public/models/paginatedMessageList.ts b/frontend/src/api/generated/public/models/paginatedMessageList.ts index 0aa2cb2..aa0ceaa 100644 --- a/frontend/src/api/generated/public/models/paginatedMessageList.ts +++ b/frontend/src/api/generated/public/models/paginatedMessageList.ts @@ -6,7 +6,6 @@ import type { Message } from "./message"; export interface PaginatedMessageList { - count: number; /** @nullable */ next?: string | null; /** @nullable */ diff --git a/frontend/src/api/generated/public/models/patchedCustomUser.ts b/frontend/src/api/generated/public/models/patchedCustomUser.ts index 6e86887..becc8f4 100644 --- a/frontend/src/api/generated/public/models/patchedCustomUser.ts +++ b/frontend/src/api/generated/public/models/patchedCustomUser.ts @@ -42,4 +42,6 @@ export interface PatchedCustomUser { postal_code?: string | null; readonly gdpr?: boolean; is_active?: boolean; + /** @nullable */ + avatar?: string | null; } diff --git a/frontend/src/api/generated/public/models/patchedPost.ts b/frontend/src/api/generated/public/models/patchedPost.ts index e61af6e..062ea7e 100644 --- a/frontend/src/api/generated/public/models/patchedPost.ts +++ b/frontend/src/api/generated/public/models/patchedPost.ts @@ -3,6 +3,7 @@ * Do not edit manually. * OpenAPI spec version: 0.0.0 */ +import type { AuthorMinimal } from "./authorMinimal"; import type { PostContent } from "./postContent"; import type { Tags } from "./tags"; @@ -12,10 +13,14 @@ export interface PatchedPost { readonly created_at?: Date; readonly updated_at?: Date; readonly author?: number; + readonly author_detail?: AuthorMinimal; /** @nullable */ hub?: number | null; /** @nullable */ reply_to?: number | null; readonly tags?: readonly Tags[]; readonly contents?: readonly PostContent[]; + readonly vote_score?: string; + readonly user_vote?: string; + readonly reply_count?: number; } diff --git a/frontend/src/api/generated/public/models/post.ts b/frontend/src/api/generated/public/models/post.ts index f386f28..9b5611d 100644 --- a/frontend/src/api/generated/public/models/post.ts +++ b/frontend/src/api/generated/public/models/post.ts @@ -3,6 +3,7 @@ * Do not edit manually. * OpenAPI spec version: 0.0.0 */ +import type { AuthorMinimal } from "./authorMinimal"; import type { PostContent } from "./postContent"; import type { Tags } from "./tags"; @@ -12,10 +13,14 @@ export interface Post { readonly created_at: Date; readonly updated_at: Date; readonly author: number; + readonly author_detail: AuthorMinimal; /** @nullable */ hub?: number | null; /** @nullable */ reply_to?: number | null; readonly tags: readonly Tags[]; readonly contents: readonly PostContent[]; + readonly vote_score: string; + readonly user_vote: string; + readonly reply_count: number; } diff --git a/frontend/src/api/generated/public/models/userRegistration.ts b/frontend/src/api/generated/public/models/userRegistration.ts index 739416b..e2bdc94 100644 --- a/frontend/src/api/generated/public/models/userRegistration.ts +++ b/frontend/src/api/generated/public/models/userRegistration.ts @@ -5,16 +5,22 @@ */ export interface UserRegistration { + /** + * Užívatelské jméno + * @maxLength 150 + * @pattern ^[\w.@+-]+$ + */ + username?: string; /** * Křestní jméno uživatele * @maxLength 150 */ - first_name: string; + first_name?: string; /** * Příjmení uživatele * @maxLength 150 */ - last_name: string; + last_name?: string; /** * Emailová adresa uživatele * @maxLength 254 @@ -26,7 +32,7 @@ export interface UserRegistration { * @nullable * @pattern ^\+?\d{9,15}$ */ - phone_number: string | null; + phone_number?: string | null; /** Heslo musí mít alespoň 8 znaků, obsahovat velká a malá písmena a číslici. */ password: string; /** @@ -34,20 +40,20 @@ export interface UserRegistration { * @maxLength 100 * @nullable */ - city: string | null; + city?: string | null; /** * Ulice uživatele * @maxLength 200 * @nullable */ - street: string | null; + street?: string | null; /** * PSČ uživatele * @maxLength 5 * @nullable * @pattern ^\d{5}$ */ - postal_code: string | null; + postal_code?: string | null; /** Souhlas se zpracováním osobních údajů */ gdpr: boolean; } diff --git a/frontend/src/api/social/feed.ts b/frontend/src/api/social/feed.ts new file mode 100644 index 0000000..fc2819c --- /dev/null +++ b/frontend/src/api/social/feed.ts @@ -0,0 +1,75 @@ +/** + * Hand-written wrappers for endpoints not yet picked up by orval regen. + * Run `npm run api:gen` after running the backend to migrate to the generated client. + */ +import { privateMutator } from "../privateClient"; +import type { Post } from "../generated/private/models/post"; +import type { Message } from "../generated/private/models/message"; + +export interface CursorPaginated { + next: string | null; + previous: string | null; + results: T[]; +} + +export type FeedStrategy = "recent"; + +export interface FeedParams { + cursor?: string | null; + feed_strategy?: FeedStrategy; + page_size?: number; +} + +export const apiSocialPostsFeed = ( + params?: FeedParams, + signal?: AbortSignal, +) => + privateMutator>({ + url: `/api/social/posts/feed/`, + method: "GET", + params, + signal, + }); + +export const feedQueryKey = (params?: Omit) => + ["social", "posts", "feed", params ?? {}] as const; + +export interface RepliesParams { + reply_to: number; + cursor?: string | null; + page_size?: number; +} + +export const apiSocialPostReplies = ( + params: RepliesParams, + signal?: AbortSignal, +) => + privateMutator>({ + url: `/api/social/posts/`, + method: "GET", + params, + signal, + }); + +export const repliesQueryKey = (postId: number) => + ["social", "posts", "replies", postId] as const; + +export interface MessagesParams { + chat: number; + cursor?: string | null; + page_size?: number; +} + +export const apiSocialMessagesCursor = ( + params: MessagesParams, + signal?: AbortSignal, +) => + privateMutator>({ + url: `/api/social/messages/`, + method: "GET", + params, + signal, + }); + +export const messagesQueryKey = (chatId: number) => + ["social", "messages", chatId] as const; diff --git a/frontend/src/api/social/ws.ts b/frontend/src/api/social/ws.ts new file mode 100644 index 0000000..ad51a2f --- /dev/null +++ b/frontend/src/api/social/ws.ts @@ -0,0 +1,23 @@ +/** + * Derives the WebSocket base URL from env. Mirrors the BE Channels routing, + * which lives behind the same host as the REST API. + * + * Set `VITE_WS_URL` to override (e.g. wss://example.com); otherwise we flip the + * scheme of `VITE_BACKEND_URL`. + */ +export function getChatSocketUrl(chatId: number | string): string { + const explicit = import.meta.env.VITE_WS_URL as string | undefined; + if (explicit) { + return `${stripTrailing(explicit)}/ws/chat/${chatId}/`; + } + const backend = (import.meta.env.VITE_BACKEND_URL as string | undefined) + ?? "http://localhost:8000"; + const wsBase = backend.replace(/^http/, "ws"); + // WS endpoints live at the host root (/ws/chat//), not under /api/. + const hostOnly = wsBase.replace(/\/api\/?$/, ""); + return `${stripTrailing(hostOnly)}/ws/chat/${chatId}/`; +} + +function stripTrailing(s: string): string { + return s.endsWith("/") ? s.slice(0, -1) : s; +} diff --git a/frontend/src/components/home/navbar/SiteNav.tsx b/frontend/src/components/home/navbar/SiteNav.tsx index 542f0ff..3f03f00 100644 --- a/frontend/src/components/home/navbar/SiteNav.tsx +++ b/frontend/src/components/home/navbar/SiteNav.tsx @@ -1,7 +1,6 @@ import { useEffect, useRef, useState } from "react"; -import { useNavigate } from "react-router-dom"; +import { Link, useNavigate } from "react-router-dom"; import { - FaUserCircle, FaSignOutAlt, FaSignInAlt, FaBars, @@ -14,15 +13,16 @@ import { FaUsers, FaHandsHelping, } from "react-icons/fa"; -import {FaClapperboard, FaCubes} from "react-icons/fa6"; +import { FaClapperboard, FaCubes } from "react-icons/fa6"; import { useAuth } from "@/context/AuthContext"; +import Avatar from "@/components/ui/Avatar"; import styles from "./navbar.module.css"; export default function Navbar() { const { user, isAuthenticated, logout } = useAuth(); const navigate = useNavigate(); - - const handleLogin = () => navigate("/login"); + + const handleLogin = () => navigate("/social/login"); const handleLogout = async () => { await logout(); navigate("/"); @@ -54,7 +54,11 @@ export default function Navbar() { }, []); return ( -