posts are done
This commit is contained in:
@@ -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"
|
||||
]
|
||||
|
||||
18
backend/account/migrations/0003_customuser_banner.py
Normal file
18
backend/account/migrations/0003_customuser_banner.py
Normal 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/'),
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
@@ -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ů.")
|
||||
|
||||
@@ -20,6 +20,7 @@ urlpatterns = [
|
||||
# Password reset endpoints
|
||||
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-change/', views.ChangePasswordView.as_view(), name='password-change'),
|
||||
|
||||
# User CRUD (list, retrieve, update, delete)
|
||||
path('', include(router.urls)), #/users/
|
||||
|
||||
@@ -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
|
||||
|
||||
28
backend/social/posts/migrations/0002_postsave.py
Normal file
28
backend/social/posts/migrations/0002_postsave.py
Normal 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')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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'
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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() {
|
||||
<Route path="hubs" element={<HubsPage />} />
|
||||
<Route path="hub/:id" element={<HubPage />} />
|
||||
<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 index element={<ChatsIndexPage />} />
|
||||
<Route path=":chatId" element={<ChatRoomPage />} />
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<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.
|
||||
* @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<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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 <T>(
|
||||
config: AxiosRequestConfig
|
||||
): Promise<T> => {
|
||||
// If sending FormData, remove Content-Type header to let axios set it with boundary
|
||||
export const privateMutator = async <T>(config: AxiosRequestConfig): Promise<T> => {
|
||||
if (config.data instanceof FormData) {
|
||||
delete config.headers?.['Content-Type'];
|
||||
}
|
||||
|
||||
const response = await privateApi.request<T>(config);
|
||||
return response.data;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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 <T>(
|
||||
config: AxiosRequestConfig
|
||||
): Promise<T> => {
|
||||
const response = await publicApi.request<T>(config);
|
||||
return response.data;
|
||||
};
|
||||
export const publicMutator = async <T>(config: AxiosRequestConfig): Promise<T> => {
|
||||
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<T>(config);
|
||||
return response.data;
|
||||
} finally {
|
||||
pendingRequests.delete(requestKey);
|
||||
}
|
||||
})();
|
||||
|
||||
pendingRequests.set(requestKey, requestPromise);
|
||||
|
||||
return requestPromise;
|
||||
};
|
||||
|
||||
@@ -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<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;
|
||||
|
||||
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 (
|
||||
<div className={`mt-3 grid ${layoutClass} gap-2 overflow-hidden rounded-xl border border-brand-lines/15`}>
|
||||
{items.map((it) => (
|
||||
<MediaItem key={it.id} item={it} />
|
||||
))}
|
||||
</div>
|
||||
<>
|
||||
<div className={`mt-3 grid ${layoutClass} overflow-hidden rounded-xl border border-brand-lines/15`}>
|
||||
{visible.map((it, i) => (
|
||||
<MediaItem
|
||||
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 }) {
|
||||
const url = item.file ?? "";
|
||||
function LightboxContent({ item }: { item: PostContent }) {
|
||||
const url = mediaUrl(item.file) ?? "";
|
||||
const mime = item.mime_type ?? "";
|
||||
|
||||
if (mime.startsWith("video/")) {
|
||||
return (
|
||||
<video
|
||||
src={url}
|
||||
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 (
|
||||
<img
|
||||
src={url}
|
||||
alt={item.alt_text ?? ""}
|
||||
className="w-full max-h-[480px] object-cover bg-brand-bg/60"
|
||||
loading="lazy"
|
||||
/>
|
||||
<a
|
||||
href={url}
|
||||
download
|
||||
target="_blank"
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useRef, useState, useEffect } from "react";
|
||||
import { FiTrash2, FiMoreHorizontal } from "react-icons/fi";
|
||||
import type { Post } from "@/api/generated/private/models/post";
|
||||
import Avatar from "@/components/ui/Avatar";
|
||||
@@ -21,11 +22,13 @@ interface AuthorDetail {
|
||||
avatar: string | null;
|
||||
}
|
||||
|
||||
type EnrichedPost = Post & {
|
||||
type EnrichedPost = Omit<Post, 'vote_score' | 'user_vote' | 'is_saved' | 'save_count'> & {
|
||||
author_detail?: AuthorDetail;
|
||||
vote_score?: number;
|
||||
user_vote?: -1 | 0 | 1;
|
||||
reply_count?: number;
|
||||
is_saved?: boolean;
|
||||
save_count?: number;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
@@ -66,12 +69,26 @@ export default function Post({
|
||||
function open(e: React.MouseEvent) {
|
||||
if (!clickable) return;
|
||||
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}`);
|
||||
}
|
||||
|
||||
const canDelete = canDeletePost(user, post);
|
||||
const canEdit = canEditPost(user, post);
|
||||
const canDelete = canDeletePost(user, post as unknown as 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 (
|
||||
<article
|
||||
@@ -84,7 +101,7 @@ export default function Post({
|
||||
>
|
||||
<div className="flex gap-3">
|
||||
<Link
|
||||
to={`/social/profile/${author?.id ?? post.author}`}
|
||||
to={`/social/profile/${author?.username ?? post.author}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="shrink-0"
|
||||
>
|
||||
@@ -99,7 +116,7 @@ export default function Post({
|
||||
<header className="flex items-start justify-between gap-2">
|
||||
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-0 text-sm">
|
||||
<Link
|
||||
to={`/social/profile/${author?.id ?? post.author}`}
|
||||
to={`/social/profile/${author?.username ?? post.author}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="font-semibold text-brand-text hover:underline"
|
||||
>
|
||||
@@ -121,19 +138,25 @@ export default function Post({
|
||||
</div>
|
||||
|
||||
{(canDelete || canEdit) && (
|
||||
<div className="flex items-center gap-1">
|
||||
{canDelete && (
|
||||
<IconButton
|
||||
icon={<FiTrash2 size={14} />}
|
||||
label={t("post.actions.delete")}
|
||||
onClick={handleDelete}
|
||||
/>
|
||||
)}
|
||||
{canEdit && (
|
||||
<IconButton
|
||||
icon={<FiMoreHorizontal size={14} />}
|
||||
label={t("post.actions.more")}
|
||||
/>
|
||||
<div ref={menuRef} className="relative">
|
||||
<IconButton
|
||||
icon={<FiMoreHorizontal size={14} />}
|
||||
label={t("post.actions.more")}
|
||||
onClick={(e) => { e.stopPropagation(); setMenuOpen((o) => !o); }}
|
||||
/>
|
||||
{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">
|
||||
{canDelete && (
|
||||
<button
|
||||
type="button"
|
||||
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>
|
||||
)}
|
||||
@@ -167,6 +190,8 @@ export default function Post({
|
||||
replyCount={post.reply_count}
|
||||
voteScore={post.vote_score}
|
||||
initialUserVote={post.user_vote}
|
||||
initialIsSaved={post.is_saved}
|
||||
initialSaveCount={post.save_count}
|
||||
onReplyClick={onReplyClick}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useState } from "react";
|
||||
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 { apiSocialPostsVoteCreate } from "@/api/generated/private/posts/posts";
|
||||
import { privateApi } from "@/api/privateClient";
|
||||
import SharePopup from "./SharePopup";
|
||||
|
||||
interface Props {
|
||||
@@ -10,6 +11,8 @@ interface Props {
|
||||
replyCount?: number;
|
||||
voteScore?: number;
|
||||
initialUserVote?: -1 | 0 | 1;
|
||||
initialIsSaved?: boolean;
|
||||
initialSaveCount?: number;
|
||||
onReplyClick?: () => void;
|
||||
}
|
||||
|
||||
@@ -18,6 +21,8 @@ export default function PostActions({
|
||||
replyCount,
|
||||
voteScore,
|
||||
initialUserVote,
|
||||
initialIsSaved,
|
||||
initialSaveCount,
|
||||
onReplyClick,
|
||||
}: Props) {
|
||||
const { t } = useTranslation("social");
|
||||
@@ -27,6 +32,8 @@ export default function PostActions({
|
||||
const [showShare, setShowShare] = useState(false);
|
||||
const [upHover, setUpHover] = 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) {
|
||||
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 downActive = vote === -1;
|
||||
|
||||
@@ -52,7 +71,7 @@ export default function PostActions({
|
||||
<button
|
||||
type="button"
|
||||
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")}
|
||||
title={t("post.actions.reply")}
|
||||
>
|
||||
@@ -64,28 +83,31 @@ export default function PostActions({
|
||||
|
||||
{/* Vote pill */}
|
||||
<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 */}
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => castVote(1)}
|
||||
{/* Upvote */}
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={t("post.actions.upvote")}
|
||||
aria-disabled={busy}
|
||||
onClick={() => !busy && castVote(1)}
|
||||
onKeyDown={(e) => e.key === "Enter" && !busy && castVote(1)}
|
||||
onMouseEnter={() => setUpHover(true)}
|
||||
onMouseLeave={() => setUpHover(false)}
|
||||
aria-label={t("post.actions.upvote")}
|
||||
title={t("post.actions.upvote")}
|
||||
style={{ borderTopRightRadius: 0, borderBottomRightRadius: 0 }}
|
||||
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",
|
||||
upActive ? "before:opacity-100" : upHover ? "before:opacity-40" : "before:opacity-0",
|
||||
].join(" ")}
|
||||
>
|
||||
<FiArrowUp
|
||||
size={15}
|
||||
size={16}
|
||||
style={{ color: upActive ? "rgb(12 74 110)" : undefined }}
|
||||
className="relative z-10 text-brand-text/50"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="w-px self-stretch bg-brand-lines/20" />
|
||||
|
||||
<span
|
||||
className={[
|
||||
@@ -96,30 +118,48 @@ export default function PostActions({
|
||||
{score}
|
||||
</span>
|
||||
|
||||
{/* Downvote — same pseudo-element trick */}
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => castVote(-1)}
|
||||
<div className="w-px self-stretch bg-brand-lines/20" />
|
||||
|
||||
{/* Downvote */}
|
||||
<div
|
||||
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)}
|
||||
onMouseLeave={() => setDownHover(false)}
|
||||
aria-label={t("post.actions.downvote")}
|
||||
title={t("post.actions.downvote")}
|
||||
style={{ borderTopLeftRadius: 0, borderBottomLeftRadius: 0 }}
|
||||
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",
|
||||
downActive ? "before:opacity-100" : downHover ? "before:opacity-40" : "before:opacity-0",
|
||||
].join(" ")}
|
||||
>
|
||||
<FiArrowDown
|
||||
size={15}
|
||||
size={16}
|
||||
style={{ color: downActive ? "rgb(12 74 110)" : undefined }}
|
||||
className="relative z-10 text-brand-text/50"
|
||||
/>
|
||||
</button>
|
||||
</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 */}
|
||||
<IconButton
|
||||
icon={<FiShare2 size={16} />}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
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 Textarea from "@/components/ui/Textarea";
|
||||
import Button from "@/components/ui/Button";
|
||||
@@ -9,36 +9,32 @@ import Spinner from "@/components/ui/Spinner";
|
||||
import FormErrorBanner from "@/components/ui/FormErrorBanner";
|
||||
import { applyServerErrors } from "@/utils/formErrors";
|
||||
import { apiSocialPostsCreate } from "@/api/generated/private/posts/posts";
|
||||
import { useApiSocialHubsList } from "@/api/generated/private/hubs/hubs";
|
||||
import { privateApi } from "@/api/privateClient";
|
||||
|
||||
interface Props {
|
||||
parentId?: number;
|
||||
defaultHubId?: number | null;
|
||||
hubId?: number | null;
|
||||
onPosted?: () => void;
|
||||
}
|
||||
|
||||
interface ComposerForm {
|
||||
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 queryClient = useQueryClient();
|
||||
const { data: hubsData } = useApiSocialHubsList(undefined);
|
||||
const [rootError, setRootError] = useState<string | undefined>();
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [previews, setPreviews] = useState<string[]>([]);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const form = useForm<ComposerForm>({
|
||||
defaultValues: { content: "", hub: defaultHubId ?? null },
|
||||
defaultValues: { content: "" },
|
||||
});
|
||||
const { register, handleSubmit, formState, reset, watch, clearErrors } = form;
|
||||
const { errors, isSubmitting } = formState;
|
||||
|
||||
const hubs = hubsData?.results ?? [];
|
||||
const content = watch("content");
|
||||
|
||||
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
@@ -61,7 +57,7 @@ export default function PostComposer({ parentId, defaultHubId, onPosted }: Props
|
||||
try {
|
||||
const created = await apiSocialPostsCreate({
|
||||
content: values.content,
|
||||
hub: values.hub ?? null,
|
||||
hub: hubId ?? null,
|
||||
reply_to: parentId ?? null,
|
||||
} as Parameters<typeof apiSocialPostsCreate>[0]);
|
||||
|
||||
@@ -75,7 +71,7 @@ export default function PostComposer({ parentId, defaultHubId, onPosted }: Props
|
||||
previews.forEach((url) => URL.revokeObjectURL(url));
|
||||
setFiles([]);
|
||||
setPreviews([]);
|
||||
reset({ content: "", hub: defaultHubId ?? null });
|
||||
reset({ content: "" });
|
||||
await queryClient.invalidateQueries({ queryKey: ["social", "posts"] });
|
||||
onPosted?.();
|
||||
} catch (err) {
|
||||
@@ -111,49 +107,58 @@ export default function PostComposer({ parentId, defaultHubId, onPosted }: Props
|
||||
})}
|
||||
/>
|
||||
|
||||
{/* Image previews */}
|
||||
{/* File previews */}
|
||||
{previews.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{previews.map((src, i) => (
|
||||
<div key={src} className="relative">
|
||||
<img
|
||||
src={src}
|
||||
alt=""
|
||||
className="h-20 w-20 rounded-xl object-cover border border-brand-lines/20"
|
||||
/>
|
||||
<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>
|
||||
))}
|
||||
{previews.map((src, i) => {
|
||||
const file = files[i];
|
||||
const isVideo = file?.type.startsWith("video/");
|
||||
const isImage = file?.type.startsWith("image/");
|
||||
return (
|
||||
<div key={src} className="relative">
|
||||
{isVideo ? (
|
||||
<div className="relative h-20 w-20">
|
||||
<video
|
||||
src={src}
|
||||
className="h-20 w-20 rounded-xl object-cover border border-brand-lines/20 bg-black"
|
||||
muted
|
||||
playsInline
|
||||
preload="metadata"
|
||||
/>
|
||||
<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 className="mt-2 flex items-center justify-between gap-2">
|
||||
<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 */}
|
||||
<button
|
||||
type="button"
|
||||
@@ -163,12 +168,12 @@ export default function PostComposer({ parentId, defaultHubId, onPosted }: Props
|
||||
aria-label={t("post.compose.attachImage")}
|
||||
title={t("post.compose.attachImage")}
|
||||
>
|
||||
<FiImage size={16} />
|
||||
<FiPaperclip size={16} />
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*,video/*"
|
||||
accept="*/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { mediaUrl } from "@/utils/mediaUrl";
|
||||
|
||||
interface Props {
|
||||
name?: 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) {
|
||||
const dim = { width: size, height: size };
|
||||
if (src) {
|
||||
const resolvedSrc = mediaUrl(src);
|
||||
if (resolvedSrc) {
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
src={resolvedSrc}
|
||||
alt={name ?? ""}
|
||||
style={dim}
|
||||
className={`rounded-full object-cover border border-brand-lines/20 ${className}`}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
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 { apiAccountUserMeRetrieve } from "@/api/generated/private/account/account";
|
||||
import { privateApi } from "@/api/privateClient";
|
||||
|
||||
// Import typů z Orval
|
||||
import type { CustomTokenObtainPair } from "@/api/generated/public/models/customTokenObtainPair";
|
||||
import type { CustomUser } from "@/api/generated/private/models/customUser";
|
||||
|
||||
export const AUTH_FLAG = "vontor_was_logged_in";
|
||||
|
||||
interface AuthContextType {
|
||||
user: CustomUser | null;
|
||||
isAuthenticated: boolean;
|
||||
@@ -24,47 +24,67 @@ const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<CustomUser | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const initialized = useRef(false);
|
||||
|
||||
const isAuthenticated = !!user;
|
||||
|
||||
// Načíst uživatele při načtení aplikace (pokud má cookies)
|
||||
useEffect(() => {
|
||||
refreshUser();
|
||||
}, []);
|
||||
|
||||
async function refreshUser() {
|
||||
const wasLoggedIn = localStorage.getItem(AUTH_FLAG);
|
||||
|
||||
if (!wasLoggedIn) {
|
||||
setUser(null);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const userData = await apiAccountUserMeRetrieve();
|
||||
setUser(userData);
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.response?.data?.error || err.message;
|
||||
console.error("Failed to refresh user:", errorMessage);
|
||||
} catch {
|
||||
setUser(null);
|
||||
localStorage.removeItem(AUTH_FLAG);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialized.current) {
|
||||
initialized.current = true;
|
||||
refreshUser();
|
||||
}
|
||||
}, []);
|
||||
|
||||
async function login(payload: CustomTokenObtainPair) {
|
||||
// Do NOT touch isLoading here — that flag is only for the initial auth
|
||||
// bootstrap on mount. Toggling it during login causes PublicOnlyRoute to
|
||||
// swap the Login page for a spinner, which unmounts the form and wipes
|
||||
// its local error state before the user can see it.
|
||||
// The Login page tracks its own submitting state via react-hook-form.
|
||||
// Must use privateApi (withCredentials: true) so the browser stores the
|
||||
// Set-Cookie headers from the login response. publicApi drops them silently.
|
||||
await privateApi.post("/api/account/login/", payload);
|
||||
await refreshUser();
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await privateApi.post("/api/account/login/", payload);
|
||||
localStorage.setItem(AUTH_FLAG, "true");
|
||||
await refreshUser();
|
||||
} catch (err: any) {
|
||||
setIsLoading(false);
|
||||
const data = err.response?.data;
|
||||
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() {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// Zavolej logout endpoint (smaže cookies na backendu)
|
||||
localStorage.removeItem(AUTH_FLAG);
|
||||
await apiAccountLogoutCreate();
|
||||
} catch (err: any) {
|
||||
console.error("Logout error:", err);
|
||||
} finally {
|
||||
setUser(null);
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,4 +99,4 @@ export function useAuth() {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) throw new Error("useAuth must be used inside AuthProvider");
|
||||
return ctx;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"feed": "Feed",
|
||||
"chats": "Zprávy",
|
||||
"hubs": "Huby",
|
||||
"saved": "Uložené",
|
||||
"profile": "Profil",
|
||||
"logout": "Odhlásit"
|
||||
},
|
||||
@@ -37,7 +38,8 @@
|
||||
"more": "Více možností",
|
||||
"delete": "Smazat",
|
||||
"edit": "Upravit",
|
||||
"share": "Sdílet"
|
||||
"share": "Sdílet",
|
||||
"save": "Uložit"
|
||||
},
|
||||
"thread": {
|
||||
"parents": "Vlákno výše",
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
FiMessageCircle,
|
||||
FiUsers,
|
||||
FiUser,
|
||||
FiBookmark,
|
||||
FiLogOut,
|
||||
} from "react-icons/fi";
|
||||
import { useAuth } from "@/context/AuthContext";
|
||||
@@ -16,13 +17,14 @@ interface NavItem {
|
||||
labelKey: string;
|
||||
}
|
||||
|
||||
function buildItems(userId?: number): NavItem[] {
|
||||
function buildItems(username?: string): NavItem[] {
|
||||
return [
|
||||
{ to: "/social/feed", icon: <FiHome size={22} />, labelKey: "nav.feed" },
|
||||
{ to: "/social/chats", icon: <FiMessageCircle size={22} />, labelKey: "nav.chats" },
|
||||
{ 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} />,
|
||||
labelKey: "nav.profile",
|
||||
},
|
||||
@@ -32,7 +34,7 @@ function buildItems(userId?: number): NavItem[] {
|
||||
export default function SocialLayout() {
|
||||
const { t } = useTranslation("social");
|
||||
const { user } = useAuth();
|
||||
const items = buildItems(user?.id);
|
||||
const items = buildItems(user?.username);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen w-full">
|
||||
@@ -62,7 +64,7 @@ export default function SocialLayout() {
|
||||
</div>
|
||||
|
||||
<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="truncate text-sm font-semibold text-brand-text">
|
||||
{user?.username ?? "—"}
|
||||
|
||||
333
frontend/src/pages/social/AccountSettingsPage.tsx
Normal file
333
frontend/src/pages/social/AccountSettingsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -41,7 +41,7 @@ export default function ProfilePage() {
|
||||
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"
|
||||
>
|
||||
<FiLogOut size={14} /> {t("nav.logout")}
|
||||
<FiLogOut size={16} /> {t("nav.logout")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
43
frontend/src/pages/social/SavedPage.tsx
Normal file
43
frontend/src/pages/social/SavedPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useRef } from "react";
|
||||
import { useParams, Link, useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FiArrowLeft, FiLogOut, FiSettings, FiUser, FiCalendar } from "react-icons/fi";
|
||||
import { useApiAccountUsersRetrieve } from "@/api/generated/private/account/account";
|
||||
import { FiArrowLeft, FiLogOut, FiSettings, FiUser, FiCalendar, FiMapPin } from "react-icons/fi";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useApiSocialPostsList } from "@/api/generated/private/posts/posts";
|
||||
import { useAuth } from "@/context/AuthContext";
|
||||
import { privateApi } from "@/api/privateClient";
|
||||
import Avatar from "@/components/ui/Avatar";
|
||||
import { mediaUrl } from "@/utils/mediaUrl";
|
||||
import Post from "@/components/social/posts/Post";
|
||||
import Spinner from "@/components/ui/Spinner";
|
||||
import EmptyState from "@/components/ui/EmptyState";
|
||||
@@ -17,24 +18,35 @@ function formatJoined(dateVal: Date | string | undefined): string {
|
||||
}
|
||||
|
||||
export default function UserProfilePage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const userId = Number(id);
|
||||
const { username } = useParams<{ username: string }>();
|
||||
const { user: me } = useAuth();
|
||||
const { t } = useTranslation("social");
|
||||
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(
|
||||
{ author: userId },
|
||||
{ author: profile?.id },
|
||||
{ query: { enabled: !!profile?.id } },
|
||||
);
|
||||
|
||||
const posts = postsData?.results ?? [];
|
||||
const p = profile as any;
|
||||
const displayName = [p?.first_name, p?.last_name].filter(Boolean).join(" ") || p?.username || "";
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<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">
|
||||
{/* Sticky back-nav */}
|
||||
<header className="sticky top-0 z-20 flex items-center gap-3 bg-brand-bg/70 px-4 py-3 backdrop-blur">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(-1)}
|
||||
@@ -49,54 +61,60 @@ export default function UserProfilePage() {
|
||||
</header>
|
||||
|
||||
{profileLoading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<Spinner size={28} />
|
||||
</div>
|
||||
<div className="flex justify-center py-12"><Spinner size={28} /></div>
|
||||
) : !profile ? (
|
||||
<EmptyState icon={<FiUser />} title={t("profile.notFound")} />
|
||||
) : (
|
||||
<>
|
||||
{/* Profile card */}
|
||||
<div className="border-b border-brand-lines/10 px-4 py-5">
|
||||
<div className="flex items-start gap-4">
|
||||
<Avatar
|
||||
name={[profile.first_name, profile.last_name].filter(Boolean).join(" ") || profile.username}
|
||||
src={(profile as any).avatar ?? null}
|
||||
size={72}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-xl font-bold text-brand-text">
|
||||
{[profile.first_name, profile.last_name].filter(Boolean).join(" ") || profile.username}
|
||||
</div>
|
||||
<div className="text-sm text-brand-text/60">@{profile.username}</div>
|
||||
{/* Banner + avatar */}
|
||||
<div className="relative">
|
||||
{/* Banner */}
|
||||
<div className="h-36 w-full overflow-hidden bg-gradient-to-br from-brand-bgLight to-brand-lines/20">
|
||||
{p?.banner && (
|
||||
<img
|
||||
src={mediaUrl(p.banner) ?? p.banner}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(profile as any).city && (
|
||||
<div className="mt-1 text-sm text-brand-text/50">{(profile as any).city}</div>
|
||||
)}
|
||||
|
||||
{(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>
|
||||
)}
|
||||
{/* Avatar — overlaps banner */}
|
||||
<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} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isOwnProfile && (
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<Link
|
||||
to="/social/account/settings"
|
||||
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"
|
||||
>
|
||||
<FiSettings size={13} /> {t("profile.editProfile")}
|
||||
</Link>
|
||||
<Link
|
||||
to="/social/logout"
|
||||
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"
|
||||
>
|
||||
<FiLogOut size={13} /> {t("nav.logout")}
|
||||
</Link>
|
||||
</div>
|
||||
{/* Action buttons — top-right of banner area */}
|
||||
{isOwnProfile && (
|
||||
<div className="absolute bottom-3 right-4 flex items-center gap-2">
|
||||
<Link
|
||||
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={16} /> {t("profile.editProfile")}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Profile info */}
|
||||
<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>
|
||||
@@ -104,17 +122,15 @@ export default function UserProfilePage() {
|
||||
{/* Posts */}
|
||||
<div>
|
||||
{postsLoading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<Spinner size={22} />
|
||||
</div>
|
||||
<div className="flex justify-center py-8"><Spinner size={22} /></div>
|
||||
) : posts.length === 0 ? (
|
||||
<EmptyState message={t("profile.noPosts")} />
|
||||
) : (
|
||||
posts.map((p) => (
|
||||
posts.map((post) => (
|
||||
<Post
|
||||
key={p.id}
|
||||
post={p}
|
||||
onReplyClick={() => navigate(`/social/post/${p.id}`)}
|
||||
key={post.id}
|
||||
post={post}
|
||||
onReplyClick={() => navigate(`/social/post/${post.id}`)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
19
frontend/src/utils/mediaUrl.ts
Normal file
19
frontend/src/utils/mediaUrl.ts
Normal 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;
|
||||
}
|
||||
@@ -1,17 +1,53 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import { defineConfig, loadEnv } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import path from 'path'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
tailwindcss()
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), '');
|
||||
const backendUrl = env.VITE_BACKEND_URL || '';
|
||||
|
||||
return {
|
||||
plugins: [
|
||||
tailwindcss(),
|
||||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user