posts are done

This commit is contained in:
2026-05-19 00:08:02 +02:00
parent 202ce22102
commit 2e9e3ed41b
35 changed files with 1528 additions and 272 deletions

View File

@@ -4,6 +4,7 @@ from django.contrib.auth import get_user_model
User = get_user_model() User = get_user_model()
class UserFilter(django_filters.FilterSet): 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") role = django_filters.CharFilter(field_name="role", lookup_expr="exact")
email = django_filters.CharFilter(field_name="email", lookup_expr="icontains") email = django_filters.CharFilter(field_name="email", lookup_expr="icontains")
phone_number = django_filters.CharFilter(field_name="phone_number", 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: class Meta:
model = User model = User
fields = [ 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" "create_time_after", "create_time_before"
] ]

View File

@@ -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/'),
),
]

View File

@@ -44,9 +44,9 @@ class CustomUser(SoftDeleteModel, AbstractUser):
class Role(models.TextChoices): class Role(models.TextChoices):
ADMIN = "admin", "cz#Administrátor" ADMIN = "admin", "cz#Administrátor"
MANAGER = "mod", "cz#Moderator" 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( phone_number = models.CharField(
null=True, null=True,
@@ -79,6 +79,7 @@ class CustomUser(SoftDeleteModel, AbstractUser):
country = models.CharField(null=True, blank=True, max_length=100) country = models.CharField(null=True, blank=True, max_length=100)
avatar = models.ImageField(upload_to='avatars/', null=True, blank=True) avatar = models.ImageField(upload_to='avatars/', null=True, blank=True)
banner = models.ImageField(upload_to='banners/', null=True, blank=True)
# firemní fakturační údaje # firemní fakturační údaje
company_name = models.CharField(max_length=255, blank=True) company_name = models.CharField(max_length=255, blank=True)

View File

@@ -21,8 +21,8 @@ class PublicUserSerializer(serializers.ModelSerializer):
"""Minimal read-only profile returned to non-owner authenticated users.""" """Minimal read-only profile returned to non-owner authenticated users."""
class Meta: class Meta:
model = User model = User
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', 'city', 'role', 'create_time'] read_only_fields = ['id', 'username', 'first_name', 'last_name', 'avatar', 'banner', 'city', 'role', 'create_time']
class CustomUserSerializer(serializers.ModelSerializer): class CustomUserSerializer(serializers.ModelSerializer):
@@ -44,6 +44,7 @@ class CustomUserSerializer(serializers.ModelSerializer):
"gdpr", "gdpr",
"is_active", "is_active",
"avatar", "avatar",
"banner",
] ]
read_only_fields = ["id", "create_time", "gdpr", "username"] # <-- removed "account_type" read_only_fields = ["id", "create_time", "gdpr", "username"] # <-- removed "account_type"
@@ -201,6 +202,23 @@ class PasswordResetConfirmSerializer(serializers.Serializer):
) )
def validate_password(self, value): 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 import re
if len(value) < 8: if len(value) < 8:
raise serializers.ValidationError("Heslo musí mít alespoň 8 znaků.") raise serializers.ValidationError("Heslo musí mít alespoň 8 znaků.")

View File

@@ -20,6 +20,7 @@ urlpatterns = [
# Password reset endpoints # Password reset endpoints
path('password-reset/', views.PasswordResetRequestView.as_view(), name='password-reset-request'), path('password-reset/', views.PasswordResetRequestView.as_view(), name='password-reset-request'),
path('password-reset-confirm/<uidb64>/<token>/', views.PasswordResetConfirmView.as_view(), name='password-reset-confirm'), path('password-reset-confirm/<uidb64>/<token>/', views.PasswordResetConfirmView.as_view(), name='password-reset-confirm'),
path('password-change/', views.ChangePasswordView.as_view(), name='password-change'),
# User CRUD (list, retrieve, update, delete) # User CRUD (list, retrieve, update, delete)
path('', include(router.urls)), #/users/ path('', include(router.urls)), #/users/

View File

@@ -232,36 +232,29 @@ class UserView(viewsets.ModelViewSet):
}, },
) )
def get_permissions(self): def get_permissions(self):
# Only admin can list or create users if self.action == 'create':
if self.action in ['list', 'create']:
return [OnlyRolesAllowed("admin")()] return [OnlyRolesAllowed("admin")()]
# Only admin or the user themselves can update or delete if self.action in ['list', 'retrieve']:
elif self.action in ['update', 'partial_update', 'destroy']: return [IsAuthenticated()]
if self.action in ['update', 'partial_update', 'destroy']:
user = getattr(self, 'request', None) and getattr(self.request, 'user', None) 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': if user and getattr(user, 'is_authenticated', False) and getattr(user, 'role', None) == 'admin':
return [OnlyRolesAllowed("admin")()] return [OnlyRolesAllowed("admin")()]
lookup = self.kwargs.get('pk', '')
# Users can modify their own record if user and getattr(user, 'is_authenticated', False) and lookup and (
if user and getattr(user, 'is_authenticated', False) and self.kwargs.get('pk') and str(getattr(user, 'id', '')) == self.kwargs['pk']: str(getattr(user, 'id', '')) == lookup
):
return [IsAuthenticated()] return [IsAuthenticated()]
# Fallback - deny access (prevents AttributeError for AnonymousUser)
return [OnlyRolesAllowed("admin")()] 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() return super().get_permissions()
def get_serializer_class(self): def get_serializer_class(self):
user = getattr(self.request, 'user', None) 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)) 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 PublicUserSerializer
return CustomUserSerializer return CustomUserSerializer
@@ -285,6 +278,29 @@ class CurrentUserView(APIView):
return Response(serializer.data) 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-------------------------------------------------------------- #------------------------------------------------REGISTRACE--------------------------------------------------------------
#1. registration API #1. registration API

View File

@@ -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')},
},
),
]

View File

@@ -82,6 +82,18 @@ class PostContent(SoftDeleteModel):
return super().save(*args, **kwargs) 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 PostVote(SoftDeleteModel):
class VoteChoice(models.IntegerChoices): class VoteChoice(models.IntegerChoices):
UP = 1, 'Upvote' UP = 1, 'Upvote'

View File

@@ -1,6 +1,6 @@
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from rest_framework import serializers from rest_framework import serializers
from .models import Post, PostContent, PostVote from .models import Post, PostContent, PostVote, PostSave
from social.hubs.serializers import TagsSerializer from social.hubs.serializers import TagsSerializer
User = get_user_model() User = get_user_model()
@@ -28,6 +28,8 @@ class PostSerializer(serializers.ModelSerializer):
vote_score = serializers.SerializerMethodField() vote_score = serializers.SerializerMethodField()
user_vote = serializers.SerializerMethodField() user_vote = serializers.SerializerMethodField()
reply_count = serializers.IntegerField(read_only=True, default=0) reply_count = serializers.IntegerField(read_only=True, default=0)
is_saved = serializers.SerializerMethodField()
save_count = serializers.SerializerMethodField()
class Meta: class Meta:
model = Post model = Post
@@ -36,7 +38,7 @@ class PostSerializer(serializers.ModelSerializer):
'author', 'author_detail', 'author', 'author_detail',
'hub', 'reply_to', 'hub', 'reply_to',
'tags', 'contents', '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'] read_only_fields = ['author', 'created_at', 'updated_at']
@@ -50,6 +52,15 @@ class PostSerializer(serializers.ModelSerializer):
vote = obj.votes.filter(user=request.user).first() vote = obj.votes.filter(user=request.user).first()
return vote.vote if vote else 0 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 PostVoteSerializer(serializers.ModelSerializer):
class Meta: class Meta:

View File

@@ -9,7 +9,7 @@ from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiPara
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
from social.hubs.models import Tags from social.hubs.models import Tags
from vontor_cz.pagination import CreatedCursorPagination 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 .permissions import CanDeletePost, IsPostAuthorOnly
from .serializers import PostSerializer, PostContentSerializer, PostVoteSerializer, TagAttachSerializer from .serializers import PostSerializer, PostContentSerializer, PostVoteSerializer, TagAttachSerializer
@@ -67,7 +67,7 @@ class PostViewSet(viewsets.ModelViewSet):
qs = ( qs = (
Post.objects Post.objects
.select_related('author', 'hub') .select_related('author', 'hub')
.prefetch_related('tags', 'contents', 'votes') .prefetch_related('tags', 'contents', 'votes', 'saves')
.annotate(reply_count=Count('replies', distinct=True)) .annotate(reply_count=Count('replies', distinct=True))
) )
hub_id = self.request.query_params.get('hub') hub_id = self.request.query_params.get('hub')
@@ -196,7 +196,7 @@ class PostViewSet(viewsets.ModelViewSet):
base_qs = ( base_qs = (
Post.objects Post.objects
.select_related('author', 'hub') .select_related('author', 'hub')
.prefetch_related('tags', 'contents', 'votes') .prefetch_related('tags', 'contents', 'votes', 'saves')
.annotate(reply_count=Count('replies', distinct=True)) .annotate(reply_count=Count('replies', distinct=True))
.filter(reply_to__isnull=True) .filter(reply_to__isnull=True)
) )
@@ -241,3 +241,45 @@ class PostViewSet(viewsets.ModelViewSet):
defaults={'vote': ser.validated_data['vote']}, defaults={'vote': ser.validated_data['vote']},
) )
return Response(PostVoteSerializer(vote_obj).data) 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)

View File

@@ -28,6 +28,8 @@ import HubsPage from "./pages/social/HubsPage";
import HubPage from "./pages/social/HubPage"; import HubPage from "./pages/social/HubPage";
import ProfilePage from "./pages/social/ProfilePage"; import ProfilePage from "./pages/social/ProfilePage";
import UserProfilePage from "./pages/social/UserProfilePage"; 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 ChatsIndexPage from "./pages/social/chat/ChatsPage";
import ChatRoomPage from "./pages/social/chat/ChatRoomPage"; import ChatRoomPage from "./pages/social/chat/ChatRoomPage";
@@ -63,7 +65,9 @@ export default function App() {
<Route path="hubs" element={<HubsPage />} /> <Route path="hubs" element={<HubsPage />} />
<Route path="hub/:id" element={<HubPage />} /> <Route path="hub/:id" element={<HubPage />} />
<Route path="profile" element={<ProfilePage />} /> <Route path="profile" element={<ProfilePage />} />
<Route path="profile/:id" element={<UserProfilePage />} /> <Route path="profile/:username" element={<UserProfilePage />} />
<Route path="saved" element={<SavedPage />} />
<Route path="account/settings" element={<AccountSettingsPage />} />
<Route path="chats" element={<ChatLayout />}> <Route path="chats" element={<ChatLayout />}>
<Route index element={<ChatsIndexPage />} /> <Route index element={<ChatsIndexPage />} />
<Route path=":chatId" element={<ChatRoomPage />} /> <Route path=":chatId" element={<ChatRoomPage />} />

View File

@@ -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;
};

View File

@@ -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;
};

View File

@@ -29,6 +29,8 @@ export * from "./apiSocialMessagesListParams";
export * from "./apiSocialPostsFeedListParams"; export * from "./apiSocialPostsFeedListParams";
export * from "./apiSocialPostsListParams"; export * from "./apiSocialPostsListParams";
export * from "./apiSocialPostsMediaCreateBody"; export * from "./apiSocialPostsMediaCreateBody";
export * from "./apiSocialPostsSaveCreate200";
export * from "./apiSocialPostsSavedListParams";
export * from "./apiZasilkovnaShipmentsListParams"; export * from "./apiZasilkovnaShipmentsListParams";
export * from "./authorMinimal"; export * from "./authorMinimal";
export * from "./callback"; export * from "./callback";

View File

@@ -23,4 +23,6 @@ export interface PatchedPost {
readonly vote_score?: string; readonly vote_score?: string;
readonly user_vote?: string; readonly user_vote?: string;
readonly reply_count?: number; readonly reply_count?: number;
readonly is_saved?: string;
readonly save_count?: string;
} }

View File

@@ -23,4 +23,6 @@ export interface Post {
readonly vote_score: string; readonly vote_score: string;
readonly user_vote: string; readonly user_vote: string;
readonly reply_count: number; readonly reply_count: number;
readonly is_saved: string;
readonly save_count: string;
} }

View File

@@ -23,6 +23,8 @@ import type {
ApiSocialPostsFeedListParams, ApiSocialPostsFeedListParams,
ApiSocialPostsListParams, ApiSocialPostsListParams,
ApiSocialPostsMediaCreateBody, ApiSocialPostsMediaCreateBody,
ApiSocialPostsSaveCreate200,
ApiSocialPostsSavedListParams,
PaginatedPostList, PaginatedPostList,
PatchedPost, PatchedPost,
Post, Post,
@@ -806,6 +808,94 @@ export const useApiSocialPostsMediaCreate = <
queryClient, 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<Post>,
signal?: AbortSignal,
) => {
return privateMutator<ApiSocialPostsSaveCreate200>({
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<ReturnType<typeof apiSocialPostsSaveCreate>>,
TError,
{ id: number; data: NonReadonly<Post> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof apiSocialPostsSaveCreate>>,
TError,
{ id: number; data: NonReadonly<Post> },
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<ReturnType<typeof apiSocialPostsSaveCreate>>,
{ id: number; data: NonReadonly<Post> }
> = (props) => {
const { id, data } = props ?? {};
return apiSocialPostsSaveCreate(id, data);
};
return { mutationFn, ...mutationOptions };
};
export type ApiSocialPostsSaveCreateMutationResult = NonNullable<
Awaited<ReturnType<typeof apiSocialPostsSaveCreate>>
>;
export type ApiSocialPostsSaveCreateMutationBody = NonReadonly<Post>;
export type ApiSocialPostsSaveCreateMutationError = unknown;
/**
* @summary Toggle save on a post
*/
export const useApiSocialPostsSaveCreate = <
TError = unknown,
TContext = unknown,
>(
options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof apiSocialPostsSaveCreate>>,
TError,
{ id: number; data: NonReadonly<Post> },
TContext
>;
},
queryClient?: QueryClient,
): UseMutationResult<
Awaited<ReturnType<typeof apiSocialPostsSaveCreate>>,
TError,
{ id: number; data: NonReadonly<Post> },
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. * 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 * @summary Attach a tag to a post
@@ -1229,3 +1319,162 @@ export function useApiSocialPostsFeedList<
return { ...query, queryKey: queryOptions.queryKey }; return { ...query, queryKey: queryOptions.queryKey };
} }
/**
* @summary List posts saved by the current user
*/
export const apiSocialPostsSavedList = (
params?: ApiSocialPostsSavedListParams,
signal?: AbortSignal,
) => {
return privateMutator<PaginatedPostList>({
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<ReturnType<typeof apiSocialPostsSavedList>>,
TError = unknown,
>(
params?: ApiSocialPostsSavedListParams,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof apiSocialPostsSavedList>>,
TError,
TData
>
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getApiSocialPostsSavedListQueryKey(params);
const queryFn: QueryFunction<
Awaited<ReturnType<typeof apiSocialPostsSavedList>>
> = ({ signal }) => apiSocialPostsSavedList(params, signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof apiSocialPostsSavedList>>,
TError,
TData
> & { queryKey: DataTag<QueryKey, TData, TError> };
};
export type ApiSocialPostsSavedListQueryResult = NonNullable<
Awaited<ReturnType<typeof apiSocialPostsSavedList>>
>;
export type ApiSocialPostsSavedListQueryError = unknown;
export function useApiSocialPostsSavedList<
TData = Awaited<ReturnType<typeof apiSocialPostsSavedList>>,
TError = unknown,
>(
params: undefined | ApiSocialPostsSavedListParams,
options: {
query: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof apiSocialPostsSavedList>>,
TError,
TData
>
> &
Pick<
DefinedInitialDataOptions<
Awaited<ReturnType<typeof apiSocialPostsSavedList>>,
TError,
Awaited<ReturnType<typeof apiSocialPostsSavedList>>
>,
"initialData"
>;
},
queryClient?: QueryClient,
): DefinedUseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
export function useApiSocialPostsSavedList<
TData = Awaited<ReturnType<typeof apiSocialPostsSavedList>>,
TError = unknown,
>(
params?: ApiSocialPostsSavedListParams,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof apiSocialPostsSavedList>>,
TError,
TData
>
> &
Pick<
UndefinedInitialDataOptions<
Awaited<ReturnType<typeof apiSocialPostsSavedList>>,
TError,
Awaited<ReturnType<typeof apiSocialPostsSavedList>>
>,
"initialData"
>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
export function useApiSocialPostsSavedList<
TData = Awaited<ReturnType<typeof apiSocialPostsSavedList>>,
TError = unknown,
>(
params?: ApiSocialPostsSavedListParams,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof apiSocialPostsSavedList>>,
TError,
TData
>
>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
/**
* @summary List posts saved by the current user
*/
export function useApiSocialPostsSavedList<
TData = Awaited<ReturnType<typeof apiSocialPostsSavedList>>,
TError = unknown,
>(
params?: ApiSocialPostsSavedListParams,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof apiSocialPostsSavedList>>,
TError,
TData
>
>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
} {
const queryOptions = getApiSocialPostsSavedListQueryOptions(params, options);
const query = useQuery(queryOptions, queryClient) as UseQueryResult<
TData,
TError
> & { queryKey: DataTag<QueryKey, TData, TError> };
return { ...query, queryKey: queryOptions.queryKey };
}

View File

@@ -23,4 +23,6 @@ export interface PatchedPost {
readonly vote_score?: string; readonly vote_score?: string;
readonly user_vote?: string; readonly user_vote?: string;
readonly reply_count?: number; readonly reply_count?: number;
readonly is_saved?: string;
readonly save_count?: string;
} }

View File

@@ -23,4 +23,6 @@ export interface Post {
readonly vote_score: string; readonly vote_score: string;
readonly user_vote: string; readonly user_vote: string;
readonly reply_count: number; readonly reply_count: number;
readonly is_saved: string;
readonly save_count: string;
} }

View File

@@ -1,31 +1,89 @@
import axios, { type AxiosRequestConfig } from "axios"; 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({ 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) let isRefreshing = false;
try { let failedQueue: Array<{
const getEnv = new Function('return import.meta.env.VITE_BACKEND_URL'); resolve: (value?: unknown) => void;
privateApi.defaults.baseURL = getEnv() || "http://localhost:8000"; reject: (reason?: any) => void;
} catch { }> = [];
privateApi.defaults.baseURL = "http://localhost:8000";
} const processQueue = (error: any = null) => {
failedQueue.forEach((promise) => {
if (error) {
promise.reject(error);
} else {
promise.resolve();
}
});
failedQueue = [];
};
privateApi.interceptors.response.use( privateApi.interceptors.response.use(
(res) => res, (res) => res,
async (error) => { async (error) => {
const original = error.config; 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 (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; original._retry = true;
isRefreshing = true;
try { try {
await privateApi.post("/api/account/token/refresh/"); await privateApi.post("/api/account/token/refresh/");
processQueue();
return privateApi(original); return privateApi(original);
} catch { } catch (refreshError) {
// optional: logout processQueue(refreshError);
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
} }
} }
@@ -33,15 +91,10 @@ privateApi.interceptors.response.use(
} }
); );
export const privateMutator = async <T>(config: AxiosRequestConfig): Promise<T> => {
export const privateMutator = async <T>(
config: AxiosRequestConfig
): Promise<T> => {
// If sending FormData, remove Content-Type header to let axios set it with boundary
if (config.data instanceof FormData) { if (config.data instanceof FormData) {
delete config.headers?.['Content-Type']; delete config.headers?.['Content-Type'];
} }
const response = await privateApi.request<T>(config); const response = await privateApi.request<T>(config);
return response.data; return response.data;
}; };

View File

@@ -1,23 +1,46 @@
import axios, { type AxiosRequestConfig } from "axios"; 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({ 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) publicApi.interceptors.response.use(
try { (res) => res,
const getEnv = new Function('return import.meta.env.VITE_BACKEND_URL'); (error) => {
publicApi.defaults.baseURL = getEnv() || "http://localhost:8000"; const url = error.config?.url ?? '';
} catch { if (
publicApi.defaults.baseURL = "http://localhost:8000"; 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 <T>(config: AxiosRequestConfig): Promise<T> => {
export const publicMutator = async <T>( const requestKey = `${config.method}_${config.url}_${JSON.stringify(config.data || '')}`;
config: AxiosRequestConfig
): Promise<T> => { if (pendingRequests.has(requestKey)) {
const response = await publicApi.request<T>(config); return pendingRequests.get(requestKey);
return response.data; }
};
const requestPromise = (async () => {
try {
const response = await publicApi.request<T>(config);
return response.data;
} finally {
pendingRequests.delete(requestKey);
}
})();
pendingRequests.set(requestKey, requestPromise);
return requestPromise;
};

View File

@@ -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 type { PostContent } from "@/api/generated/private/models/postContent";
import { mediaUrl } from "@/utils/mediaUrl";
interface Props { interface Props {
items: readonly PostContent[]; items: readonly PostContent[];
} }
export default function MediaGallery({ items }: Props) { export default function MediaGallery({ items }: Props) {
const [lightboxIndex, setLightboxIndex] = useState<number | null>(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; if (!items?.length) return null;
const layoutClass = const layoutClass = visible.length === 1 ? "grid-cols-1" : "grid-cols-2";
items.length === 1
? "grid-cols-1"
: items.length === 2
? "grid-cols-2"
: "grid-cols-2";
return ( return (
<div className={`mt-3 grid ${layoutClass} gap-2 overflow-hidden rounded-xl border border-brand-lines/15`}> <>
{items.map((it) => ( <div className={`mt-3 grid ${layoutClass} overflow-hidden rounded-xl border border-brand-lines/15`}>
<MediaItem key={it.id} item={it} /> {visible.map((it, i) => (
))} <MediaItem
</div> key={it.id}
item={it}
overflowCount={i === 3 && overflow > 0 ? overflow : 0}
onOpen={() => setLightboxIndex(i)}
/>
))}
</div>
{isOpen &&
createPortal(
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm"
onClick={close}
>
{/* Close */}
<button
type="button"
onClick={close}
className="absolute right-4 top-4 flex h-9 w-9 items-center justify-center rounded-full bg-white/10 text-white hover:bg-white/20 transition-colors"
aria-label="Zavřít"
>
<FiX size={20} />
</button>
{/* Prev */}
{items.length > 1 && (
<button
type="button"
onClick={(e) => { e.stopPropagation(); prev(); }}
className="absolute left-4 flex h-10 w-10 items-center justify-center rounded-full bg-white/10 text-white hover:bg-white/20 transition-colors"
aria-label="Předchozí"
>
<FiChevronLeft size={24} />
</button>
)}
{/* Content */}
<LightboxContent item={items[lightboxIndex!]} />
{/* Next */}
{items.length > 1 && (
<button
type="button"
onClick={(e) => { e.stopPropagation(); next(); }}
className="absolute right-4 flex h-10 w-10 items-center justify-center rounded-full bg-white/10 text-white hover:bg-white/20 transition-colors"
aria-label="Další"
>
<FiChevronRight size={24} />
</button>
)}
{/* Dots */}
{items.length > 1 && (
<div
className="absolute bottom-5 flex items-center gap-2"
onClick={(e) => e.stopPropagation()}
>
{items.map((_: PostContent, i: number) => (
<button
key={i}
type="button"
onClick={() => setLightboxIndex(i)}
aria-label={`Položka ${i + 1}`}
className={[
"h-2 rounded-full transition-all duration-200",
i === lightboxIndex ? "w-5 bg-white" : "w-2 bg-white/40 hover:bg-white/70",
].join(" ")}
/>
))}
</div>
)}
</div>,
document.body,
)}
</>
); );
} }
function MediaItem({ item }: { item: PostContent }) { function LightboxContent({ item }: { item: PostContent }) {
const url = item.file ?? ""; const url = mediaUrl(item.file) ?? "";
const mime = item.mime_type ?? ""; const mime = item.mime_type ?? "";
if (mime.startsWith("video/")) { if (mime.startsWith("video/")) {
return ( return (
<video <video
src={url} src={url}
controls controls
className="w-full max-h-[480px] object-cover bg-black" className="max-h-[90vh] max-w-[90vw] rounded-xl shadow-2xl"
onClick={(e) => e.stopPropagation()}
/> />
); );
} }
if (mime.startsWith("image/")) {
return (
<img
src={url}
alt={item.alt_text ?? ""}
className="max-h-[90vh] max-w-[90vw] rounded-xl object-contain shadow-2xl select-none"
onClick={(e) => e.stopPropagation()}
draggable={false}
/>
);
}
const filename = url.split("/").pop() ?? "soubor";
return ( return (
<img <a
src={url} href={url}
alt={item.alt_text ?? ""} download
className="w-full max-h-[480px] object-cover bg-brand-bg/60" target="_blank"
loading="lazy" rel="noreferrer"
/> onClick={(e) => e.stopPropagation()}
className="flex flex-col items-center gap-4 rounded-2xl border border-white/20 bg-white/10 px-10 py-8 text-white hover:bg-white/20 transition-colors"
>
<FiFile size={48} />
<span className="max-w-xs truncate text-sm">{filename}</span>
<FiDownload size={20} className="opacity-70" />
</a>
);
}
function MediaItem({
item,
onOpen,
overflowCount = 0,
}: {
item: PostContent;
onOpen: () => void;
overflowCount?: number;
}) {
const url = mediaUrl(item.file) ?? "";
const mime = item.mime_type ?? "";
const overlay = overflowCount > 0 ? (
<div
className="absolute inset-0 flex items-center justify-center bg-black/55 cursor-pointer"
onClick={(e) => { e.stopPropagation(); onOpen(); }}
>
<span className="text-3xl font-semibold text-white">+{overflowCount}</span>
</div>
) : null;
if (mime.startsWith("video/")) {
return (
<div className="relative aspect-square w-full bg-black cursor-pointer" onClick={(e) => { e.stopPropagation(); onOpen(); }}>
<video src={url} muted playsInline preload="metadata" className="h-full w-full object-cover pointer-events-none" />
<div className="absolute inset-0 flex items-center justify-center bg-black/20">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-white/20">
<svg viewBox="0 0 24 24" fill="white" className="h-5 w-5 translate-x-0.5"><path d="M8 5v14l11-7z"/></svg>
</div>
</div>
{overlay}
</div>
);
}
if (mime.startsWith("image/")) {
return (
<div className="relative aspect-square w-full bg-brand-bg/60">
<img
src={url}
alt={item.alt_text ?? ""}
className="h-full w-full cursor-zoom-in object-cover transition-opacity hover:opacity-90"
loading="lazy"
onClick={(e) => { e.stopPropagation(); onOpen(); }}
/>
{overlay}
</div>
);
}
const filename = url.split("/").pop() ?? "soubor";
return (
<div className="relative aspect-square w-full p-2">
<a
href={url}
download
target="_blank"
rel="noreferrer"
onClick={(e) => e.stopPropagation()}
className="flex h-full w-full flex-col items-center justify-center gap-2 rounded-xl border border-brand-lines/20 bg-brand-bgLight/30 px-3 hover:bg-brand-lines/10 transition-colors"
>
<FiFile size={28} className="text-brand-text/40" />
<span className="w-full truncate text-center text-[11px] text-brand-text/60 leading-tight">{filename}</span>
<FiDownload size={13} className="text-brand-text/30" />
</a>
{overlay}
</div>
); );
} }

View File

@@ -1,5 +1,6 @@
import { Link, useNavigate } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useRef, useState, useEffect } from "react";
import { FiTrash2, FiMoreHorizontal } from "react-icons/fi"; import { FiTrash2, FiMoreHorizontal } from "react-icons/fi";
import type { Post } from "@/api/generated/private/models/post"; import type { Post } from "@/api/generated/private/models/post";
import Avatar from "@/components/ui/Avatar"; import Avatar from "@/components/ui/Avatar";
@@ -21,11 +22,13 @@ interface AuthorDetail {
avatar: string | null; avatar: string | null;
} }
type EnrichedPost = Post & { type EnrichedPost = Omit<Post, 'vote_score' | 'user_vote' | 'is_saved' | 'save_count'> & {
author_detail?: AuthorDetail; author_detail?: AuthorDetail;
vote_score?: number; vote_score?: number;
user_vote?: -1 | 0 | 1; user_vote?: -1 | 0 | 1;
reply_count?: number; reply_count?: number;
is_saved?: boolean;
save_count?: number;
}; };
interface Props { interface Props {
@@ -66,12 +69,26 @@ export default function Post({
function open(e: React.MouseEvent) { function open(e: React.MouseEvent) {
if (!clickable) return; if (!clickable) return;
const target = e.target as HTMLElement; const target = e.target as HTMLElement;
if (target.closest("button, a")) return; if (target.closest("button, a, [role='button']")) return;
navigate(`/social/post/${post.id}`); navigate(`/social/post/${post.id}`);
} }
const canDelete = canDeletePost(user, post); const canDelete = canDeletePost(user, post as unknown as Post);
const canEdit = canEditPost(user, post); const canEdit = canEditPost(user, post as unknown as Post);
const [menuOpen, setMenuOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!menuOpen) return;
function onClickOutside(e: MouseEvent) {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setMenuOpen(false);
}
}
document.addEventListener("mousedown", onClickOutside);
return () => document.removeEventListener("mousedown", onClickOutside);
}, [menuOpen]);
return ( return (
<article <article
@@ -84,7 +101,7 @@ export default function Post({
> >
<div className="flex gap-3"> <div className="flex gap-3">
<Link <Link
to={`/social/profile/${author?.id ?? post.author}`} to={`/social/profile/${author?.username ?? post.author}`}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
className="shrink-0" className="shrink-0"
> >
@@ -99,7 +116,7 @@ export default function Post({
<header className="flex items-start justify-between gap-2"> <header className="flex items-start justify-between gap-2">
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-0 text-sm"> <div className="flex flex-wrap items-baseline gap-x-2 gap-y-0 text-sm">
<Link <Link
to={`/social/profile/${author?.id ?? post.author}`} to={`/social/profile/${author?.username ?? post.author}`}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
className="font-semibold text-brand-text hover:underline" className="font-semibold text-brand-text hover:underline"
> >
@@ -121,19 +138,25 @@ export default function Post({
</div> </div>
{(canDelete || canEdit) && ( {(canDelete || canEdit) && (
<div className="flex items-center gap-1"> <div ref={menuRef} className="relative">
{canDelete && ( <IconButton
<IconButton icon={<FiMoreHorizontal size={14} />}
icon={<FiTrash2 size={14} />} label={t("post.actions.more")}
label={t("post.actions.delete")} onClick={(e) => { e.stopPropagation(); setMenuOpen((o) => !o); }}
onClick={handleDelete} />
/> {menuOpen && (
)} <div className="absolute right-0 top-full z-20 mt-1 min-w-[140px] overflow-hidden rounded-xl border border-brand-lines/20 bg-brand-bg shadow-lg">
{canEdit && ( {canDelete && (
<IconButton <button
icon={<FiMoreHorizontal size={14} />} type="button"
label={t("post.actions.more")} onClick={handleDelete}
/> className="flex w-full items-center gap-2 px-3 py-2 text-sm text-red-400 hover:bg-brand-lines/10 transition-colors"
>
<FiTrash2 size={14} />
{t("post.actions.delete")}
</button>
)}
</div>
)} )}
</div> </div>
)} )}
@@ -167,6 +190,8 @@ export default function Post({
replyCount={post.reply_count} replyCount={post.reply_count}
voteScore={post.vote_score} voteScore={post.vote_score}
initialUserVote={post.user_vote} initialUserVote={post.user_vote}
initialIsSaved={post.is_saved}
initialSaveCount={post.save_count}
onReplyClick={onReplyClick} onReplyClick={onReplyClick}
/> />
)} )}

View File

@@ -1,8 +1,9 @@
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { FiMessageSquare, FiArrowUp, FiArrowDown, FiShare2 } from "react-icons/fi"; import { FiMessageSquare, FiArrowUp, FiArrowDown, FiShare2, FiBookmark } from "react-icons/fi";
import IconButton from "@/components/ui/IconButton"; import IconButton from "@/components/ui/IconButton";
import { apiSocialPostsVoteCreate } from "@/api/generated/private/posts/posts"; import { apiSocialPostsVoteCreate } from "@/api/generated/private/posts/posts";
import { privateApi } from "@/api/privateClient";
import SharePopup from "./SharePopup"; import SharePopup from "./SharePopup";
interface Props { interface Props {
@@ -10,6 +11,8 @@ interface Props {
replyCount?: number; replyCount?: number;
voteScore?: number; voteScore?: number;
initialUserVote?: -1 | 0 | 1; initialUserVote?: -1 | 0 | 1;
initialIsSaved?: boolean;
initialSaveCount?: number;
onReplyClick?: () => void; onReplyClick?: () => void;
} }
@@ -18,6 +21,8 @@ export default function PostActions({
replyCount, replyCount,
voteScore, voteScore,
initialUserVote, initialUserVote,
initialIsSaved,
initialSaveCount,
onReplyClick, onReplyClick,
}: Props) { }: Props) {
const { t } = useTranslation("social"); const { t } = useTranslation("social");
@@ -27,6 +32,8 @@ export default function PostActions({
const [showShare, setShowShare] = useState(false); const [showShare, setShowShare] = useState(false);
const [upHover, setUpHover] = useState(false); const [upHover, setUpHover] = useState(false);
const [downHover, setDownHover] = useState(false); const [downHover, setDownHover] = useState(false);
const [saved, setSaved] = useState(initialIsSaved ?? false);
const [saveCount, setSaveCount] = useState(initialSaveCount ?? 0);
async function castVote(value: 1 | -1) { async function castVote(value: 1 | -1) {
if (busy) return; if (busy) return;
@@ -42,6 +49,18 @@ export default function PostActions({
} }
} }
async function toggleSave() {
const next = !saved;
setSaved(next);
setSaveCount((c) => c + (next ? 1 : -1));
try {
await privateApi.post(`/api/social/posts/${postId}/save/`);
} catch {
setSaved(!next);
setSaveCount((c) => c + (next ? -1 : 1));
}
}
const upActive = vote === 1; const upActive = vote === 1;
const downActive = vote === -1; const downActive = vote === -1;
@@ -52,7 +71,7 @@ export default function PostActions({
<button <button
type="button" type="button"
onClick={onReplyClick} onClick={onReplyClick}
className="inline-flex items-center gap-1.5 rounded-full px-2 py-1 text-sm hover:bg-brand-lines/10 hover:text-brand-accent transition-colors" className="inline-flex items-center gap-1.5 rounded-full px-2.5 py-2 text-sm hover:bg-brand-lines/10 hover:text-brand-accent transition-colors"
aria-label={t("post.actions.reply")} aria-label={t("post.actions.reply")}
title={t("post.actions.reply")} title={t("post.actions.reply")}
> >
@@ -64,28 +83,31 @@ export default function PostActions({
{/* Vote pill */} {/* Vote pill */}
<div className="inline-flex items-center overflow-hidden rounded-full border border-brand-lines/20"> <div className="inline-flex items-center overflow-hidden rounded-full border border-brand-lines/20">
{/* Upvote — pseudo-element carries the gradient so opacity can transition */} {/* Upvote */}
<button <div
type="button" role="button"
disabled={busy} tabIndex={0}
onClick={() => castVote(1)} aria-label={t("post.actions.upvote")}
aria-disabled={busy}
onClick={() => !busy && castVote(1)}
onKeyDown={(e) => e.key === "Enter" && !busy && castVote(1)}
onMouseEnter={() => setUpHover(true)} onMouseEnter={() => setUpHover(true)}
onMouseLeave={() => setUpHover(false)} onMouseLeave={() => setUpHover(false)}
aria-label={t("post.actions.upvote")}
title={t("post.actions.upvote")}
style={{ borderTopRightRadius: 0, borderBottomRightRadius: 0 }}
className={[ className={[
"relative flex items-center justify-center px-2.5 py-1.5 disabled:opacity-50", "relative flex cursor-pointer items-center justify-center px-2.5 py-2 select-none",
busy ? "opacity-50 pointer-events-none" : "",
"before:absolute before:inset-0 before:bg-gradient-to-br before:from-white/90 before:to-sky-200/60 before:transition-opacity before:duration-200", "before:absolute before:inset-0 before:bg-gradient-to-br before:from-white/90 before:to-sky-200/60 before:transition-opacity before:duration-200",
upActive ? "before:opacity-100" : upHover ? "before:opacity-40" : "before:opacity-0", upActive ? "before:opacity-100" : upHover ? "before:opacity-40" : "before:opacity-0",
].join(" ")} ].join(" ")}
> >
<FiArrowUp <FiArrowUp
size={15} size={16}
style={{ color: upActive ? "rgb(12 74 110)" : undefined }} style={{ color: upActive ? "rgb(12 74 110)" : undefined }}
className="relative z-10 text-brand-text/50" className="relative z-10 text-brand-text/50"
/> />
</button> </div>
<div className="w-px self-stretch bg-brand-lines/20" />
<span <span
className={[ className={[
@@ -96,30 +118,48 @@ export default function PostActions({
{score} {score}
</span> </span>
{/* Downvote — same pseudo-element trick */} <div className="w-px self-stretch bg-brand-lines/20" />
<button
type="button" {/* Downvote */}
disabled={busy} <div
onClick={() => castVote(-1)} role="button"
tabIndex={0}
aria-label={t("post.actions.downvote")}
aria-disabled={busy}
onClick={() => !busy && castVote(-1)}
onKeyDown={(e) => e.key === "Enter" && !busy && castVote(-1)}
onMouseEnter={() => setDownHover(true)} onMouseEnter={() => setDownHover(true)}
onMouseLeave={() => setDownHover(false)} onMouseLeave={() => setDownHover(false)}
aria-label={t("post.actions.downvote")}
title={t("post.actions.downvote")}
style={{ borderTopLeftRadius: 0, borderBottomLeftRadius: 0 }}
className={[ className={[
"relative flex items-center justify-center rounded-r-full rounded-l-none border-none px-2.5 py-1.5 disabled:opacity-50", "relative flex cursor-pointer items-center justify-center px-2.5 py-2 select-none",
busy ? "opacity-50 pointer-events-none" : "",
"before:absolute before:inset-0 before:bg-gradient-to-br before:from-white/90 before:to-sky-200/60 before:transition-opacity before:duration-200", "before:absolute before:inset-0 before:bg-gradient-to-br before:from-white/90 before:to-sky-200/60 before:transition-opacity before:duration-200",
downActive ? "before:opacity-100" : downHover ? "before:opacity-40" : "before:opacity-0", downActive ? "before:opacity-100" : downHover ? "before:opacity-40" : "before:opacity-0",
].join(" ")} ].join(" ")}
> >
<FiArrowDown <FiArrowDown
size={15} size={16}
style={{ color: downActive ? "rgb(12 74 110)" : undefined }} style={{ color: downActive ? "rgb(12 74 110)" : undefined }}
className="relative z-10 text-brand-text/50" className="relative z-10 text-brand-text/50"
/> />
</button> </div>
</div> </div>
{/* Save */}
<button
type="button"
onClick={toggleSave}
className={[
"inline-flex items-center gap-1.5 rounded-full px-2.5 py-2 text-sm transition-colors hover:bg-brand-lines/10",
saved ? "text-brand-accent" : "hover:text-brand-accent",
].join(" ")}
aria-label={t("post.actions.save")}
title={t("post.actions.save")}
>
<FiBookmark size={16} fill={saved ? "currentColor" : "none"} />
{saveCount > 0 && <span className="text-xs tabular-nums">{saveCount}</span>}
</button>
{/* Share */} {/* Share */}
<IconButton <IconButton
icon={<FiShare2 size={16} />} icon={<FiShare2 size={16} />}

View File

@@ -1,7 +1,7 @@
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { FiSend, FiImage, FiX } from "react-icons/fi"; import { FiSend, FiPaperclip, FiX, FiPlay, FiFile } from "react-icons/fi";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import Textarea from "@/components/ui/Textarea"; import Textarea from "@/components/ui/Textarea";
import Button from "@/components/ui/Button"; import Button from "@/components/ui/Button";
@@ -9,36 +9,32 @@ import Spinner from "@/components/ui/Spinner";
import FormErrorBanner from "@/components/ui/FormErrorBanner"; import FormErrorBanner from "@/components/ui/FormErrorBanner";
import { applyServerErrors } from "@/utils/formErrors"; import { applyServerErrors } from "@/utils/formErrors";
import { apiSocialPostsCreate } from "@/api/generated/private/posts/posts"; import { apiSocialPostsCreate } from "@/api/generated/private/posts/posts";
import { useApiSocialHubsList } from "@/api/generated/private/hubs/hubs";
import { privateApi } from "@/api/privateClient"; import { privateApi } from "@/api/privateClient";
interface Props { interface Props {
parentId?: number; parentId?: number;
defaultHubId?: number | null; hubId?: number | null;
onPosted?: () => void; onPosted?: () => void;
} }
interface ComposerForm { interface ComposerForm {
content: string; content: string;
hub: number | null;
} }
export default function PostComposer({ parentId, defaultHubId, onPosted }: Props) { export default function PostComposer({ parentId, hubId, onPosted }: Props) {
const { t } = useTranslation("social"); const { t } = useTranslation("social");
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { data: hubsData } = useApiSocialHubsList(undefined);
const [rootError, setRootError] = useState<string | undefined>(); const [rootError, setRootError] = useState<string | undefined>();
const [files, setFiles] = useState<File[]>([]); const [files, setFiles] = useState<File[]>([]);
const [previews, setPreviews] = useState<string[]>([]); const [previews, setPreviews] = useState<string[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const form = useForm<ComposerForm>({ const form = useForm<ComposerForm>({
defaultValues: { content: "", hub: defaultHubId ?? null }, defaultValues: { content: "" },
}); });
const { register, handleSubmit, formState, reset, watch, clearErrors } = form; const { register, handleSubmit, formState, reset, watch, clearErrors } = form;
const { errors, isSubmitting } = formState; const { errors, isSubmitting } = formState;
const hubs = hubsData?.results ?? [];
const content = watch("content"); const content = watch("content");
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) { function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
@@ -61,7 +57,7 @@ export default function PostComposer({ parentId, defaultHubId, onPosted }: Props
try { try {
const created = await apiSocialPostsCreate({ const created = await apiSocialPostsCreate({
content: values.content, content: values.content,
hub: values.hub ?? null, hub: hubId ?? null,
reply_to: parentId ?? null, reply_to: parentId ?? null,
} as Parameters<typeof apiSocialPostsCreate>[0]); } as Parameters<typeof apiSocialPostsCreate>[0]);
@@ -75,7 +71,7 @@ export default function PostComposer({ parentId, defaultHubId, onPosted }: Props
previews.forEach((url) => URL.revokeObjectURL(url)); previews.forEach((url) => URL.revokeObjectURL(url));
setFiles([]); setFiles([]);
setPreviews([]); setPreviews([]);
reset({ content: "", hub: defaultHubId ?? null }); reset({ content: "" });
await queryClient.invalidateQueries({ queryKey: ["social", "posts"] }); await queryClient.invalidateQueries({ queryKey: ["social", "posts"] });
onPosted?.(); onPosted?.();
} catch (err) { } catch (err) {
@@ -111,49 +107,58 @@ export default function PostComposer({ parentId, defaultHubId, onPosted }: Props
})} })}
/> />
{/* Image previews */} {/* File previews */}
{previews.length > 0 && ( {previews.length > 0 && (
<div className="mt-2 flex flex-wrap gap-2"> <div className="mt-2 flex flex-wrap gap-2">
{previews.map((src, i) => ( {previews.map((src, i) => {
<div key={src} className="relative"> const file = files[i];
<img const isVideo = file?.type.startsWith("video/");
src={src} const isImage = file?.type.startsWith("image/");
alt="" return (
className="h-20 w-20 rounded-xl object-cover border border-brand-lines/20" <div key={src} className="relative">
/> {isVideo ? (
<button <div className="relative h-20 w-20">
type="button" <video
onClick={() => removeFile(i)} src={src}
className="absolute -right-1.5 -top-1.5 flex h-5 w-5 items-center justify-center rounded-full bg-brand-bg border border-brand-lines/30 text-brand-text/70 hover:text-brand-text shadow" className="h-20 w-20 rounded-xl object-cover border border-brand-lines/20 bg-black"
aria-label={t("post.compose.removeImage")} muted
> playsInline
<FiX size={11} /> preload="metadata"
</button> />
</div> <div className="absolute inset-0 flex items-center justify-center rounded-xl bg-black/30 pointer-events-none">
))} <FiPlay size={22} className="text-white drop-shadow" fill="white" />
</div>
</div>
) : isImage ? (
<img
src={src}
alt=""
className="h-20 w-20 rounded-xl object-cover border border-brand-lines/20"
/>
) : (
<div className="flex h-20 w-32 flex-col items-center justify-center gap-1 rounded-xl border border-brand-lines/20 bg-brand-bgLight/40 px-2">
<FiFile size={22} className="text-brand-text/50 shrink-0" />
<span className="w-full truncate text-center text-[10px] text-brand-text/60 leading-tight">
{file?.name}
</span>
</div>
)}
<button
type="button"
onClick={() => removeFile(i)}
className="absolute -right-1.5 -top-1.5 flex h-5 w-5 items-center justify-center rounded-full bg-brand-bg border border-brand-lines/30 text-brand-text/70 hover:text-brand-text shadow"
aria-label={t("post.compose.removeImage")}
>
<FiX size={11} />
</button>
</div>
);
})}
</div> </div>
)} )}
<div className="mt-2 flex items-center justify-between gap-2"> <div className="mt-2 flex items-center justify-between gap-2">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{/* Hub selector — top-level posts only */}
{!parentId && (
<select
disabled={isSubmitting}
className="rounded-xl border border-brand-lines/25 bg-brand-bgLight/40 px-2 py-1.5 text-sm text-brand-text focus:outline-none focus:border-brand-accent"
{...register("hub", {
setValueAs: (v) => (v === "" || v == null ? null : Number(v)),
})}
>
<option value="">{t("post.compose.noHub")}</option>
{hubs.map((h) => (
<option key={h.id} value={h.id}>
{h.name}
</option>
))}
</select>
)}
{/* Image attach button */} {/* Image attach button */}
<button <button
type="button" type="button"
@@ -163,12 +168,12 @@ export default function PostComposer({ parentId, defaultHubId, onPosted }: Props
aria-label={t("post.compose.attachImage")} aria-label={t("post.compose.attachImage")}
title={t("post.compose.attachImage")} title={t("post.compose.attachImage")}
> >
<FiImage size={16} /> <FiPaperclip size={16} />
</button> </button>
<input <input
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
accept="image/*,video/*" accept="*/*"
multiple multiple
className="hidden" className="hidden"
onChange={handleFileChange} onChange={handleFileChange}

View File

@@ -1,3 +1,5 @@
import { mediaUrl } from "@/utils/mediaUrl";
interface Props { interface Props {
name?: string | null; name?: string | null;
src?: string | null; src?: string | null;
@@ -14,10 +16,11 @@ function initialsOf(name?: string | null): string {
export default function Avatar({ name, src, size = 40, className = "" }: Props) { export default function Avatar({ name, src, size = 40, className = "" }: Props) {
const dim = { width: size, height: size }; const dim = { width: size, height: size };
if (src) { const resolvedSrc = mediaUrl(src);
if (resolvedSrc) {
return ( return (
<img <img
src={src} src={resolvedSrc}
alt={name ?? ""} alt={name ?? ""}
style={dim} style={dim}
className={`rounded-full object-cover border border-brand-lines/20 ${className}`} className={`rounded-full object-cover border border-brand-lines/20 ${className}`}

View File

@@ -1,15 +1,15 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { createContext, useContext, useState, useEffect } from "react"; import { createContext, useContext, useState, useEffect, useRef } from "react";
// Import z Orval generovaného API
import { apiAccountLogoutCreate } from "@/api/generated/public/account"; import { apiAccountLogoutCreate } from "@/api/generated/public/account";
import { apiAccountUserMeRetrieve } from "@/api/generated/private/account/account"; import { apiAccountUserMeRetrieve } from "@/api/generated/private/account/account";
import { privateApi } from "@/api/privateClient"; import { privateApi } from "@/api/privateClient";
// Import typů z Orval
import type { CustomTokenObtainPair } from "@/api/generated/public/models/customTokenObtainPair"; import type { CustomTokenObtainPair } from "@/api/generated/public/models/customTokenObtainPair";
import type { CustomUser } from "@/api/generated/private/models/customUser"; import type { CustomUser } from "@/api/generated/private/models/customUser";
export const AUTH_FLAG = "vontor_was_logged_in";
interface AuthContextType { interface AuthContextType {
user: CustomUser | null; user: CustomUser | null;
isAuthenticated: boolean; isAuthenticated: boolean;
@@ -24,47 +24,67 @@ const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: ReactNode }) { export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<CustomUser | null>(null); const [user, setUser] = useState<CustomUser | null>(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const initialized = useRef(false);
const isAuthenticated = !!user; const isAuthenticated = !!user;
// Načíst uživatele při načtení aplikace (pokud má cookies)
useEffect(() => {
refreshUser();
}, []);
async function refreshUser() { async function refreshUser() {
const wasLoggedIn = localStorage.getItem(AUTH_FLAG);
if (!wasLoggedIn) {
setUser(null);
setIsLoading(false);
return;
}
try { try {
const userData = await apiAccountUserMeRetrieve(); const userData = await apiAccountUserMeRetrieve();
setUser(userData); setUser(userData);
} catch (err: any) { } catch {
const errorMessage = err.response?.data?.error || err.message;
console.error("Failed to refresh user:", errorMessage);
setUser(null); setUser(null);
localStorage.removeItem(AUTH_FLAG);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
} }
useEffect(() => {
if (!initialized.current) {
initialized.current = true;
refreshUser();
}
}, []);
async function login(payload: CustomTokenObtainPair) { async function login(payload: CustomTokenObtainPair) {
// Do NOT touch isLoading here — that flag is only for the initial auth setIsLoading(true);
// bootstrap on mount. Toggling it during login causes PublicOnlyRoute to try {
// swap the Login page for a spinner, which unmounts the form and wipes await privateApi.post("/api/account/login/", payload);
// its local error state before the user can see it. localStorage.setItem(AUTH_FLAG, "true");
// The Login page tracks its own submitting state via react-hook-form. await refreshUser();
// Must use privateApi (withCredentials: true) so the browser stores the } catch (err: any) {
// Set-Cookie headers from the login response. publicApi drops them silently. setIsLoading(false);
await privateApi.post("/api/account/login/", payload); const data = err.response?.data;
await refreshUser(); const errorMessage =
data?.detail ||
(typeof data === "object" && !Array.isArray(data)
? Object.values(data).flat().filter(Boolean).join(" ")
: null) ||
err.message ||
"Login failed";
throw new Error(errorMessage);
}
} }
async function logout() { async function logout() {
setIsLoading(true);
try { try {
// Zavolej logout endpoint (smaže cookies na backendu) localStorage.removeItem(AUTH_FLAG);
await apiAccountLogoutCreate(); await apiAccountLogoutCreate();
} catch (err: any) { } catch (err: any) {
console.error("Logout error:", err); console.error("Logout error:", err);
} finally { } finally {
setUser(null); setUser(null);
setIsLoading(false);
} }
} }
@@ -79,4 +99,4 @@ export function useAuth() {
const ctx = useContext(AuthContext); const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used inside AuthProvider"); if (!ctx) throw new Error("useAuth must be used inside AuthProvider");
return ctx; return ctx;
} }

View File

@@ -3,6 +3,7 @@
"feed": "Feed", "feed": "Feed",
"chats": "Zprávy", "chats": "Zprávy",
"hubs": "Huby", "hubs": "Huby",
"saved": "Uložené",
"profile": "Profil", "profile": "Profil",
"logout": "Odhlásit" "logout": "Odhlásit"
}, },
@@ -37,7 +38,8 @@
"more": "Více možností", "more": "Více možností",
"delete": "Smazat", "delete": "Smazat",
"edit": "Upravit", "edit": "Upravit",
"share": "Sdílet" "share": "Sdílet",
"save": "Uložit"
}, },
"thread": { "thread": {
"parents": "Vlákno výše", "parents": "Vlákno výše",

View File

@@ -5,6 +5,7 @@ import {
FiMessageCircle, FiMessageCircle,
FiUsers, FiUsers,
FiUser, FiUser,
FiBookmark,
FiLogOut, FiLogOut,
} from "react-icons/fi"; } from "react-icons/fi";
import { useAuth } from "@/context/AuthContext"; import { useAuth } from "@/context/AuthContext";
@@ -16,13 +17,14 @@ interface NavItem {
labelKey: string; labelKey: string;
} }
function buildItems(userId?: number): NavItem[] { function buildItems(username?: string): NavItem[] {
return [ return [
{ to: "/social/feed", icon: <FiHome size={22} />, labelKey: "nav.feed" }, { to: "/social/feed", icon: <FiHome size={22} />, labelKey: "nav.feed" },
{ to: "/social/chats", icon: <FiMessageCircle size={22} />, labelKey: "nav.chats" }, { to: "/social/chats", icon: <FiMessageCircle size={22} />, labelKey: "nav.chats" },
{ to: "/social/hubs", icon: <FiUsers size={22} />, labelKey: "nav.hubs" }, { to: "/social/hubs", icon: <FiUsers size={22} />, labelKey: "nav.hubs" },
{ to: "/social/saved", icon: <FiBookmark size={22} />, labelKey: "nav.saved" },
{ {
to: userId ? `/social/profile/${userId}` : "/social/profile", to: username ? `/social/profile/${username}` : "/social/feed",
icon: <FiUser size={22} />, icon: <FiUser size={22} />,
labelKey: "nav.profile", labelKey: "nav.profile",
}, },
@@ -32,7 +34,7 @@ function buildItems(userId?: number): NavItem[] {
export default function SocialLayout() { export default function SocialLayout() {
const { t } = useTranslation("social"); const { t } = useTranslation("social");
const { user } = useAuth(); const { user } = useAuth();
const items = buildItems(user?.id); const items = buildItems(user?.username);
return ( return (
<div className="min-h-screen w-full"> <div className="min-h-screen w-full">
@@ -62,7 +64,7 @@ export default function SocialLayout() {
</div> </div>
<div className="flex w-full items-center gap-3 rounded-2xl px-2 py-2 hover:bg-brand-lines/10"> <div className="flex w-full items-center gap-3 rounded-2xl px-2 py-2 hover:bg-brand-lines/10">
<Avatar name={user?.username ?? user?.email ?? "?"} size={36} /> <Avatar name={user?.username ?? user?.email ?? "?"} src={(user as any)?.avatar ?? null} size={36} />
<div className="hidden min-w-0 md:block"> <div className="hidden min-w-0 md:block">
<div className="truncate text-sm font-semibold text-brand-text"> <div className="truncate text-sm font-semibold text-brand-text">
{user?.username ?? "—"} {user?.username ?? "—"}

View File

@@ -0,0 +1,333 @@
import { useState, useRef } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { useForm } from "react-hook-form";
import { useQueryClient } from "@tanstack/react-query";
import { FiArrowLeft, FiUser, FiLock, FiCamera, FiImage } from "react-icons/fi";
import { useAuth } from "@/context/AuthContext";
import { privateApi } from "@/api/privateClient";
import { mediaUrl } from "@/utils/mediaUrl";
import Avatar from "@/components/ui/Avatar";
import Button from "@/components/ui/Button";
import Spinner from "@/components/ui/Spinner";
import FormErrorBanner from "@/components/ui/FormErrorBanner";
import { applyServerErrors } from "@/utils/formErrors";
type Tab = "profile" | "security";
interface ProfileForm {
first_name: string;
last_name: string;
city: string;
phone_number: string;
}
interface PasswordForm {
current_password: string;
new_password: string;
confirm_password: string;
}
export default function AccountSettingsPage() {
const { t } = useTranslation("social");
const { user, refreshUser } = useAuth() as any;
const navigate = useNavigate();
const queryClient = useQueryClient();
const [tab, setTab] = useState<Tab>("profile");
// ── Profile form ──────────────────────────────────────────────
const [profileSuccess, setProfileSuccess] = useState(false);
const [profileRootError, setProfileRootError] = useState<string>();
const profileForm = useForm<ProfileForm>({
defaultValues: {
first_name: user?.first_name ?? "",
last_name: user?.last_name ?? "",
city: user?.city ?? "",
phone_number: user?.phone_number ?? "",
},
});
const { register: regProfile, handleSubmit: handleProfile, formState: { isSubmitting: profileSubmitting }, setError: setProfileError } = profileForm;
async function onProfileSubmit(values: ProfileForm) {
setProfileRootError(undefined);
setProfileSuccess(false);
try {
await privateApi.patch(`/api/account/users/${user.id}/`, values);
setProfileSuccess(true);
await queryClient.invalidateQueries({ queryKey: ["account"] });
if (refreshUser) await refreshUser();
} catch (err) {
setProfileRootError(applyServerErrors(profileForm, err));
}
}
// ── Avatar upload ─────────────────────────────────────────────
const avatarInputRef = useRef<HTMLInputElement>(null);
const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
const [avatarUploading, setAvatarUploading] = useState(false);
async function handleAvatarChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setAvatarPreview(URL.createObjectURL(file));
setAvatarUploading(true);
try {
const fd = new FormData();
fd.append("avatar", file);
await privateApi.patch(`/api/account/users/${user.id}/`, fd);
await queryClient.invalidateQueries({ queryKey: ["account"] });
if (refreshUser) await refreshUser();
} finally {
setAvatarUploading(false);
e.target.value = "";
}
}
// ── Banner upload ─────────────────────────────────────────────
const bannerInputRef = useRef<HTMLInputElement>(null);
const [bannerPreview, setBannerPreview] = useState<string | null>(null);
const [bannerUploading, setBannerUploading] = useState(false);
async function handleBannerChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setBannerPreview(URL.createObjectURL(file));
setBannerUploading(true);
try {
const fd = new FormData();
fd.append("banner", file);
await privateApi.patch(`/api/account/users/${user.id}/`, fd);
await queryClient.invalidateQueries({ queryKey: ["account"] });
if (refreshUser) await refreshUser();
} finally {
setBannerUploading(false);
e.target.value = "";
}
}
// ── Password form ─────────────────────────────────────────────
const [passwordSuccess, setPasswordSuccess] = useState(false);
const [passwordRootError, setPasswordRootError] = useState<string>();
const passwordForm = useForm<PasswordForm>({
defaultValues: { current_password: "", new_password: "", confirm_password: "" },
});
const { register: regPassword, handleSubmit: handlePassword, formState: { isSubmitting: passwordSubmitting }, reset: resetPassword, setError: setPasswordError } = passwordForm;
async function onPasswordSubmit(values: PasswordForm) {
setPasswordRootError(undefined);
setPasswordSuccess(false);
if (values.new_password !== values.confirm_password) {
setPasswordError("confirm_password", { message: "Hesla se neshodují." });
return;
}
try {
await privateApi.post("/api/account/password-change/", {
current_password: values.current_password,
new_password: values.new_password,
});
setPasswordSuccess(true);
resetPassword();
} catch (err) {
setPasswordRootError(applyServerErrors(passwordForm, err));
}
}
const displayName = [user?.first_name, user?.last_name].filter(Boolean).join(" ") || user?.username || "?";
const avatarSrc = avatarPreview ?? mediaUrl((user as any)?.avatar);
const bannerSrc = bannerPreview ?? mediaUrl((user as any)?.banner);
const tabClass = (active: boolean) =>
[
"flex items-center gap-2 rounded-xl px-3 py-2 text-sm font-medium transition-colors",
active ? "bg-brand-lines/15 text-brand-text" : "text-brand-text/60 hover:bg-brand-lines/10 hover:text-brand-text",
].join(" ");
const inputClass = "w-full rounded-xl border border-brand-lines/25 bg-brand-bgLight/40 px-3 py-2 text-sm text-brand-text placeholder:text-brand-text/30 focus:outline-none focus:border-brand-accent disabled:opacity-50";
return (
<div>
<header className="sticky top-0 z-10 flex items-center gap-3 border-b border-brand-lines/10 bg-brand-bg/80 px-4 py-3 backdrop-blur">
<button
type="button"
onClick={() => navigate(-1)}
className="rounded-full p-1 text-brand-text hover:bg-brand-lines/10"
>
<FiArrowLeft size={20} />
</button>
<h1 className="text-lg font-bold text-brand-text">Nastavení účtu</h1>
</header>
<div className="flex gap-0">
{/* Sidebar tabs */}
<nav className="w-[180px] shrink-0 border-r border-brand-lines/10 p-3 flex flex-col gap-1">
<button type="button" className={tabClass(tab === "profile")} onClick={() => setTab("profile")}>
<FiUser size={16} /> Profil
</button>
<button type="button" className={tabClass(tab === "security")} onClick={() => setTab("security")}>
<FiLock size={16} /> Heslo
</button>
</nav>
{/* Content */}
<div className="flex-1 p-6 max-w-lg">
{/* ── Profile tab ── */}
{tab === "profile" && (
<div className="flex flex-col gap-6">
{/* Appearance: banner + avatar */}
<div>
<div className="text-sm font-semibold text-brand-text mb-3">Vzhled</div>
{/* Banner */}
<div className="relative mb-10">
<div
className="group relative h-28 w-full cursor-pointer overflow-hidden rounded-2xl bg-gradient-to-br from-brand-bgLight to-brand-lines/20"
onClick={() => bannerInputRef.current?.click()}
>
{bannerSrc && (
<img src={bannerSrc} alt="" className="h-full w-full object-cover" />
)}
<div className="absolute inset-0 flex items-center justify-center bg-black/0 transition-colors group-hover:bg-black/40">
{bannerUploading ? (
<Spinner size={22} />
) : (
<div className="flex flex-col items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<FiImage size={20} className="text-white" />
<span className="text-xs text-white/90">Změnit banner</span>
</div>
)}
</div>
</div>
<input ref={bannerInputRef} type="file" accept="image/*" className="hidden" onChange={handleBannerChange} />
{/* Avatar overlapping banner bottom-left */}
<div className="absolute -bottom-8 left-4">
<div className="relative rounded-full ring-4 ring-brand-bg">
<Avatar name={displayName} src={avatarSrc} size={64} />
{avatarUploading && (
<div className="absolute inset-0 flex items-center justify-center rounded-full bg-black/50">
<Spinner size={16} />
</div>
)}
<button
type="button"
onClick={() => avatarInputRef.current?.click()}
className="absolute -bottom-1 -right-1 flex h-6 w-6 items-center justify-center rounded-full bg-brand-accent text-white shadow hover:opacity-90 transition-opacity"
>
<FiCamera size={11} />
</button>
</div>
<input ref={avatarInputRef} type="file" accept="image/*" className="hidden" onChange={handleAvatarChange} />
</div>
</div>
<p className="text-xs text-brand-text/40">JPG, PNG nebo WebP · Banner max. 5 MB · Avatar max. 5 MB</p>
</div>
{/* Profile form */}
<form onSubmit={handleProfile(onProfileSubmit)} className="flex flex-col gap-4">
<FormErrorBanner message={profileRootError} />
{profileSuccess && (
<div className="rounded-xl bg-green-500/10 border border-green-500/20 px-3 py-2 text-sm text-green-400">
Profil byl uložen.
</div>
)}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="mb-1 block text-xs font-medium text-brand-text/70">Jméno</label>
<input className={inputClass} placeholder="Jméno" {...regProfile("first_name")} />
</div>
<div>
<label className="mb-1 block text-xs font-medium text-brand-text/70">Příjmení</label>
<input className={inputClass} placeholder="Příjmení" {...regProfile("last_name")} />
</div>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-brand-text/70">Uživatelské jméno</label>
<input className={inputClass + " opacity-50 cursor-not-allowed"} value={user?.username ?? ""} readOnly disabled />
</div>
<div>
<label className="mb-1 block text-xs font-medium text-brand-text/70">E-mail</label>
<input className={inputClass + " opacity-50 cursor-not-allowed"} value={user?.email ?? ""} readOnly disabled />
</div>
<div>
<label className="mb-1 block text-xs font-medium text-brand-text/70">Město</label>
<input className={inputClass} placeholder="Vaše město" {...regProfile("city")} />
</div>
<div>
<label className="mb-1 block text-xs font-medium text-brand-text/70">Telefon</label>
<input className={inputClass} placeholder="+420 ..." {...regProfile("phone_number")} />
</div>
<Button type="submit" disabled={profileSubmitting} leftIcon={profileSubmitting ? <Spinner size={14} /> : undefined}>
{profileSubmitting ? "Ukládání…" : "Uložit profil"}
</Button>
</form>
</div>
)}
{/* ── Security tab ── */}
{tab === "security" && (
<form onSubmit={handlePassword(onPasswordSubmit)} className="flex flex-col gap-4">
<div className="text-sm font-semibold text-brand-text">Změna hesla</div>
<FormErrorBanner message={passwordRootError} />
{passwordSuccess && (
<div className="rounded-xl bg-green-500/10 border border-green-500/20 px-3 py-2 text-sm text-green-400">
Heslo bylo změněno.
</div>
)}
<div>
<label className="mb-1 block text-xs font-medium text-brand-text/70">Stávající heslo</label>
<input
type="password"
className={inputClass}
placeholder="••••••••"
{...regPassword("current_password", { required: "Povinné pole." })}
/>
{passwordForm.formState.errors.current_password && (
<p className="mt-1 text-xs text-red-400">{passwordForm.formState.errors.current_password.message}</p>
)}
</div>
<div>
<label className="mb-1 block text-xs font-medium text-brand-text/70">Nové heslo</label>
<input
type="password"
className={inputClass}
placeholder="••••••••"
{...regPassword("new_password", { required: "Povinné pole." })}
/>
{passwordForm.formState.errors.new_password && (
<p className="mt-1 text-xs text-red-400">{passwordForm.formState.errors.new_password.message}</p>
)}
</div>
<div>
<label className="mb-1 block text-xs font-medium text-brand-text/70">Potvrdit nové heslo</label>
<input
type="password"
className={inputClass}
placeholder="••••••••"
{...regPassword("confirm_password", { required: "Povinné pole." })}
/>
{passwordForm.formState.errors.confirm_password && (
<p className="mt-1 text-xs text-red-400">{passwordForm.formState.errors.confirm_password.message}</p>
)}
</div>
<Button type="submit" disabled={passwordSubmitting} leftIcon={passwordSubmitting ? <Spinner size={14} /> : undefined}>
{passwordSubmitting ? "Ukládání…" : "Změnit heslo"}
</Button>
</form>
)}
</div>
</div>
</div>
);
}

View File

@@ -41,7 +41,7 @@ export default function ProfilePage() {
to="/social/logout" to="/social/logout"
className="inline-flex items-center gap-2 rounded-xl border border-brand-lines/20 px-3 py-2 text-sm text-brand-text/80 hover:bg-brand-lines/10 hover:text-brand-accent" className="inline-flex items-center gap-2 rounded-xl border border-brand-lines/20 px-3 py-2 text-sm text-brand-text/80 hover:bg-brand-lines/10 hover:text-brand-accent"
> >
<FiLogOut size={14} /> {t("nav.logout")} <FiLogOut size={16} /> {t("nav.logout")}
</Link> </Link>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,43 @@
import { useTranslation } from "react-i18next";
import { FiBookmark } from "react-icons/fi";
import Post from "@/components/social/posts/Post";
import EmptyState from "@/components/ui/EmptyState";
import Spinner from "@/components/ui/Spinner";
import { useQuery } from "@tanstack/react-query";
import { privateApi } from "@/api/privateClient";
export default function SavedPage() {
const { t } = useTranslation("social");
const { data, isLoading } = useQuery({
queryKey: ["social", "posts", "saved"],
queryFn: () =>
privateApi.get("/api/social/posts/saved/").then((r) => r.data),
});
const posts = data?.results ?? [];
return (
<div>
<header className="sticky top-0 z-10 border-b border-brand-lines/10 bg-brand-bg/80 px-4 py-3 backdrop-blur">
<h1 className="text-lg font-bold text-brand-text">{t("nav.saved")}</h1>
</header>
{isLoading && (
<div className="flex justify-center py-10">
<Spinner size={28} />
</div>
)}
{!isLoading && posts.length === 0 && (
<EmptyState icon={<FiBookmark />} message={t("saved.empty", { defaultValue: "Žádné uložené příspěvky." })} />
)}
<div>
{posts.map((p: any) => (
<Post key={p.id} post={p} />
))}
</div>
</div>
);
}

View File

@@ -1,11 +1,12 @@
import { useRef } from "react";
import { useParams, Link, useNavigate } from "react-router-dom"; import { useParams, Link, useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { FiArrowLeft, FiLogOut, FiSettings, FiUser, FiCalendar } from "react-icons/fi"; import { FiArrowLeft, FiLogOut, FiSettings, FiUser, FiCalendar, FiMapPin } from "react-icons/fi";
import { useApiAccountUsersRetrieve } from "@/api/generated/private/account/account"; import { useQuery } from "@tanstack/react-query";
import { useApiSocialPostsList } from "@/api/generated/private/posts/posts"; import { useApiSocialPostsList } from "@/api/generated/private/posts/posts";
import { useAuth } from "@/context/AuthContext"; import { useAuth } from "@/context/AuthContext";
import { privateApi } from "@/api/privateClient";
import Avatar from "@/components/ui/Avatar"; import Avatar from "@/components/ui/Avatar";
import { mediaUrl } from "@/utils/mediaUrl";
import Post from "@/components/social/posts/Post"; import Post from "@/components/social/posts/Post";
import Spinner from "@/components/ui/Spinner"; import Spinner from "@/components/ui/Spinner";
import EmptyState from "@/components/ui/EmptyState"; import EmptyState from "@/components/ui/EmptyState";
@@ -17,24 +18,35 @@ function formatJoined(dateVal: Date | string | undefined): string {
} }
export default function UserProfilePage() { export default function UserProfilePage() {
const { id } = useParams<{ id: string }>(); const { username } = useParams<{ username: string }>();
const userId = Number(id);
const { user: me } = useAuth(); const { user: me } = useAuth();
const { t } = useTranslation("social"); const { t } = useTranslation("social");
const navigate = useNavigate(); const navigate = useNavigate();
const isOwnProfile = me?.id === userId;
const { data: profile, isLoading: profileLoading } = useApiAccountUsersRetrieve(userId); const { data: profile, isLoading: profileLoading } = useQuery({
queryKey: ["account", "users", username],
queryFn: () =>
privateApi
.get(`/api/account/users/`, { params: { username } })
.then((r) => r.data?.results?.[0] ?? null),
enabled: !!username,
});
const isOwnProfile = me?.username === username;
const { data: postsData, isLoading: postsLoading } = useApiSocialPostsList( const { data: postsData, isLoading: postsLoading } = useApiSocialPostsList(
{ author: userId }, { author: profile?.id },
{ query: { enabled: !!profile?.id } },
); );
const posts = postsData?.results ?? []; const posts = postsData?.results ?? [];
const p = profile as any;
const displayName = [p?.first_name, p?.last_name].filter(Boolean).join(" ") || p?.username || "";
return ( return (
<div> <div>
{/* Header */} {/* Sticky back-nav */}
<header className="sticky top-0 z-10 flex items-center gap-3 border-b border-brand-lines/10 bg-brand-bg/80 px-4 py-3 backdrop-blur"> <header className="sticky top-0 z-20 flex items-center gap-3 bg-brand-bg/70 px-4 py-3 backdrop-blur">
<button <button
type="button" type="button"
onClick={() => navigate(-1)} onClick={() => navigate(-1)}
@@ -49,54 +61,60 @@ export default function UserProfilePage() {
</header> </header>
{profileLoading ? ( {profileLoading ? (
<div className="flex justify-center py-12"> <div className="flex justify-center py-12"><Spinner size={28} /></div>
<Spinner size={28} />
</div>
) : !profile ? ( ) : !profile ? (
<EmptyState icon={<FiUser />} title={t("profile.notFound")} /> <EmptyState icon={<FiUser />} title={t("profile.notFound")} />
) : ( ) : (
<> <>
{/* Profile card */} {/* Banner + avatar */}
<div className="border-b border-brand-lines/10 px-4 py-5"> <div className="relative">
<div className="flex items-start gap-4"> {/* Banner */}
<Avatar <div className="h-36 w-full overflow-hidden bg-gradient-to-br from-brand-bgLight to-brand-lines/20">
name={[profile.first_name, profile.last_name].filter(Boolean).join(" ") || profile.username} {p?.banner && (
src={(profile as any).avatar ?? null} <img
size={72} src={mediaUrl(p.banner) ?? p.banner}
/> alt=""
<div className="min-w-0 flex-1"> className="h-full w-full object-cover"
<div className="text-xl font-bold text-brand-text"> />
{[profile.first_name, profile.last_name].filter(Boolean).join(" ") || profile.username} )}
</div> </div>
<div className="text-sm text-brand-text/60">@{profile.username}</div>
{(profile as any).city && ( {/* Avatar — overlaps banner */}
<div className="mt-1 text-sm text-brand-text/50">{(profile as any).city}</div> <div className="absolute -bottom-10 left-4">
)} <div className="rounded-full ring-4 ring-brand-bg">
<Avatar name={displayName} src={p?.avatar ?? null} size={80} />
{(profile as any).create_time && (
<div className="mt-1 flex items-center gap-1 text-xs text-brand-text/40">
<FiCalendar size={12} />
{t("profile.joined", { date: formatJoined((profile as any).create_time) })}
</div>
)}
</div> </div>
</div>
{isOwnProfile && ( {/* Action buttons — top-right of banner area */}
<div className="flex flex-col items-end gap-2"> {isOwnProfile && (
<Link <div className="absolute bottom-3 right-4 flex items-center gap-2">
to="/social/account/settings" <Link
className="inline-flex items-center gap-1.5 rounded-xl border border-brand-lines/20 px-3 py-1.5 text-sm text-brand-text/70 hover:bg-brand-lines/10 hover:text-brand-text transition-colors" to="/social/account/settings"
> className="inline-flex items-center gap-1.5 rounded-full border border-brand-lines/30 bg-brand-bg/80 px-3 py-1.5 text-sm text-brand-text/80 backdrop-blur hover:bg-brand-lines/10 transition-colors"
<FiSettings size={13} /> {t("profile.editProfile")} >
</Link> <FiSettings size={16} /> {t("profile.editProfile")}
<Link </Link>
to="/social/logout" </div>
className="inline-flex items-center gap-1.5 rounded-xl border border-brand-lines/20 px-3 py-1.5 text-sm text-brand-text/70 hover:bg-brand-lines/10 hover:text-red-400 transition-colors" )}
> </div>
<FiLogOut size={13} /> {t("nav.logout")}
</Link> {/* Profile info */}
</div> <div className="border-b border-brand-lines/10 px-4 pb-4 pt-12">
<div className="text-xl font-bold text-brand-text">{displayName}</div>
<div className="text-sm text-brand-text/50">@{profile.username}</div>
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-brand-text/40">
{p?.city && (
<span className="flex items-center gap-1">
<FiMapPin size={12} /> {p.city}
</span>
)}
{p?.create_time && (
<span className="flex items-center gap-1">
<FiCalendar size={12} />
{t("profile.joined", { date: formatJoined(p.create_time) })}
</span>
)} )}
</div> </div>
</div> </div>
@@ -104,17 +122,15 @@ export default function UserProfilePage() {
{/* Posts */} {/* Posts */}
<div> <div>
{postsLoading ? ( {postsLoading ? (
<div className="flex justify-center py-8"> <div className="flex justify-center py-8"><Spinner size={22} /></div>
<Spinner size={22} />
</div>
) : posts.length === 0 ? ( ) : posts.length === 0 ? (
<EmptyState message={t("profile.noPosts")} /> <EmptyState message={t("profile.noPosts")} />
) : ( ) : (
posts.map((p) => ( posts.map((post) => (
<Post <Post
key={p.id} key={post.id}
post={p} post={post}
onReplyClick={() => navigate(`/social/post/${p.id}`)} onReplyClick={() => navigate(`/social/post/${post.id}`)}
/> />
)) ))
)} )}

View File

@@ -0,0 +1,19 @@
const BACKEND_ORIGIN = (() => {
try {
return new URL(import.meta.env.VITE_BACKEND_URL ?? "http://localhost:8000").origin;
} catch {
return "http://localhost:8000";
}
})();
export function mediaUrl(src: string | null | undefined): string | null {
if (!src) return null;
if (src.startsWith("blob:") || src.startsWith("data:")) return src;
try {
const url = new URL(src);
if (url.origin === BACKEND_ORIGIN) return url.pathname + url.search;
} catch {
// already a relative path — return as-is
}
return src;
}

View File

@@ -1,17 +1,53 @@
import { defineConfig } from 'vite' import { defineConfig, loadEnv } from 'vite'
import tailwindcss from '@tailwindcss/vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import path from 'path' import path from 'path'
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig(({ mode }) => {
plugins: [ const env = loadEnv(mode, process.cwd(), '');
react(), const backendUrl = env.VITE_BACKEND_URL || '';
tailwindcss()
], return {
resolve: { plugins: [
alias: { tailwindcss(),
'@': path.resolve(__dirname, './src'), react({
babel: {
plugins: [['babel-plugin-react-compiler']],
},
}),
],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
// Force single React instance to avoid duplicate React errors with @yudiel/react-qr-scanner
'react': path.resolve(__dirname, './node_modules/react'),
'react-dom': path.resolve(__dirname, './node_modules/react-dom'),
},
}, },
}, build: {
target: 'es2020',
},
server: {
proxy: {
'/api': {
target: backendUrl,
changeOrigin: true,
},
'/static': {
target: backendUrl,
changeOrigin: true,
},
'/media': {
target: backendUrl,
changeOrigin: true,
},
'/ws': {
target: backendUrl,
ws: true,
changeOrigin: true,
},
},
},
};
}) })