diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 8359675..b863243 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -9,7 +9,14 @@ "Bash(npx eslint *)", "Bash(python -c ' *)", "PowerShell(Get-ChildItem -Path \"c:\\\\Users\\\\bruno\\\\Documents\\\\GitHub\\\\vontor-cz\\\\frontend\\\\src\\\\components\\\\social\" -File -Recurse | Select-Object FullName, @{n='Lines';e={\\(Get-Content $_.FullName | Measure-Object -Line\\).Lines}} | Format-Table -AutoSize)", - "Bash(grep -E \"\\\\.\\(ts|tsx\\)$\")" + "Bash(grep -E \"\\\\.\\(ts|tsx\\)$\")", + "Bash(grep -v \"^$\")", + "Bash(node -e \"const r = require\\('react-icons/fa'\\); console.log\\('FaMigrateAlt' in r, 'FaBrain' in r, 'FaBolt' in r, 'FaCode' in r, 'FaCreditCard' in r, 'FaServer' in r\\);\")", + "Bash(node -e \"const r = require\\('react-icons/fa'\\); const keys = Object.keys\\(r\\).filter\\(k => k.toLowerCase\\(\\).includes\\('migrat'\\) || k.toLowerCase\\(\\).includes\\('sync'\\) || k.toLowerCase\\(\\).includes\\('exchange'\\) || k.toLowerCase\\(\\).includes\\('arrow'\\)\\).slice\\(0,15\\); console.log\\(keys.join\\('\\\\n'\\)\\);\")", + "Bash(node -e \"const r = require\\('react-icons/fa'\\); console.log\\('FaExchangeAlt' in r, 'FaSyncAlt' in r, 'FaCloudUploadAlt' in r, 'FaRandom' in r, 'FaDatabase' in r\\);\")", + "Bash(node -e \"const r = require\\('react-icons/gi'\\); console.log\\('GiStabilizer' in r, 'GiDroneBoy' in r, 'GiCctvCamera' in r, 'GiFilmProjector' in r, 'GiGyroscope' in r\\);\")", + "Bash(node -e \"const r = require\\('react-icons/si'\\); const celery = Object.keys\\(r\\).filter\\(k => k.toLowerCase\\(\\).includes\\('celery'\\) || k.toLowerCase\\(\\).includes\\('worker'\\) || k.toLowerCase\\(\\).includes\\('task'\\)\\).slice\\(0,10\\); console.log\\(celery\\);\")", + "Bash(Get-ChildItem -Path \"c:\\\\Users\\\\bruno\\\\Documents\\\\GitHub\\\\vontor-cz\\\\backend\\\\\" -Directory | Select-Object -ExpandProperty Name)" ] } } diff --git a/backend/advertisement/tasks.py b/backend/advertisement/tasks.py index d6b7845..30a7527 100644 --- a/backend/advertisement/tasks.py +++ b/backend/advertisement/tasks.py @@ -14,8 +14,10 @@ def send_contact_me_email_task(client_email, message_content): "client_email": client_email, "message_content": message_content } + config_email = SiteConfiguration.get_solo().contact_email + recipient = config_email if config_email else "brunovontor@gmail.com" send_email_with_context( - recipients=SiteConfiguration.get_solo().contact_email, + recipients=recipient, subject="Poptávka z kontaktního formuláře!!!", template_path="email/contact_me.html", context=context, diff --git a/backend/social/chat/apps.py b/backend/social/chat/apps.py index bdfbeb0..dd7ad1f 100644 --- a/backend/social/chat/apps.py +++ b/backend/social/chat/apps.py @@ -6,3 +6,6 @@ class ChatConfig(AppConfig): name = 'social.chat' label = "chat" + + def ready(self): + import social.chat.signals # noqa: F401 diff --git a/backend/social/chat/permissions.py b/backend/social/chat/permissions.py index a449178..3dadbf2 100644 --- a/backend/social/chat/permissions.py +++ b/backend/social/chat/permissions.py @@ -9,7 +9,11 @@ class IsChatMember(IsAuthenticated): """ def has_object_permission(self, request, view, obj): - return request.user.is_superuser or obj.members.filter(pk=request.user.pk).exists() + return ( + request.user.is_superuser + or obj.owner == request.user + or obj.members.filter(pk=request.user.pk).exists() + ) class CanManageChat(IsAuthenticated): diff --git a/backend/social/chat/serializers.py b/backend/social/chat/serializers.py index 7349059..1031c79 100644 --- a/backend/social/chat/serializers.py +++ b/backend/social/chat/serializers.py @@ -43,11 +43,12 @@ class MessageHistorySerializer(serializers.ModelSerializer): class ReplyToSerializer(serializers.ModelSerializer): sender = MessageSenderSerializer(read_only=True) + media_files = MessageFileSerializer(many=True, read_only=True) class Meta: model = Message - fields = ['id', 'content', 'sender'] - read_only_fields = ['id', 'content', 'sender'] + fields = ['id', 'content', 'sender', 'created_at', 'media_files'] + read_only_fields = ['id', 'content', 'sender', 'created_at', 'media_files'] class MessageSerializer(serializers.ModelSerializer): @@ -69,23 +70,34 @@ class MessageSerializer(serializers.ModelSerializer): if not reply_to_id: return None try: - msg = Message.all_objects.select_related('sender').get(pk=reply_to_id) + msg = Message.all_objects.select_related('sender').prefetch_related('media_files').get(pk=reply_to_id) except Message.DoesNotExist: return None + from django.conf import settings sender_data = None if msg.sender: - from django.conf import settings avatar = (settings.MEDIA_URL + msg.sender.avatar.name) if msg.sender.avatar else None sender_data = {'id': msg.sender.id, 'username': msg.sender.username, 'avatar': avatar} else: sender_data = {'id': 0, 'username': '…', 'avatar': None} + media_files_data = [] + if not msg.is_deleted: + for f in msg.media_files.all(): + media_files_data.append({ + 'id': f.id, + 'file': settings.MEDIA_URL + f.file.name if f.file else '', + 'media_type': f.media_type, + 'uploaded_at': f.uploaded_at.isoformat(), + }) + return { 'id': msg.id, - # content=None signals the frontend to show the deleted tombstone 'content': None if msg.is_deleted else msg.content, 'sender': sender_data, + 'created_at': msg.created_at.isoformat(), + 'media_files': media_files_data, } class Meta: diff --git a/backend/social/chat/signals.py b/backend/social/chat/signals.py new file mode 100644 index 0000000..8f20c3d --- /dev/null +++ b/backend/social/chat/signals.py @@ -0,0 +1,35 @@ +from django.conf import settings +from django.db.models.signals import post_save +from django.dispatch import receiver + + +@receiver(post_save, sender=settings.AUTH_USER_MODEL) +def sync_dm_chat_identity(sender, instance, created, update_fields, **kwargs): + """Keep DM chat name/icon in sync when a user updates their username or avatar.""" + if created: + return + + changed = set(update_fields) if update_fields else None # None = full save + username_changed = changed is None or 'username' in changed + avatar_changed = changed is None or 'avatar' in changed + + if not (username_changed or avatar_changed): + return + + from .models import Chat + + dm_chats = Chat.objects.filter( + chat_type=Chat.ChatType.DM, + members=instance, + ).exclude(owner=instance) + + if not dm_chats.exists(): + return + + update_kwargs = {} + if username_changed: + update_kwargs['name'] = instance.username + if avatar_changed: + update_kwargs['icon'] = instance.avatar.name if instance.avatar else None + + dm_chats.update(**update_kwargs) diff --git a/backend/social/chat/views.py b/backend/social/chat/views.py index 0128422..a09b79a 100644 --- a/backend/social/chat/views.py +++ b/backend/social/chat/views.py @@ -53,7 +53,23 @@ class ChatViewSet(viewsets.ModelViewSet): return Chat.objects.filter(Q(members=user) | Q(owner=user)).distinct() def perform_create(self, serializer): - serializer.save(owner=self.request.user) + chat = serializer.save(owner=self.request.user) + + # Ensure the creator is always a member so they pass membership checks. + chat.members.add(self.request.user) + + if chat.chat_type == Chat.ChatType.DM: + other = chat.members.exclude(pk=self.request.user.pk).first() + if other: + update_fields = [] + if not chat.name: + chat.name = other.username + update_fields.append('name') + if not chat.icon and other.avatar: + chat.icon = other.avatar + update_fields.append('icon') + if update_fields: + chat.save(update_fields=update_fields) # ------------------------------------------------------------------ # Member management @@ -180,8 +196,7 @@ class MessageViewSet(viewsets.ModelViewSet): qs = Message.objects.select_related('sender', 'chat').prefetch_related('media_files', 'reactions') if user.is_superuser: return qs - # Only messages from chats the user is a member of - return qs.filter(chat__members=user) + return qs.filter(Q(chat__members=user) | Q(chat__owner=user)).distinct() def perform_update(self, serializer): message = serializer.instance @@ -221,7 +236,7 @@ class MessageViewSet(viewsets.ModelViewSet): ser.is_valid(raise_exception=True) chat = ser.validated_data['chat'] - if not request.user.is_superuser and not chat.members.filter(pk=request.user.pk).exists(): + if not request.user.is_superuser and not chat.members.filter(pk=request.user.pk).exists() and chat.owner != request.user: raise PermissionDenied('You are not a member of this chat.') message = Message.objects.create( diff --git a/backend/social/hubs/urls.py b/backend/social/hubs/urls.py index b86e968..7a47a87 100644 --- a/backend/social/hubs/urls.py +++ b/backend/social/hubs/urls.py @@ -1,9 +1,19 @@ +from django.urls import include, path from rest_framework.routers import DefaultRouter from .views import HubViewSet, HubPermissionViewSet, TagsViewSet -router = DefaultRouter() -router.register('', HubViewSet, basename='hub') -router.register('moderators', HubPermissionViewSet, basename='hub-moderator') -router.register('tags', TagsViewSet, basename='hub-tag') +hub_router = DefaultRouter() +hub_router.register('', HubViewSet, basename='hub') -urlpatterns = router.urls +moderators_router = DefaultRouter() +moderators_router.register('', HubPermissionViewSet, basename='hub-moderator') + +tags_router = DefaultRouter() +tags_router.register('', TagsViewSet, basename='hub-tag') + +# moderators/ and tags/ must be declared BEFORE the hub router urls so that +# Django resolves them before the hub's generic /{pk}/ pattern can swallow them. +urlpatterns = [ + path('moderators/', include(moderators_router.urls)), + path('tags/', include(tags_router.urls)), +] + hub_router.urls diff --git a/backend/social/hubs/views.py b/backend/social/hubs/views.py index dc8cfbf..a55f447 100644 --- a/backend/social/hubs/views.py +++ b/backend/social/hubs/views.py @@ -58,6 +58,7 @@ from .serializers import HubPermissionSerializer, HubSerializer, TagsSerializer, class HubViewSet(viewsets.ModelViewSet): serializer_class = HubSerializer permission_classes = [CanEditHub] + lookup_field = 'name' filterset_fields = ['is_public', 'owner'] search_fields = ['name', 'description'] ordering_fields = ['name'] @@ -223,7 +224,11 @@ class HubPermissionViewSet(viewsets.ModelViewSet): filterset_fields = ['user', 'changing_name', 'changing_description', 'changing_icon', 'changing_banner', 'managing_members', 'managing_posts', 'managing_chats'] def _get_hub(self): - hub_id = self.kwargs.get('hub_pk') or self.request.query_params.get('hub') + hub_id = ( + self.kwargs.get('hub_pk') + or self.request.query_params.get('hub') + or self.request.data.get('hub') + ) return Hub.objects.get(pk=hub_id) def get_queryset(self): @@ -279,10 +284,16 @@ class TagsViewSet(viewsets.ModelViewSet): ordering = ['name'] def _get_hub(self): - hub_id = self.kwargs.get('hub_pk') or self.request.query_params.get('hub') + hub_id = ( + self.kwargs.get('hub_pk') + or self.request.query_params.get('hub') + or self.request.data.get('hub') + ) return Hub.objects.get(pk=hub_id) def get_queryset(self): + if self.kwargs.get('pk'): + return Tags.objects.all() return Tags.objects.filter(hub=self._get_hub()) def perform_create(self, serializer): diff --git a/backend/social/posts/serializers.py b/backend/social/posts/serializers.py index f93a413..ceb34fd 100644 --- a/backend/social/posts/serializers.py +++ b/backend/social/posts/serializers.py @@ -2,6 +2,7 @@ from django.contrib.auth import get_user_model from rest_framework import serializers from .models import Post, PostContent, PostVote, PostSave from social.hubs.serializers import TagsSerializer +from social.hubs.models import Hub User = get_user_model() @@ -21,10 +22,17 @@ class PostContentSerializer(serializers.ModelSerializer): read_only_fields = ['mime_type'] +class PostHubSerializer(serializers.ModelSerializer): + class Meta: + model = Hub + fields = ['id', 'name', 'icon'] + + class PostSerializer(serializers.ModelSerializer): contents = PostContentSerializer(many=True, read_only=True) tags = TagsSerializer(many=True, read_only=True) author_detail = AuthorMinimalSerializer(source='author', read_only=True) + hub_detail = PostHubSerializer(source='hub', read_only=True) vote_score = serializers.SerializerMethodField() user_vote = serializers.SerializerMethodField() reply_count = serializers.IntegerField(read_only=True, default=0) @@ -36,7 +44,7 @@ class PostSerializer(serializers.ModelSerializer): fields = [ 'id', 'content', 'created_at', 'updated_at', 'author', 'author_detail', - 'hub', 'reply_to', + 'hub', 'hub_detail', 'reply_to', 'tags', 'contents', 'vote_score', 'user_vote', 'reply_count', 'is_saved', 'save_count', ] diff --git a/backend/social/posts/views.py b/backend/social/posts/views.py index b23f967..0f6e068 100644 --- a/backend/social/posts/views.py +++ b/backend/social/posts/views.py @@ -1,4 +1,8 @@ -from django.db.models import Count, Q +from datetime import timedelta + +from django.db.models import Count, Q, Sum +from django.db.models.functions import Coalesce +from django.utils import timezone from rest_framework import status, viewsets from rest_framework.decorators import action from rest_framework.exceptions import PermissionDenied, ValidationError @@ -8,7 +12,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 vontor_cz.pagination import CreatedCursorPagination, TopPostsCursorPagination from .models import Post, PostContent, PostVote, PostSave from .permissions import CanDeletePost, IsPostAuthorOnly from .serializers import PostSerializer, PostContentSerializer, PostVoteSerializer, TagAttachSerializer @@ -78,6 +82,51 @@ class PostViewSet(viewsets.ModelViewSet): def perform_create(self, serializer): serializer.save(author=self.request.user) + _TIME_WINDOWS = { + '1h': timedelta(hours=1), + '6h': timedelta(hours=6), + 'day': timedelta(days=1), + 'week': timedelta(weeks=1), + 'month': timedelta(days=30), + 'year': timedelta(days=365), + } + + def _get_cutoff(self, time_param): + """Return a datetime cutoff for the given time window, or None for 'all'.""" + if time_param in self._TIME_WINDOWS: + return timezone.now() - self._TIME_WINDOWS[time_param] + return None + + def list(self, request, *args, **kwargs): + sort = request.query_params.get('sort', 'newest') + time_param = request.query_params.get('time', 'all') + + qs = self.filter_queryset(self.get_queryset()) + + # Time filter + if time_param == 'custom': + start = request.query_params.get('start') + end = request.query_params.get('end') + if start: + qs = qs.filter(created_at__date__gte=start) + if end: + qs = qs.filter(created_at__date__lte=end) + else: + cutoff = self._get_cutoff(time_param) + if cutoff: + qs = qs.filter(created_at__gte=cutoff) + + if sort == 'top': + qs = qs.annotate(vote_score=Coalesce(Sum('votes__vote'), 0)).order_by('-vote_score', '-id') + paginator = TopPostsCursorPagination() + else: + qs = qs.order_by('-created_at') + paginator = CreatedCursorPagination() + + page = paginator.paginate_queryset(qs, request, view=self) + ser = PostSerializer(page, many=True, context={'request': request}) + return paginator.get_paginated_response(ser.data) + # ------------------------------------------------------------------ # Media upload action # ------------------------------------------------------------------ diff --git a/backend/templates/email/contact_me.html b/backend/templates/email/contact_me.html new file mode 100644 index 0000000..1f9d9ab --- /dev/null +++ b/backend/templates/email/contact_me.html @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + +
+

+ 📬 Nová zpráva z kontaktního formuláře +

+

+ Přišla poptávka přes vontor.cz +

+
+

Od

+

+ {{ client_email }} +

+
+

Zpráva

+

{{ message_content }}

+
+ + Odpovědět + +
diff --git a/backend/vontor_cz/pagination.py b/backend/vontor_cz/pagination.py index 790370d..c0cd6f5 100644 --- a/backend/vontor_cz/pagination.py +++ b/backend/vontor_cz/pagination.py @@ -18,6 +18,15 @@ class CreatedCursorPagination(CursorPagination): max_page_size = 100 +class TopPostsCursorPagination(CursorPagination): + """Cursor pagination ordered by vote score descending, then by id descending as tiebreaker.""" + page_size = 20 + ordering = ('-vote_score', '-id') + cursor_query_param = 'cursor' + page_size_query_param = 'page_size' + max_page_size = 100 + + class CreatedAscCursorPagination(CursorPagination): """Cursor pagination ordered by `created_at` (oldest first). diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f41b1e4..d65995f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -26,6 +26,8 @@ import FeedPage from "./pages/social/FeedPage"; import PostPage from "./pages/social/PostPage"; import HubsPage from "./pages/social/HubsPage"; import HubPage from "./pages/social/HubPage"; +import HubCreatePage from "./pages/social/hub/Create"; +import HubSettingsPage from "./pages/social/hub/Settings"; import ProfilePage from "./pages/social/ProfilePage"; import UserProfilePage from "./pages/social/UserProfilePage"; import SavedPage from "./pages/social/SavedPage"; @@ -63,7 +65,9 @@ export default function App() { } /> } /> } /> - } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/frontend/src/api/generated/private/hubs/hubs.ts b/frontend/src/api/generated/private/hubs/hubs.ts index fe9f2fc..4edbfd5 100644 --- a/frontend/src/api/generated/private/hubs/hubs.ts +++ b/frontend/src/api/generated/private/hubs/hubs.ts @@ -313,23 +313,23 @@ export const useApiSocialHubsCreate = ( /** * @summary Retrieve a hub */ -export const apiSocialHubsRetrieve = (id: string, signal?: AbortSignal) => { +export const apiSocialHubsRetrieve = (name: string, signal?: AbortSignal) => { return privateMutator({ - url: `/api/social/hubs/${id}/`, + url: `/api/social/hubs/${name}/`, method: "GET", signal, }); }; -export const getApiSocialHubsRetrieveQueryKey = (id: string) => { - return [`/api/social/hubs/${id}/`] as const; +export const getApiSocialHubsRetrieveQueryKey = (name: string) => { + return [`/api/social/hubs/${name}/`] as const; }; export const getApiSocialHubsRetrieveQueryOptions = < TData = Awaited>, TError = unknown, >( - id: string, + name: string, options?: { query?: Partial< UseQueryOptions< @@ -343,16 +343,16 @@ export const getApiSocialHubsRetrieveQueryOptions = < const { query: queryOptions } = options ?? {}; const queryKey = - queryOptions?.queryKey ?? getApiSocialHubsRetrieveQueryKey(id); + queryOptions?.queryKey ?? getApiSocialHubsRetrieveQueryKey(name); const queryFn: QueryFunction< Awaited> - > = ({ signal }) => apiSocialHubsRetrieve(id, signal); + > = ({ signal }) => apiSocialHubsRetrieve(name, signal); return { queryKey, queryFn, - enabled: !!id, + enabled: !!name, ...queryOptions, } as UseQueryOptions< Awaited>, @@ -370,7 +370,7 @@ export function useApiSocialHubsRetrieve< TData = Awaited>, TError = unknown, >( - id: string, + name: string, options: { query: Partial< UseQueryOptions< @@ -396,7 +396,7 @@ export function useApiSocialHubsRetrieve< TData = Awaited>, TError = unknown, >( - id: string, + name: string, options?: { query?: Partial< UseQueryOptions< @@ -422,7 +422,7 @@ export function useApiSocialHubsRetrieve< TData = Awaited>, TError = unknown, >( - id: string, + name: string, options?: { query?: Partial< UseQueryOptions< @@ -444,7 +444,7 @@ export function useApiSocialHubsRetrieve< TData = Awaited>, TError = unknown, >( - id: string, + name: string, options?: { query?: Partial< UseQueryOptions< @@ -458,7 +458,7 @@ export function useApiSocialHubsRetrieve< ): UseQueryResult & { queryKey: DataTag; } { - const queryOptions = getApiSocialHubsRetrieveQueryOptions(id, options); + const queryOptions = getApiSocialHubsRetrieveQueryOptions(name, options); const query = useQuery(queryOptions, queryClient) as UseQueryResult< TData, @@ -473,12 +473,12 @@ export function useApiSocialHubsRetrieve< * @summary Replace a hub */ export const apiSocialHubsUpdate = ( - id: string, + name: string, hub: NonReadonly, signal?: AbortSignal, ) => { return privateMutator({ - url: `/api/social/hubs/${id}/`, + url: `/api/social/hubs/${name}/`, method: "PUT", headers: { "Content-Type": "application/json" }, data: hub, @@ -493,13 +493,13 @@ export const getApiSocialHubsUpdateMutationOptions = < mutation?: UseMutationOptions< Awaited>, TError, - { id: string; data: NonReadonly }, + { name: string; data: NonReadonly }, TContext >; }): UseMutationOptions< Awaited>, TError, - { id: string; data: NonReadonly }, + { name: string; data: NonReadonly }, TContext > => { const mutationKey = ["apiSocialHubsUpdate"]; @@ -513,11 +513,11 @@ export const getApiSocialHubsUpdateMutationOptions = < const mutationFn: MutationFunction< Awaited>, - { id: string; data: NonReadonly } + { name: string; data: NonReadonly } > = (props) => { - const { id, data } = props ?? {}; + const { name, data } = props ?? {}; - return apiSocialHubsUpdate(id, data); + return apiSocialHubsUpdate(name, data); }; return { mutationFn, ...mutationOptions }; @@ -537,7 +537,7 @@ export const useApiSocialHubsUpdate = ( mutation?: UseMutationOptions< Awaited>, TError, - { id: string; data: NonReadonly }, + { name: string; data: NonReadonly }, TContext >; }, @@ -545,7 +545,7 @@ export const useApiSocialHubsUpdate = ( ): UseMutationResult< Awaited>, TError, - { id: string; data: NonReadonly }, + { name: string; data: NonReadonly }, TContext > => { return useMutation( @@ -558,12 +558,12 @@ export const useApiSocialHubsUpdate = ( * @summary Update a hub */ export const apiSocialHubsPartialUpdate = ( - id: string, + name: string, patchedHub: NonReadonly, signal?: AbortSignal, ) => { return privateMutator({ - url: `/api/social/hubs/${id}/`, + url: `/api/social/hubs/${name}/`, method: "PATCH", headers: { "Content-Type": "application/json" }, data: patchedHub, @@ -578,13 +578,13 @@ export const getApiSocialHubsPartialUpdateMutationOptions = < mutation?: UseMutationOptions< Awaited>, TError, - { id: string; data: NonReadonly }, + { name: string; data: NonReadonly }, TContext >; }): UseMutationOptions< Awaited>, TError, - { id: string; data: NonReadonly }, + { name: string; data: NonReadonly }, TContext > => { const mutationKey = ["apiSocialHubsPartialUpdate"]; @@ -598,11 +598,11 @@ export const getApiSocialHubsPartialUpdateMutationOptions = < const mutationFn: MutationFunction< Awaited>, - { id: string; data: NonReadonly } + { name: string; data: NonReadonly } > = (props) => { - const { id, data } = props ?? {}; + const { name, data } = props ?? {}; - return apiSocialHubsPartialUpdate(id, data); + return apiSocialHubsPartialUpdate(name, data); }; return { mutationFn, ...mutationOptions }; @@ -625,7 +625,7 @@ export const useApiSocialHubsPartialUpdate = < mutation?: UseMutationOptions< Awaited>, TError, - { id: string; data: NonReadonly }, + { name: string; data: NonReadonly }, TContext >; }, @@ -633,7 +633,7 @@ export const useApiSocialHubsPartialUpdate = < ): UseMutationResult< Awaited>, TError, - { id: string; data: NonReadonly }, + { name: string; data: NonReadonly }, TContext > => { return useMutation( @@ -645,9 +645,9 @@ export const useApiSocialHubsPartialUpdate = < * Soft-deletes the hub. Owner or admin only. * @summary Delete a hub */ -export const apiSocialHubsDestroy = (id: string, signal?: AbortSignal) => { +export const apiSocialHubsDestroy = (name: string, signal?: AbortSignal) => { return privateMutator({ - url: `/api/social/hubs/${id}/`, + url: `/api/social/hubs/${name}/`, method: "DELETE", signal, }); @@ -660,13 +660,13 @@ export const getApiSocialHubsDestroyMutationOptions = < mutation?: UseMutationOptions< Awaited>, TError, - { id: string }, + { name: string }, TContext >; }): UseMutationOptions< Awaited>, TError, - { id: string }, + { name: string }, TContext > => { const mutationKey = ["apiSocialHubsDestroy"]; @@ -680,11 +680,11 @@ export const getApiSocialHubsDestroyMutationOptions = < const mutationFn: MutationFunction< Awaited>, - { id: string } + { name: string } > = (props) => { - const { id } = props ?? {}; + const { name } = props ?? {}; - return apiSocialHubsDestroy(id); + return apiSocialHubsDestroy(name); }; return { mutationFn, ...mutationOptions }; @@ -704,7 +704,7 @@ export const useApiSocialHubsDestroy = ( mutation?: UseMutationOptions< Awaited>, TError, - { id: string }, + { name: string }, TContext >; }, @@ -712,7 +712,7 @@ export const useApiSocialHubsDestroy = ( ): UseMutationResult< Awaited>, TError, - { id: string }, + { name: string }, TContext > => { return useMutation( @@ -724,9 +724,9 @@ export const useApiSocialHubsDestroy = ( * Adds the authenticated user as a member. Private hubs reject this request. * @summary Join a hub */ -export const apiSocialHubsJoinCreate = (id: string, signal?: AbortSignal) => { +export const apiSocialHubsJoinCreate = (name: string, signal?: AbortSignal) => { return privateMutator({ - url: `/api/social/hubs/${id}/join/`, + url: `/api/social/hubs/${name}/join/`, method: "POST", signal, }); @@ -739,13 +739,13 @@ export const getApiSocialHubsJoinCreateMutationOptions = < mutation?: UseMutationOptions< Awaited>, TError, - { id: string }, + { name: string }, TContext >; }): UseMutationOptions< Awaited>, TError, - { id: string }, + { name: string }, TContext > => { const mutationKey = ["apiSocialHubsJoinCreate"]; @@ -759,11 +759,11 @@ export const getApiSocialHubsJoinCreateMutationOptions = < const mutationFn: MutationFunction< Awaited>, - { id: string } + { name: string } > = (props) => { - const { id } = props ?? {}; + const { name } = props ?? {}; - return apiSocialHubsJoinCreate(id); + return apiSocialHubsJoinCreate(name); }; return { mutationFn, ...mutationOptions }; @@ -786,7 +786,7 @@ export const useApiSocialHubsJoinCreate = < mutation?: UseMutationOptions< Awaited>, TError, - { id: string }, + { name: string }, TContext >; }, @@ -794,7 +794,7 @@ export const useApiSocialHubsJoinCreate = < ): UseMutationResult< Awaited>, TError, - { id: string }, + { name: string }, TContext > => { return useMutation( @@ -806,9 +806,12 @@ export const useApiSocialHubsJoinCreate = < * Removes the authenticated user from the hub's members. * @summary Leave a hub */ -export const apiSocialHubsLeaveCreate = (id: string, signal?: AbortSignal) => { +export const apiSocialHubsLeaveCreate = ( + name: string, + signal?: AbortSignal, +) => { return privateMutator({ - url: `/api/social/hubs/${id}/leave/`, + url: `/api/social/hubs/${name}/leave/`, method: "POST", signal, }); @@ -821,13 +824,13 @@ export const getApiSocialHubsLeaveCreateMutationOptions = < mutation?: UseMutationOptions< Awaited>, TError, - { id: string }, + { name: string }, TContext >; }): UseMutationOptions< Awaited>, TError, - { id: string }, + { name: string }, TContext > => { const mutationKey = ["apiSocialHubsLeaveCreate"]; @@ -841,11 +844,11 @@ export const getApiSocialHubsLeaveCreateMutationOptions = < const mutationFn: MutationFunction< Awaited>, - { id: string } + { name: string } > = (props) => { - const { id } = props ?? {}; + const { name } = props ?? {}; - return apiSocialHubsLeaveCreate(id); + return apiSocialHubsLeaveCreate(name); }; return { mutationFn, ...mutationOptions }; @@ -868,7 +871,7 @@ export const useApiSocialHubsLeaveCreate = < mutation?: UseMutationOptions< Awaited>, TError, - { id: string }, + { name: string }, TContext >; }, @@ -876,7 +879,7 @@ export const useApiSocialHubsLeaveCreate = < ): UseMutationResult< Awaited>, TError, - { id: string }, + { name: string }, TContext > => { return useMutation( @@ -889,11 +892,11 @@ export const useApiSocialHubsLeaveCreate = < * @summary Cancel ownership transfer */ export const apiSocialHubsTransferCancelCreate = ( - id: string, + name: string, signal?: AbortSignal, ) => { return privateMutator({ - url: `/api/social/hubs/${id}/transfer/cancel/`, + url: `/api/social/hubs/${name}/transfer/cancel/`, method: "POST", signal, }); @@ -906,13 +909,13 @@ export const getApiSocialHubsTransferCancelCreateMutationOptions = < mutation?: UseMutationOptions< Awaited>, TError, - { id: string }, + { name: string }, TContext >; }): UseMutationOptions< Awaited>, TError, - { id: string }, + { name: string }, TContext > => { const mutationKey = ["apiSocialHubsTransferCancelCreate"]; @@ -926,11 +929,11 @@ export const getApiSocialHubsTransferCancelCreateMutationOptions = < const mutationFn: MutationFunction< Awaited>, - { id: string } + { name: string } > = (props) => { - const { id } = props ?? {}; + const { name } = props ?? {}; - return apiSocialHubsTransferCancelCreate(id); + return apiSocialHubsTransferCancelCreate(name); }; return { mutationFn, ...mutationOptions }; @@ -953,7 +956,7 @@ export const useApiSocialHubsTransferCancelCreate = < mutation?: UseMutationOptions< Awaited>, TError, - { id: string }, + { name: string }, TContext >; }, @@ -961,7 +964,7 @@ export const useApiSocialHubsTransferCancelCreate = < ): UseMutationResult< Awaited>, TError, - { id: string }, + { name: string }, TContext > => { return useMutation( @@ -974,12 +977,12 @@ export const useApiSocialHubsTransferCancelCreate = < * @summary Initiate ownership transfer */ export const apiSocialHubsTransferInitiateCreate = ( - id: string, + name: string, transferInit: TransferInit, signal?: AbortSignal, ) => { return privateMutator({ - url: `/api/social/hubs/${id}/transfer/initiate/`, + url: `/api/social/hubs/${name}/transfer/initiate/`, method: "POST", headers: { "Content-Type": "application/json" }, data: transferInit, @@ -994,13 +997,13 @@ export const getApiSocialHubsTransferInitiateCreateMutationOptions = < mutation?: UseMutationOptions< Awaited>, TError, - { id: string; data: TransferInit }, + { name: string; data: TransferInit }, TContext >; }): UseMutationOptions< Awaited>, TError, - { id: string; data: TransferInit }, + { name: string; data: TransferInit }, TContext > => { const mutationKey = ["apiSocialHubsTransferInitiateCreate"]; @@ -1014,11 +1017,11 @@ export const getApiSocialHubsTransferInitiateCreateMutationOptions = < const mutationFn: MutationFunction< Awaited>, - { id: string; data: TransferInit } + { name: string; data: TransferInit } > = (props) => { - const { id, data } = props ?? {}; + const { name, data } = props ?? {}; - return apiSocialHubsTransferInitiateCreate(id, data); + return apiSocialHubsTransferInitiateCreate(name, data); }; return { mutationFn, ...mutationOptions }; @@ -1041,7 +1044,7 @@ export const useApiSocialHubsTransferInitiateCreate = < mutation?: UseMutationOptions< Awaited>, TError, - { id: string; data: TransferInit }, + { name: string; data: TransferInit }, TContext >; }, @@ -1049,7 +1052,7 @@ export const useApiSocialHubsTransferInitiateCreate = < ): UseMutationResult< Awaited>, TError, - { id: string; data: TransferInit }, + { name: string; data: TransferInit }, TContext > => { return useMutation( @@ -1062,12 +1065,12 @@ export const useApiSocialHubsTransferInitiateCreate = < * @summary Verify ownership transfer */ export const apiSocialHubsTransferVerifyCreate = ( - id: string, + name: string, transferVerify: TransferVerify, signal?: AbortSignal, ) => { return privateMutator({ - url: `/api/social/hubs/${id}/transfer/verify/`, + url: `/api/social/hubs/${name}/transfer/verify/`, method: "POST", headers: { "Content-Type": "application/json" }, data: transferVerify, @@ -1082,13 +1085,13 @@ export const getApiSocialHubsTransferVerifyCreateMutationOptions = < mutation?: UseMutationOptions< Awaited>, TError, - { id: string; data: TransferVerify }, + { name: string; data: TransferVerify }, TContext >; }): UseMutationOptions< Awaited>, TError, - { id: string; data: TransferVerify }, + { name: string; data: TransferVerify }, TContext > => { const mutationKey = ["apiSocialHubsTransferVerifyCreate"]; @@ -1102,11 +1105,11 @@ export const getApiSocialHubsTransferVerifyCreateMutationOptions = < const mutationFn: MutationFunction< Awaited>, - { id: string; data: TransferVerify } + { name: string; data: TransferVerify } > = (props) => { - const { id, data } = props ?? {}; + const { name, data } = props ?? {}; - return apiSocialHubsTransferVerifyCreate(id, data); + return apiSocialHubsTransferVerifyCreate(name, data); }; return { mutationFn, ...mutationOptions }; @@ -1129,7 +1132,7 @@ export const useApiSocialHubsTransferVerifyCreate = < mutation?: UseMutationOptions< Awaited>, TError, - { id: string; data: TransferVerify }, + { name: string; data: TransferVerify }, TContext >; }, @@ -1137,7 +1140,7 @@ export const useApiSocialHubsTransferVerifyCreate = < ): UseMutationResult< Awaited>, TError, - { id: string; data: TransferVerify }, + { name: string; data: TransferVerify }, TContext > => { return useMutation( diff --git a/frontend/src/api/generated/private/models/chat.ts b/frontend/src/api/generated/private/models/chat.ts index 0f22475..4f4ca43 100644 --- a/frontend/src/api/generated/private/models/chat.ts +++ b/frontend/src/api/generated/private/models/chat.ts @@ -4,6 +4,7 @@ * OpenAPI spec version: 0.0.0 */ import type { ChatTypeEnum } from "./chatTypeEnum"; +import type { MessageSender } from "./messageSender"; export interface Chat { readonly id: number; @@ -23,4 +24,5 @@ export interface Chat { readonly created_at: Date; readonly updated_at: Date; readonly unread_count: number; + readonly members_detail: readonly MessageSender[]; } diff --git a/frontend/src/api/generated/private/models/index.ts b/frontend/src/api/generated/private/models/index.ts index 6939f94..027d857 100644 --- a/frontend/src/api/generated/private/models/index.ts +++ b/frontend/src/api/generated/private/models/index.ts @@ -126,6 +126,7 @@ export * from "./paymentRead"; export * from "./platformCount"; export * from "./post"; export * from "./postContent"; +export * from "./postHub"; export * from "./postVote"; export * from "./product"; export * from "./productImage"; @@ -134,6 +135,7 @@ export * from "./productMiniForWishlist"; export * from "./qualityCount"; export * from "./reasonChoiceEnum"; export * from "./refund"; +export * from "./replyTo"; export * from "./reviewSerializerPublic"; export * from "./roleEnum"; export * from "./shippingMethodEnum"; diff --git a/frontend/src/api/generated/private/models/message.ts b/frontend/src/api/generated/private/models/message.ts index d822c44..e97c425 100644 --- a/frontend/src/api/generated/private/models/message.ts +++ b/frontend/src/api/generated/private/models/message.ts @@ -6,19 +6,13 @@ import type { MessageFile } from "./messageFile"; import type { MessageReaction } from "./messageReaction"; import type { MessageSender } from "./messageSender"; - -export interface ReplyToPreview { - readonly id: number; - content?: string; - readonly sender: MessageSender; -} +import type { ReplyTo } from "./replyTo"; export interface Message { readonly id: number; readonly chat: number; readonly sender: MessageSender; - /** @nullable */ - readonly reply_to: ReplyToPreview | null; + readonly reply_to: ReplyTo; content?: string; readonly is_edited: boolean; /** @nullable */ diff --git a/frontend/src/api/generated/private/models/patchedChat.ts b/frontend/src/api/generated/private/models/patchedChat.ts index 0bf062e..614e834 100644 --- a/frontend/src/api/generated/private/models/patchedChat.ts +++ b/frontend/src/api/generated/private/models/patchedChat.ts @@ -4,6 +4,7 @@ * OpenAPI spec version: 0.0.0 */ import type { ChatTypeEnum } from "./chatTypeEnum"; +import type { MessageSender } from "./messageSender"; export interface PatchedChat { readonly id?: number; @@ -23,4 +24,5 @@ export interface PatchedChat { readonly created_at?: Date; readonly updated_at?: Date; readonly unread_count?: number; + readonly members_detail?: readonly MessageSender[]; } diff --git a/frontend/src/api/generated/private/models/patchedMessage.ts b/frontend/src/api/generated/private/models/patchedMessage.ts index bb75e9d..6bebc41 100644 --- a/frontend/src/api/generated/private/models/patchedMessage.ts +++ b/frontend/src/api/generated/private/models/patchedMessage.ts @@ -6,13 +6,13 @@ import type { MessageFile } from "./messageFile"; import type { MessageReaction } from "./messageReaction"; import type { MessageSender } from "./messageSender"; +import type { ReplyTo } from "./replyTo"; export interface PatchedMessage { readonly id?: number; readonly chat?: number; readonly sender?: MessageSender; - /** @nullable */ - readonly reply_to?: number | null; + readonly reply_to?: ReplyTo; content?: string; readonly is_edited?: boolean; /** @nullable */ diff --git a/frontend/src/api/generated/private/models/patchedPost.ts b/frontend/src/api/generated/private/models/patchedPost.ts index f7cade1..f3e6b13 100644 --- a/frontend/src/api/generated/private/models/patchedPost.ts +++ b/frontend/src/api/generated/private/models/patchedPost.ts @@ -5,6 +5,7 @@ */ import type { AuthorMinimal } from "./authorMinimal"; import type { PostContent } from "./postContent"; +import type { PostHub } from "./postHub"; import type { Tags } from "./tags"; export interface PatchedPost { @@ -16,6 +17,7 @@ export interface PatchedPost { readonly author_detail?: AuthorMinimal; /** @nullable */ hub?: number | null; + readonly hub_detail?: PostHub; /** @nullable */ reply_to?: number | null; readonly tags?: readonly Tags[]; diff --git a/frontend/src/api/generated/private/models/post.ts b/frontend/src/api/generated/private/models/post.ts index 071edc2..aaf8e03 100644 --- a/frontend/src/api/generated/private/models/post.ts +++ b/frontend/src/api/generated/private/models/post.ts @@ -5,6 +5,7 @@ */ import type { AuthorMinimal } from "./authorMinimal"; import type { PostContent } from "./postContent"; +import type { PostHub } from "./postHub"; import type { Tags } from "./tags"; export interface Post { @@ -16,6 +17,7 @@ export interface Post { readonly author_detail: AuthorMinimal; /** @nullable */ hub?: number | null; + readonly hub_detail: PostHub; /** @nullable */ reply_to?: number | null; readonly tags: readonly Tags[]; diff --git a/frontend/src/api/generated/private/models/postHub.ts b/frontend/src/api/generated/private/models/postHub.ts new file mode 100644 index 0000000..21c9adc --- /dev/null +++ b/frontend/src/api/generated/private/models/postHub.ts @@ -0,0 +1,13 @@ +/** + * Generated by orval v8.8.0 🍺 + * Do not edit manually. + * OpenAPI spec version: 0.0.0 + */ + +export interface PostHub { + readonly id: number; + /** @maxLength 255 */ + name: string; + /** @nullable */ + icon?: string | null; +} diff --git a/frontend/src/api/generated/private/models/replyTo.ts b/frontend/src/api/generated/private/models/replyTo.ts new file mode 100644 index 0000000..2ddb48d --- /dev/null +++ b/frontend/src/api/generated/private/models/replyTo.ts @@ -0,0 +1,15 @@ +/** + * Generated by orval v8.8.0 🍺 + * Do not edit manually. + * OpenAPI spec version: 0.0.0 + */ +import type { MessageFile } from "./messageFile"; +import type { MessageSender } from "./messageSender"; + +export interface ReplyTo { + readonly id: number; + readonly content: string; + readonly sender: MessageSender; + readonly created_at: Date; + readonly media_files: readonly MessageFile[]; +} diff --git a/frontend/src/api/generated/public/models/chat.ts b/frontend/src/api/generated/public/models/chat.ts index 0f22475..4f4ca43 100644 --- a/frontend/src/api/generated/public/models/chat.ts +++ b/frontend/src/api/generated/public/models/chat.ts @@ -4,6 +4,7 @@ * OpenAPI spec version: 0.0.0 */ import type { ChatTypeEnum } from "./chatTypeEnum"; +import type { MessageSender } from "./messageSender"; export interface Chat { readonly id: number; @@ -23,4 +24,5 @@ export interface Chat { readonly created_at: Date; readonly updated_at: Date; readonly unread_count: number; + readonly members_detail: readonly MessageSender[]; } diff --git a/frontend/src/api/generated/public/models/index.ts b/frontend/src/api/generated/public/models/index.ts index 0378a13..80f288a 100644 --- a/frontend/src/api/generated/public/models/index.ts +++ b/frontend/src/api/generated/public/models/index.ts @@ -108,6 +108,7 @@ export * from "./paymentRead"; export * from "./platformCount"; export * from "./post"; export * from "./postContent"; +export * from "./postHub"; export * from "./postVote"; export * from "./product"; export * from "./productImage"; @@ -116,6 +117,7 @@ export * from "./productMiniForWishlist"; export * from "./qualityCount"; export * from "./reasonChoiceEnum"; export * from "./refund"; +export * from "./replyTo"; export * from "./reviewSerializerPublic"; export * from "./roleEnum"; export * from "./shippingMethodEnum"; diff --git a/frontend/src/api/generated/public/models/message.ts b/frontend/src/api/generated/public/models/message.ts index 21ae00b..e97c425 100644 --- a/frontend/src/api/generated/public/models/message.ts +++ b/frontend/src/api/generated/public/models/message.ts @@ -6,13 +6,13 @@ import type { MessageFile } from "./messageFile"; import type { MessageReaction } from "./messageReaction"; import type { MessageSender } from "./messageSender"; +import type { ReplyTo } from "./replyTo"; export interface Message { readonly id: number; readonly chat: number; readonly sender: MessageSender; - /** @nullable */ - readonly reply_to: number | null; + readonly reply_to: ReplyTo; content?: string; readonly is_edited: boolean; /** @nullable */ diff --git a/frontend/src/api/generated/public/models/patchedChat.ts b/frontend/src/api/generated/public/models/patchedChat.ts index 0bf062e..614e834 100644 --- a/frontend/src/api/generated/public/models/patchedChat.ts +++ b/frontend/src/api/generated/public/models/patchedChat.ts @@ -4,6 +4,7 @@ * OpenAPI spec version: 0.0.0 */ import type { ChatTypeEnum } from "./chatTypeEnum"; +import type { MessageSender } from "./messageSender"; export interface PatchedChat { readonly id?: number; @@ -23,4 +24,5 @@ export interface PatchedChat { readonly created_at?: Date; readonly updated_at?: Date; readonly unread_count?: number; + readonly members_detail?: readonly MessageSender[]; } diff --git a/frontend/src/api/generated/public/models/patchedMessage.ts b/frontend/src/api/generated/public/models/patchedMessage.ts index bb75e9d..6bebc41 100644 --- a/frontend/src/api/generated/public/models/patchedMessage.ts +++ b/frontend/src/api/generated/public/models/patchedMessage.ts @@ -6,13 +6,13 @@ import type { MessageFile } from "./messageFile"; import type { MessageReaction } from "./messageReaction"; import type { MessageSender } from "./messageSender"; +import type { ReplyTo } from "./replyTo"; export interface PatchedMessage { readonly id?: number; readonly chat?: number; readonly sender?: MessageSender; - /** @nullable */ - readonly reply_to?: number | null; + readonly reply_to?: ReplyTo; content?: string; readonly is_edited?: boolean; /** @nullable */ diff --git a/frontend/src/api/generated/public/models/patchedPost.ts b/frontend/src/api/generated/public/models/patchedPost.ts index f7cade1..f3e6b13 100644 --- a/frontend/src/api/generated/public/models/patchedPost.ts +++ b/frontend/src/api/generated/public/models/patchedPost.ts @@ -5,6 +5,7 @@ */ import type { AuthorMinimal } from "./authorMinimal"; import type { PostContent } from "./postContent"; +import type { PostHub } from "./postHub"; import type { Tags } from "./tags"; export interface PatchedPost { @@ -16,6 +17,7 @@ export interface PatchedPost { readonly author_detail?: AuthorMinimal; /** @nullable */ hub?: number | null; + readonly hub_detail?: PostHub; /** @nullable */ reply_to?: number | null; readonly tags?: readonly Tags[]; diff --git a/frontend/src/api/generated/public/models/post.ts b/frontend/src/api/generated/public/models/post.ts index 071edc2..aaf8e03 100644 --- a/frontend/src/api/generated/public/models/post.ts +++ b/frontend/src/api/generated/public/models/post.ts @@ -5,6 +5,7 @@ */ import type { AuthorMinimal } from "./authorMinimal"; import type { PostContent } from "./postContent"; +import type { PostHub } from "./postHub"; import type { Tags } from "./tags"; export interface Post { @@ -16,6 +17,7 @@ export interface Post { readonly author_detail: AuthorMinimal; /** @nullable */ hub?: number | null; + readonly hub_detail: PostHub; /** @nullable */ reply_to?: number | null; readonly tags: readonly Tags[]; diff --git a/frontend/src/api/generated/public/models/postHub.ts b/frontend/src/api/generated/public/models/postHub.ts new file mode 100644 index 0000000..21c9adc --- /dev/null +++ b/frontend/src/api/generated/public/models/postHub.ts @@ -0,0 +1,13 @@ +/** + * Generated by orval v8.8.0 🍺 + * Do not edit manually. + * OpenAPI spec version: 0.0.0 + */ + +export interface PostHub { + readonly id: number; + /** @maxLength 255 */ + name: string; + /** @nullable */ + icon?: string | null; +} diff --git a/frontend/src/api/generated/public/models/replyTo.ts b/frontend/src/api/generated/public/models/replyTo.ts new file mode 100644 index 0000000..2ddb48d --- /dev/null +++ b/frontend/src/api/generated/public/models/replyTo.ts @@ -0,0 +1,15 @@ +/** + * Generated by orval v8.8.0 🍺 + * Do not edit manually. + * OpenAPI spec version: 0.0.0 + */ +import type { MessageFile } from "./messageFile"; +import type { MessageSender } from "./messageSender"; + +export interface ReplyTo { + readonly id: number; + readonly content: string; + readonly sender: MessageSender; + readonly created_at: Date; + readonly media_files: readonly MessageFile[]; +} diff --git a/frontend/src/api/social/hubFeed.ts b/frontend/src/api/social/hubFeed.ts new file mode 100644 index 0000000..85624e4 --- /dev/null +++ b/frontend/src/api/social/hubFeed.ts @@ -0,0 +1,43 @@ +import { privateMutator } from "../privateClient"; +import type { Post } from "../generated/private/models/post"; +import type { CursorPaginated } from "./feed"; + +export type HubSortOption = "newest" | "top"; +export type HubTimeOption = "1h" | "6h" | "day" | "week" | "month" | "year" | "all" | "custom"; + +export interface HubPostsParams { + hub: number; + cursor?: string | null; + tag?: number | null; + sort?: HubSortOption; + time?: HubTimeOption; + start?: string; + end?: string; +} + +export const apiSocialHubPostsCursor = ( + params: HubPostsParams, + signal?: AbortSignal, +) => + privateMutator>({ + url: `/api/social/posts/`, + method: "GET", + params: { + hub: params.hub, + cursor: params.cursor ?? undefined, + tag: params.tag ?? undefined, + sort: params.sort ?? undefined, + time: params.time ?? undefined, + start: params.start ?? undefined, + end: params.end ?? undefined, + }, + signal, + }); + +export const hubPostsQueryKey = ( + hubId: number, + sort: HubSortOption, + time: HubTimeOption, + start?: string, + end?: string, +) => ["social", "hubs", hubId, "posts", sort, time, start ?? null, end ?? null] as const; diff --git a/frontend/src/components/home/ContactMe/ContactMeForm.tsx b/frontend/src/components/home/ContactMe/ContactMeForm.tsx index ff4387b..f4ff139 100644 --- a/frontend/src/components/home/ContactMe/ContactMeForm.tsx +++ b/frontend/src/components/home/ContactMe/ContactMeForm.tsx @@ -1,35 +1,50 @@ import React, { useState, useRef } from "react" import styles from "./contact-me.module.css" import { LuMousePointerClick } from "react-icons/lu"; +import { publicApi } from "@/api/publicClient"; export default function ContactMeForm() { - const [opened, setOpened] = useState(false) - const [contentMoveUp, setContentMoveUp] = useState(false) - const [openingBehind, setOpeningBehind] = useState(false) - // const [success, setSuccess] = useState(false) + const [opened, setOpened] = useState(true) + const [contentMoveUp, setContentMoveUp] = useState(true) + const [openingBehind, setOpeningBehind] = useState(true) + const [email, setEmail] = useState("") + const [message, setMessage] = useState("") + const [loading, setLoading] = useState(false) + const [success, setSuccess] = useState(false) + const [error, setError] = useState("") const openingRef = useRef(null) - function handleSubmit() { - // form submission logic here + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + setLoading(true) + setError("") + try { + await publicApi.post("/api/advertisement/contact-me/", { email, message, hp: "" }) + setSuccess(true) + setEmail("") + setMessage("") + } catch { + setError("Nepodařilo se odeslat zprávu. Zkuste to prosím znovu.") + } finally { + setLoading(false) + } } const toggleOpen = () => { if (!opened) { setOpened(true) setOpeningBehind(false) - setContentMoveUp(false) - // Wait for the rotate-opening animation to finish before moving content up - // The actual moveUp will be handled in onTransitionEnd + setContentMoveUp(false) } else { setContentMoveUp(false) setOpeningBehind(false) - setTimeout(() => setOpened(false), 1000) // match transition duration + setTimeout(() => setOpened(false), 1000) } } const handleTransitionEnd = (e: React.TransitionEvent) => { if (opened && e.propertyName === "transform") { - setContentMoveUp(true) + setContentMoveUp(true) setTimeout(() => setOpeningBehind(true), 10) } if (!opened && e.propertyName === "transform") { @@ -38,7 +53,6 @@ export default function ContactMeForm() { } return ( -
- +
-
- -