diff --git a/backend/account/filters.py b/backend/account/filters.py index 149681a..53f316f 100644 --- a/backend/account/filters.py +++ b/backend/account/filters.py @@ -4,6 +4,7 @@ from django.contrib.auth import get_user_model User = get_user_model() class UserFilter(django_filters.FilterSet): + username = django_filters.CharFilter(field_name="username", lookup_expr="exact") role = django_filters.CharFilter(field_name="role", lookup_expr="exact") email = django_filters.CharFilter(field_name="email", lookup_expr="icontains") phone_number = django_filters.CharFilter(field_name="phone_number", lookup_expr="icontains") @@ -19,6 +20,6 @@ class UserFilter(django_filters.FilterSet): class Meta: model = User fields = [ - "role", "email", "phone_number", "city", "street", "postal_code", "gdpr", "is_active", "email_verified", + "username", "role", "email", "phone_number", "city", "street", "postal_code", "gdpr", "is_active", "email_verified", "create_time_after", "create_time_before" ] diff --git a/backend/account/migrations/0003_customuser_banner.py b/backend/account/migrations/0003_customuser_banner.py new file mode 100644 index 0000000..f8f7415 --- /dev/null +++ b/backend/account/migrations/0003_customuser_banner.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.7 on 2026-05-18 20:10 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('account', '0002_customuser_avatar'), + ] + + operations = [ + migrations.AddField( + model_name='customuser', + name='banner', + field=models.ImageField(blank=True, null=True, upload_to='banners/'), + ), + ] diff --git a/backend/account/models.py b/backend/account/models.py index d4ddf2a..716be9d 100644 --- a/backend/account/models.py +++ b/backend/account/models.py @@ -44,9 +44,9 @@ class CustomUser(SoftDeleteModel, AbstractUser): class Role(models.TextChoices): ADMIN = "admin", "cz#Administrátor" MANAGER = "mod", "cz#Moderator" - CUSTOMER = "regular", "cz#Regular" + REGULAR = "regular", "cz#Regular" - role = models.CharField(max_length=20, choices=Role.choices, default=Role.CUSTOMER) + role = models.CharField(max_length=20, choices=Role.choices, default=Role.REGULAR) phone_number = models.CharField( null=True, @@ -79,6 +79,7 @@ class CustomUser(SoftDeleteModel, AbstractUser): country = models.CharField(null=True, blank=True, max_length=100) avatar = models.ImageField(upload_to='avatars/', null=True, blank=True) + banner = models.ImageField(upload_to='banners/', null=True, blank=True) # firemní fakturační údaje company_name = models.CharField(max_length=255, blank=True) diff --git a/backend/account/serializers.py b/backend/account/serializers.py index d714216..de00bc5 100644 --- a/backend/account/serializers.py +++ b/backend/account/serializers.py @@ -21,8 +21,8 @@ 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'] + fields = ['id', 'username', 'first_name', 'last_name', 'avatar', 'banner', 'city', 'role', 'create_time'] + read_only_fields = ['id', 'username', 'first_name', 'last_name', 'avatar', 'banner', 'city', 'role', 'create_time'] class CustomUserSerializer(serializers.ModelSerializer): @@ -44,6 +44,7 @@ class CustomUserSerializer(serializers.ModelSerializer): "gdpr", "is_active", "avatar", + "banner", ] read_only_fields = ["id", "create_time", "gdpr", "username"] # <-- removed "account_type" @@ -201,6 +202,23 @@ class PasswordResetConfirmSerializer(serializers.Serializer): ) def validate_password(self, value): + import re + if len(value) < 8: + raise serializers.ValidationError("Heslo musí mít alespoň 8 znaků.") + if not re.search(r"[A-Z]", value): + raise serializers.ValidationError("Musí obsahovat velké písmeno.") + if not re.search(r"[a-z]", value): + raise serializers.ValidationError("Musí obsahovat malé písmeno.") + if not re.search(r"\d", value): + raise serializers.ValidationError("Musí obsahovat číslici.") + return value + + +class ChangePasswordSerializer(serializers.Serializer): + current_password = serializers.CharField(write_only=True) + new_password = serializers.CharField(write_only=True) + + def validate_new_password(self, value): import re if len(value) < 8: raise serializers.ValidationError("Heslo musí mít alespoň 8 znaků.") diff --git a/backend/account/urls.py b/backend/account/urls.py index 0237a93..3bdab45 100644 --- a/backend/account/urls.py +++ b/backend/account/urls.py @@ -20,6 +20,7 @@ urlpatterns = [ # Password reset endpoints path('password-reset/', views.PasswordResetRequestView.as_view(), name='password-reset-request'), path('password-reset-confirm///', views.PasswordResetConfirmView.as_view(), name='password-reset-confirm'), + path('password-change/', views.ChangePasswordView.as_view(), name='password-change'), # User CRUD (list, retrieve, update, delete) path('', include(router.urls)), #/users/ diff --git a/backend/account/views.py b/backend/account/views.py index 35f451d..9a0aed2 100644 --- a/backend/account/views.py +++ b/backend/account/views.py @@ -232,36 +232,29 @@ class UserView(viewsets.ModelViewSet): }, ) def get_permissions(self): - # Only admin can list or create users - if self.action in ['list', 'create']: + if self.action == 'create': return [OnlyRolesAllowed("admin")()] - # Only admin or the user themselves can update or delete - elif self.action in ['update', 'partial_update', 'destroy']: + if self.action in ['list', 'retrieve']: + return [IsAuthenticated()] + + if self.action in ['update', 'partial_update', 'destroy']: user = getattr(self, 'request', None) and getattr(self.request, 'user', None) - # Admins can modify any user if user and getattr(user, 'is_authenticated', False) and getattr(user, 'role', None) == 'admin': return [OnlyRolesAllowed("admin")()] - - # Users can modify their own record - if user and getattr(user, 'is_authenticated', False) and self.kwargs.get('pk') and str(getattr(user, 'id', '')) == self.kwargs['pk']: + lookup = self.kwargs.get('pk', '') + if user and getattr(user, 'is_authenticated', False) and lookup and ( + str(getattr(user, 'id', '')) == lookup + ): return [IsAuthenticated()] - - # Fallback - deny access (prevents AttributeError for AnonymousUser) return [OnlyRolesAllowed("admin")()] - # Any authenticated user can retrieve a profile (serializer limits fields for non-owner/non-admin) - elif self.action == 'retrieve': - 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: + if self.action in ['retrieve', 'list'] and not is_admin: return PublicUserSerializer return CustomUserSerializer @@ -285,6 +278,29 @@ class CurrentUserView(APIView): return Response(serializer.data) +@extend_schema( + tags=["account"], + summary="Change password for the authenticated user", + request=ChangePasswordSerializer, + responses={ + 200: OpenApiResponse(description="Password changed successfully."), + 400: OpenApiResponse(description="Invalid current password or validation error."), + }, +) +class ChangePasswordView(APIView): + permission_classes = [IsAuthenticated] + + def post(self, request): + serializer = ChangePasswordSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + user = request.user + if not user.check_password(serializer.validated_data['current_password']): + return Response({"current_password": "Nesprávné heslo."}, status=status.HTTP_400_BAD_REQUEST) + user.set_password(serializer.validated_data['new_password']) + user.save() + return Response({"detail": "Heslo bylo úspěšně změněno."}) + + #------------------------------------------------REGISTRACE-------------------------------------------------------------- #1. registration API diff --git a/backend/social/posts/migrations/0002_postsave.py b/backend/social/posts/migrations/0002_postsave.py new file mode 100644 index 0000000..0e2051f --- /dev/null +++ b/backend/social/posts/migrations/0002_postsave.py @@ -0,0 +1,28 @@ +# Generated by Django 5.2.7 on 2026-05-18 19:20 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('posts', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='PostSave', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('post', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='saves', to='posts.post')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='saved_posts', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'unique_together': {('post', 'user')}, + }, + ), + ] diff --git a/backend/social/posts/models.py b/backend/social/posts/models.py index 8e572fb..31e6b7f 100644 --- a/backend/social/posts/models.py +++ b/backend/social/posts/models.py @@ -82,6 +82,18 @@ class PostContent(SoftDeleteModel): return super().save(*args, **kwargs) +class PostSave(models.Model): + post = models.ForeignKey(Post, on_delete=models.CASCADE, related_name='saves') + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='saved_posts') + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + unique_together = ('post', 'user') + + def __str__(self): + return f"{self.user} saved Post {self.post_id}" + + class PostVote(SoftDeleteModel): class VoteChoice(models.IntegerChoices): UP = 1, 'Upvote' diff --git a/backend/social/posts/serializers.py b/backend/social/posts/serializers.py index 6e0207b..f93a413 100644 --- a/backend/social/posts/serializers.py +++ b/backend/social/posts/serializers.py @@ -1,6 +1,6 @@ from django.contrib.auth import get_user_model from rest_framework import serializers -from .models import Post, PostContent, PostVote +from .models import Post, PostContent, PostVote, PostSave from social.hubs.serializers import TagsSerializer User = get_user_model() @@ -28,6 +28,8 @@ class PostSerializer(serializers.ModelSerializer): vote_score = serializers.SerializerMethodField() user_vote = serializers.SerializerMethodField() reply_count = serializers.IntegerField(read_only=True, default=0) + is_saved = serializers.SerializerMethodField() + save_count = serializers.SerializerMethodField() class Meta: model = Post @@ -36,7 +38,7 @@ class PostSerializer(serializers.ModelSerializer): 'author', 'author_detail', 'hub', 'reply_to', 'tags', 'contents', - 'vote_score', 'user_vote', 'reply_count', + 'vote_score', 'user_vote', 'reply_count', 'is_saved', 'save_count', ] read_only_fields = ['author', 'created_at', 'updated_at'] @@ -50,6 +52,15 @@ class PostSerializer(serializers.ModelSerializer): vote = obj.votes.filter(user=request.user).first() return vote.vote if vote else 0 + def get_is_saved(self, obj): + request = self.context.get('request') + if not request or not request.user.is_authenticated: + return False + return obj.saves.filter(user=request.user).exists() + + def get_save_count(self, obj): + return obj.saves.count() + class PostVoteSerializer(serializers.ModelSerializer): class Meta: diff --git a/backend/social/posts/views.py b/backend/social/posts/views.py index 83f3e49..b23f967 100644 --- a/backend/social/posts/views.py +++ b/backend/social/posts/views.py @@ -9,7 +9,7 @@ from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiPara from rest_framework.permissions import IsAuthenticated from social.hubs.models import Tags from vontor_cz.pagination import CreatedCursorPagination -from .models import Post, PostContent, PostVote +from .models import Post, PostContent, PostVote, PostSave from .permissions import CanDeletePost, IsPostAuthorOnly from .serializers import PostSerializer, PostContentSerializer, PostVoteSerializer, TagAttachSerializer @@ -67,7 +67,7 @@ class PostViewSet(viewsets.ModelViewSet): qs = ( Post.objects .select_related('author', 'hub') - .prefetch_related('tags', 'contents', 'votes') + .prefetch_related('tags', 'contents', 'votes', 'saves') .annotate(reply_count=Count('replies', distinct=True)) ) hub_id = self.request.query_params.get('hub') @@ -196,7 +196,7 @@ class PostViewSet(viewsets.ModelViewSet): base_qs = ( Post.objects .select_related('author', 'hub') - .prefetch_related('tags', 'contents', 'votes') + .prefetch_related('tags', 'contents', 'votes', 'saves') .annotate(reply_count=Count('replies', distinct=True)) .filter(reply_to__isnull=True) ) @@ -241,3 +241,45 @@ class PostViewSet(viewsets.ModelViewSet): defaults={'vote': ser.validated_data['vote']}, ) return Response(PostVoteSerializer(vote_obj).data) + + # ------------------------------------------------------------------ + # Save / unsave action + # ------------------------------------------------------------------ + + @extend_schema( + tags=["posts"], + summary="Toggle save on a post", + description="Saves the post for the current user, or unsaves it if already saved. Returns `{saved: true/false}`.", + responses={200: {'type': 'object', 'properties': {'saved': {'type': 'boolean'}}}}, + ) + @action(detail=True, methods=['post'], url_path='save') + def save_post(self, request, pk=None): + post = self.get_object() + obj, created = PostSave.objects.get_or_create(post=post, user=request.user) + if not created: + obj.delete() + return Response({'saved': created}) + + # ------------------------------------------------------------------ + # Saved feed + # ------------------------------------------------------------------ + + @extend_schema( + tags=["posts"], + summary="List posts saved by the current user", + responses={200: PostSerializer(many=True)}, + ) + @action(detail=False, methods=['get'], url_path='saved') + def saved(self, request): + qs = ( + Post.objects + .select_related('author', 'hub') + .prefetch_related('tags', 'contents', 'votes', 'saves') + .annotate(reply_count=Count('replies', distinct=True)) + .filter(saves__user=request.user) + .order_by('-saves__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) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index df53d5b..f41b1e4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -28,6 +28,8 @@ 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 SavedPage from "./pages/social/SavedPage"; +import AccountSettingsPage from "./pages/social/AccountSettingsPage"; import ChatsIndexPage from "./pages/social/chat/ChatsPage"; import ChatRoomPage from "./pages/social/chat/ChatRoomPage"; @@ -63,7 +65,9 @@ export default function App() { } /> } /> } /> - } /> + } /> + } /> + } /> }> } /> } /> diff --git a/frontend/src/api/generated/private/models/apiSocialPostsSaveCreate200.ts b/frontend/src/api/generated/private/models/apiSocialPostsSaveCreate200.ts new file mode 100644 index 0000000..3a45760 --- /dev/null +++ b/frontend/src/api/generated/private/models/apiSocialPostsSaveCreate200.ts @@ -0,0 +1,9 @@ +/** + * Generated by orval v8.8.0 🍺 + * Do not edit manually. + * OpenAPI spec version: 0.0.0 + */ + +export type ApiSocialPostsSaveCreate200 = { + saved?: boolean; +}; diff --git a/frontend/src/api/generated/private/models/apiSocialPostsSavedListParams.ts b/frontend/src/api/generated/private/models/apiSocialPostsSavedListParams.ts new file mode 100644 index 0000000..2c2ebc3 --- /dev/null +++ b/frontend/src/api/generated/private/models/apiSocialPostsSavedListParams.ts @@ -0,0 +1,23 @@ +/** + * Generated by orval v8.8.0 🍺 + * Do not edit manually. + * OpenAPI spec version: 0.0.0 + */ + +export type ApiSocialPostsSavedListParams = { + author?: number; + 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/index.ts b/frontend/src/api/generated/private/models/index.ts index 4f4663c..eaf2b9a 100644 --- a/frontend/src/api/generated/private/models/index.ts +++ b/frontend/src/api/generated/private/models/index.ts @@ -29,6 +29,8 @@ export * from "./apiSocialMessagesListParams"; export * from "./apiSocialPostsFeedListParams"; export * from "./apiSocialPostsListParams"; export * from "./apiSocialPostsMediaCreateBody"; +export * from "./apiSocialPostsSaveCreate200"; +export * from "./apiSocialPostsSavedListParams"; export * from "./apiZasilkovnaShipmentsListParams"; export * from "./authorMinimal"; export * from "./callback"; diff --git a/frontend/src/api/generated/private/models/patchedPost.ts b/frontend/src/api/generated/private/models/patchedPost.ts index 062ea7e..f7cade1 100644 --- a/frontend/src/api/generated/private/models/patchedPost.ts +++ b/frontend/src/api/generated/private/models/patchedPost.ts @@ -23,4 +23,6 @@ export interface PatchedPost { readonly vote_score?: string; readonly user_vote?: string; readonly reply_count?: number; + readonly is_saved?: string; + readonly save_count?: string; } diff --git a/frontend/src/api/generated/private/models/post.ts b/frontend/src/api/generated/private/models/post.ts index 9b5611d..071edc2 100644 --- a/frontend/src/api/generated/private/models/post.ts +++ b/frontend/src/api/generated/private/models/post.ts @@ -23,4 +23,6 @@ export interface Post { readonly vote_score: string; readonly user_vote: string; readonly reply_count: number; + readonly is_saved: string; + readonly save_count: string; } diff --git a/frontend/src/api/generated/private/posts/posts.ts b/frontend/src/api/generated/private/posts/posts.ts index e56d649..c765d65 100644 --- a/frontend/src/api/generated/private/posts/posts.ts +++ b/frontend/src/api/generated/private/posts/posts.ts @@ -23,6 +23,8 @@ import type { ApiSocialPostsFeedListParams, ApiSocialPostsListParams, ApiSocialPostsMediaCreateBody, + ApiSocialPostsSaveCreate200, + ApiSocialPostsSavedListParams, PaginatedPostList, PatchedPost, Post, @@ -806,6 +808,94 @@ export const useApiSocialPostsMediaCreate = < queryClient, ); }; +/** + * Saves the post for the current user, or unsaves it if already saved. Returns `{saved: true/false}`. + * @summary Toggle save on a post + */ +export const apiSocialPostsSaveCreate = ( + id: number, + post: NonReadonly, + signal?: AbortSignal, +) => { + return privateMutator({ + url: `/api/social/posts/${id}/save/`, + method: "POST", + headers: { "Content-Type": "application/json" }, + data: post, + signal, + }); +}; + +export const getApiSocialPostsSaveCreateMutationOptions = < + TError = unknown, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { id: number; data: NonReadonly }, + TContext + >; +}): UseMutationOptions< + Awaited>, + TError, + { id: number; data: NonReadonly }, + TContext +> => { + const mutationKey = ["apiSocialPostsSaveCreate"]; + 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: NonReadonly } + > = (props) => { + const { id, data } = props ?? {}; + + return apiSocialPostsSaveCreate(id, data); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type ApiSocialPostsSaveCreateMutationResult = NonNullable< + Awaited> +>; +export type ApiSocialPostsSaveCreateMutationBody = NonReadonly; +export type ApiSocialPostsSaveCreateMutationError = unknown; + +/** + * @summary Toggle save on a post + */ +export const useApiSocialPostsSaveCreate = < + TError = unknown, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { id: number; data: NonReadonly }, + TContext + >; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + TError, + { id: number; data: NonReadonly }, + TContext +> => { + return useMutation( + getApiSocialPostsSaveCreateMutationOptions(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 @@ -1229,3 +1319,162 @@ export function useApiSocialPostsFeedList< return { ...query, queryKey: queryOptions.queryKey }; } + +/** + * @summary List posts saved by the current user + */ +export const apiSocialPostsSavedList = ( + params?: ApiSocialPostsSavedListParams, + signal?: AbortSignal, +) => { + return privateMutator({ + url: `/api/social/posts/saved/`, + method: "GET", + params, + signal, + }); +}; + +export const getApiSocialPostsSavedListQueryKey = ( + params?: ApiSocialPostsSavedListParams, +) => { + return [`/api/social/posts/saved/`, ...(params ? [params] : [])] as const; +}; + +export const getApiSocialPostsSavedListQueryOptions = < + TData = Awaited>, + TError = unknown, +>( + params?: ApiSocialPostsSavedListParams, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + }, +) => { + const { query: queryOptions } = options ?? {}; + + const queryKey = + queryOptions?.queryKey ?? getApiSocialPostsSavedListQueryKey(params); + + const queryFn: QueryFunction< + Awaited> + > = ({ signal }) => apiSocialPostsSavedList(params, signal); + + return { queryKey, queryFn, ...queryOptions } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: DataTag }; +}; + +export type ApiSocialPostsSavedListQueryResult = NonNullable< + Awaited> +>; +export type ApiSocialPostsSavedListQueryError = unknown; + +export function useApiSocialPostsSavedList< + TData = Awaited>, + TError = unknown, +>( + params: undefined | ApiSocialPostsSavedListParams, + options: { + query: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + > & + Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + }, + queryClient?: QueryClient, +): DefinedUseQueryResult & { + queryKey: DataTag; +}; +export function useApiSocialPostsSavedList< + TData = Awaited>, + TError = unknown, +>( + params?: ApiSocialPostsSavedListParams, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + > & + Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +export function useApiSocialPostsSavedList< + TData = Awaited>, + TError = unknown, +>( + params?: ApiSocialPostsSavedListParams, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +/** + * @summary List posts saved by the current user + */ + +export function useApiSocialPostsSavedList< + TData = Awaited>, + TError = unknown, +>( + params?: ApiSocialPostsSavedListParams, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +} { + const queryOptions = getApiSocialPostsSavedListQueryOptions(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/patchedPost.ts b/frontend/src/api/generated/public/models/patchedPost.ts index 062ea7e..f7cade1 100644 --- a/frontend/src/api/generated/public/models/patchedPost.ts +++ b/frontend/src/api/generated/public/models/patchedPost.ts @@ -23,4 +23,6 @@ export interface PatchedPost { readonly vote_score?: string; readonly user_vote?: string; readonly reply_count?: number; + readonly is_saved?: string; + readonly save_count?: string; } diff --git a/frontend/src/api/generated/public/models/post.ts b/frontend/src/api/generated/public/models/post.ts index 9b5611d..071edc2 100644 --- a/frontend/src/api/generated/public/models/post.ts +++ b/frontend/src/api/generated/public/models/post.ts @@ -23,4 +23,6 @@ export interface Post { readonly vote_score: string; readonly user_vote: string; readonly reply_count: number; + readonly is_saved: string; + readonly save_count: string; } diff --git a/frontend/src/api/privateClient.ts b/frontend/src/api/privateClient.ts index 6798278..9a46173 100644 --- a/frontend/src/api/privateClient.ts +++ b/frontend/src/api/privateClient.ts @@ -1,31 +1,89 @@ import axios, { type AxiosRequestConfig } from "axios"; +import { AUTH_FLAG } from "@/context/AuthContext"; -// použij tohle pro API vyžadující autentizaci export const privateApi = axios.create({ - withCredentials: true, // potřebuje HttpOnly cookies + withCredentials: true, + baseURL: '', }); -// Set baseURL at runtime (using Function to hide from orval's esbuild) -try { - const getEnv = new Function('return import.meta.env.VITE_BACKEND_URL'); - privateApi.defaults.baseURL = getEnv() || "http://localhost:8000"; -} catch { - privateApi.defaults.baseURL = "http://localhost:8000"; -} +let isRefreshing = false; +let failedQueue: Array<{ + resolve: (value?: unknown) => void; + reject: (reason?: any) => void; +}> = []; + +const processQueue = (error: any = null) => { + failedQueue.forEach((promise) => { + if (error) { + promise.reject(error); + } else { + promise.resolve(); + } + }); + failedQueue = []; +}; privateApi.interceptors.response.use( (res) => res, async (error) => { const original = error.config; + if (error.response?.status === 400 && error.response?.data) { + const data = error.response.data; + if (typeof data === 'object' && !Array.isArray(data)) { + const firstKey = Object.keys(data)[0]; + if (firstKey && Array.isArray(data[firstKey]) && data[firstKey].length > 0) { + error.message = data[firstKey][0]; + } + } + } + if (error.response?.status === 401 && !original._retry) { + if (original.url?.includes("/api/account/logout/")) { + return Promise.reject(error); + } + + if (error.response?.data?.code === "user_not_found") { + processQueue(error); + isRefreshing = false; + localStorage.removeItem(AUTH_FLAG); + window.location.href = "/social/login"; + return Promise.reject(error); + } + + if (original.url?.includes("/api/account/token/refresh/")) { + processQueue(error); + isRefreshing = false; + localStorage.removeItem(AUTH_FLAG); + try { + await privateApi.post("/api/account/logout/"); + } catch { + // cookies may already be expired + } + window.location.href = "/social/login"; + return Promise.reject(error); + } + + if (isRefreshing) { + return new Promise((resolve, reject) => { + failedQueue.push({ resolve, reject }); + }) + .then(() => privateApi(original)) + .catch((err) => Promise.reject(err)); + } + original._retry = true; + isRefreshing = true; try { await privateApi.post("/api/account/token/refresh/"); + processQueue(); return privateApi(original); - } catch { - // optional: logout + } catch (refreshError) { + processQueue(refreshError); + return Promise.reject(refreshError); + } finally { + isRefreshing = false; } } @@ -33,15 +91,10 @@ privateApi.interceptors.response.use( } ); - -export const privateMutator = async ( - config: AxiosRequestConfig -): Promise => { - // If sending FormData, remove Content-Type header to let axios set it with boundary +export const privateMutator = async (config: AxiosRequestConfig): Promise => { if (config.data instanceof FormData) { delete config.headers?.['Content-Type']; } - const response = await privateApi.request(config); return response.data; -}; \ No newline at end of file +}; diff --git a/frontend/src/api/publicClient.ts b/frontend/src/api/publicClient.ts index 87175de..d18b350 100644 --- a/frontend/src/api/publicClient.ts +++ b/frontend/src/api/publicClient.ts @@ -1,23 +1,46 @@ import axios, { type AxiosRequestConfig } from "axios"; +import { AUTH_FLAG } from "@/context/AuthContext"; -// použij tohle pro veřejné API nevyžadující autentizaci export const publicApi = axios.create({ - withCredentials: false, // veřejné API NEPOSÍLÁ cookies + withCredentials: true, + baseURL: '', }); -// Set baseURL at runtime (using Function to hide from orval's esbuild) -try { - const getEnv = new Function('return import.meta.env.VITE_BACKEND_URL'); - publicApi.defaults.baseURL = getEnv() || "http://localhost:8000"; -} catch { - publicApi.defaults.baseURL = "http://localhost:8000"; -} +publicApi.interceptors.response.use( + (res) => res, + (error) => { + const url = error.config?.url ?? ''; + if ( + error.response?.status === 401 && + error.response?.data?.code === "user_not_found" && + !url.includes("/api/account/logout/") + ) { + localStorage.removeItem(AUTH_FLAG); + window.location.href = "/social/login"; + } + return Promise.reject(error); + } +); +const pendingRequests = new Map(); -// ⬇⬇⬇ TOHLE JE TEN MUTATOR ⬇⬇⬇ -export const publicMutator = async ( - config: AxiosRequestConfig -): Promise => { - const response = await publicApi.request(config); - return response.data; -}; \ No newline at end of file +export const publicMutator = async (config: AxiosRequestConfig): Promise => { + const requestKey = `${config.method}_${config.url}_${JSON.stringify(config.data || '')}`; + + if (pendingRequests.has(requestKey)) { + return pendingRequests.get(requestKey); + } + + const requestPromise = (async () => { + try { + const response = await publicApi.request(config); + return response.data; + } finally { + pendingRequests.delete(requestKey); + } + })(); + + pendingRequests.set(requestKey, requestPromise); + + return requestPromise; +}; diff --git a/frontend/src/components/social/posts/MediaGallery.tsx b/frontend/src/components/social/posts/MediaGallery.tsx index 0540452..341943c 100644 --- a/frontend/src/components/social/posts/MediaGallery.tsx +++ b/frontend/src/components/social/posts/MediaGallery.tsx @@ -1,46 +1,239 @@ +import { useState, useEffect, useCallback } from "react"; +import { createPortal } from "react-dom"; +import { FiX, FiChevronLeft, FiChevronRight, FiFile, FiDownload } from "react-icons/fi"; import type { PostContent } from "@/api/generated/private/models/postContent"; +import { mediaUrl } from "@/utils/mediaUrl"; interface Props { items: readonly PostContent[]; } export default function MediaGallery({ items }: Props) { + const [lightboxIndex, setLightboxIndex] = useState(null); + + // Grid shows first 4; lightbox navigates all + const visible = items.slice(0, 4); + const overflow = items.length - visible.length; + const isOpen = lightboxIndex !== null; + + const prev = useCallback(() => { + setLightboxIndex((i) => (i !== null ? (i - 1 + items.length) % items.length : null)); + }, [items.length]); + + const next = useCallback(() => { + setLightboxIndex((i) => (i !== null ? (i + 1) % items.length : null)); + }, [items.length]); + + const close = useCallback(() => setLightboxIndex(null), []); + + useEffect(() => { + if (!isOpen) return; + function onKey(e: KeyboardEvent) { + if (e.key === "Escape") close(); + if (e.key === "ArrowLeft") prev(); + if (e.key === "ArrowRight") next(); + } + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [isOpen, close, prev, next]); + if (!items?.length) return null; - const layoutClass = - items.length === 1 - ? "grid-cols-1" - : items.length === 2 - ? "grid-cols-2" - : "grid-cols-2"; + const layoutClass = visible.length === 1 ? "grid-cols-1" : "grid-cols-2"; return ( -
- {items.map((it) => ( - - ))} -
+ <> +
+ {visible.map((it, i) => ( + 0 ? overflow : 0} + onOpen={() => setLightboxIndex(i)} + /> + ))} +
+ + {isOpen && + createPortal( +
+ {/* Close */} + + + {/* Prev */} + {items.length > 1 && ( + + )} + + {/* Content */} + + + {/* Next */} + {items.length > 1 && ( + + )} + + {/* Dots */} + {items.length > 1 && ( +
e.stopPropagation()} + > + {items.map((_: PostContent, i: number) => ( +
+ )} +
, + document.body, + )} + ); } -function MediaItem({ item }: { item: PostContent }) { - const url = item.file ?? ""; +function LightboxContent({ item }: { item: PostContent }) { + const url = mediaUrl(item.file) ?? ""; const mime = item.mime_type ?? ""; + if (mime.startsWith("video/")) { return (