diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 8359675..22824d1 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -9,7 +9,8 @@ "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 \"^$\")" ] } } 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/views.py b/backend/social/chat/views.py index bd8e004..a09b79a 100644 --- a/backend/social/chat/views.py +++ b/backend/social/chat/views.py @@ -54,6 +54,10 @@ class ChatViewSet(viewsets.ModelViewSet): def perform_create(self, serializer): 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: @@ -192,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 @@ -233,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..00f9f4b 100644 --- a/backend/social/hubs/views.py +++ b/backend/social/hubs/views.py @@ -223,7 +223,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 +283,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/frontend/src/App.tsx b/frontend/src/App.tsx index f41b1e4..4ebbc2a 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/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..59e704c --- /dev/null +++ b/frontend/src/api/social/hubFeed.ts @@ -0,0 +1,23 @@ +import { privateMutator } from "../privateClient"; +import type { Post } from "../generated/private/models/post"; +import type { CursorPaginated } from "./feed"; + +export interface HubPostsParams { + hub: number; + cursor?: string | null; + tag?: number | null; +} + +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 }, + signal, + }); + +export const hubPostsQueryKey = (hubId: number, tag?: number) => + ["social", "hubs", hubId, "posts", tag ?? null] as const; diff --git a/frontend/src/components/social/chat/ChatMediaGallery.tsx b/frontend/src/components/social/chat/ChatMediaGallery.tsx index 7ea5371..ae9a36f 100644 --- a/frontend/src/components/social/chat/ChatMediaGallery.tsx +++ b/frontend/src/components/social/chat/ChatMediaGallery.tsx @@ -188,7 +188,8 @@ function ChatMediaItem({ src={url} muted playsInline - preload="none" + preload="metadata" + onLoadedMetadata={(e) => { (e.target as HTMLVideoElement).currentTime = 0.001; }} className="h-full w-full object-cover pointer-events-none" />
diff --git a/frontend/src/components/social/chat/Message.tsx b/frontend/src/components/social/chat/Message.tsx index 6067746..d19dd0d 100644 --- a/frontend/src/components/social/chat/Message.tsx +++ b/frontend/src/components/social/chat/Message.tsx @@ -10,9 +10,12 @@ import IconButton from "@/components/ui/IconButton"; import { useAuth } from "@/hooks/useAuth"; import { canDeleteMessage } from "@/hooks/usePermissions"; import { formatRelative } from "@/utils/relativeTime"; +import { mediaUrl } from "@/utils/mediaUrl"; import { apiSocialMessagesDestroy } from "@/api/generated/private/chat/chat"; type MemberInfo = { id: number; username: string; avatar: string | null }; +type ReplyMedia = { id: number; file: string; media_type: string }; +type ExtendedReply = { id: number; content?: string; sender?: MemberInfo; created_at?: string; media_files?: ReplyMedia[] }; interface Props { message: MessageModel; @@ -90,100 +93,153 @@ export default function Message({ message, chat, onReply, onReact, highlighted,
- {/* Row 1 – reply preview */} - {replyTo && ( -
-
e.key === "Enter" && handleJump()} - className={[ - "w-fit select-none rounded-lg border-l-2 px-3 py-1.5 text-xs", - replyDeleted ? "cursor-default" : "cursor-pointer", - isOwn - ? "border-white/30 bg-brand-lines/10 text-brand-text/50" - : "border-brand-accent/40 bg-brand-lines/10 text-brand-text/50", - ].join(" ")} - > - {replyDeleted ? ( - {t("chat.room.deletedMessage")} - ) : ( - - @{replyTo.sender?.username ?? "…"} - {(replyTo.content ?? "").slice(0, 80) || "…"} - - )} -
- - {!isOwn && sender?.username && ( - {sender.username} - )} -
- )} - - {/* Row 2 – avatar */} -
+ {/* Avatar */} +
- {/* Row 2 – bubble */} + {/* Message body column: reply → bubble → timestamp → reactions */}
- {hasText &&

{message.content}

} - {hasMedia && ( -
- + {/* Reply preview */} + {replyTo && (() => { + const ext = replyTo as unknown as ExtendedReply; + const firstMedia = ext.media_files?.[0]; + return ( +
e.key === "Enter" && handleJump()} + className={[ + "max-w-[260px] select-none rounded-lg border-l-2 px-3 py-1.5 opacity-50 transition-opacity hover:opacity-90", + replyDeleted ? "cursor-default" : "cursor-pointer", + isOwn + ? "border-white/30 bg-brand-lines/10" + : "border-brand-accent/40 bg-brand-lines/10", + ].join(" ")} + > + {replyDeleted ? ( + {t("chat.room.deletedMessage")} + ) : ( + <> + {/* Sender + time */} +
+ + {replyTo.sender?.username ?? "…"} + + {ext.created_at && ( + + · {formatRelative(ext.created_at)} + + )} +
+ {/* Content: thumbnail + text */} +
+ {firstMedia?.media_type === "IMAGE" && ( + + )} + {firstMedia && firstMedia.media_type !== "IMAGE" && ( + 📎 + )} + + {replyTo.content + ? replyTo.content.slice(0, 80) + : firstMedia + ? firstMedia.media_type === "IMAGE" ? "Fotka" : "Soubor" + : "…"} + +
+ + )} +
+ ); + })()} + + {/* Bubble */} +
+ {hasText &&

{message.content}

} + {hasMedia && ( +
+ +
+ )} +
+ + {/* Timestamp */} +
+ + {message.is_edited && · {t("chat.room.edited")}} +
+ + {/* Reactions */} + {Object.keys(reactionGroups).length > 0 && ( +
+ {Object.entries(reactionGroups).map(([emoji, { count, mine }]) => ( + onReact?.(message, ej)} + /> + ))}
)}
- {/* Row 2 – ⋮ trigger: actions slide left, ⋮ morphs to ✕ */} + {/* ⋮ trigger — always sits directly next to the bubble */}
- {/* Emoji picker — floats above the trigger row */} + {/* Emoji picker */} {pickerOpen && ( { onReact?.(message, emoji); }} onClose={() => setPickerOpen(false)} /> )} - {/* Actions slide out to the left */} + {/* Actions slide out away from the bubble */}
@@ -206,7 +262,7 @@ export default function Message({ message, chat, onReply, onReact, highlighted, )}
- {/* Toggle button — ⋮ rotates into ✕ */} + {/* Toggle ⋮ / ✕ */}
- - {/* Row 3 – timestamp */} -
- - {message.is_edited && · {t("chat.room.edited")}} -
- - {/* Row 4 – reactions grouped by emoji */} - {Object.keys(reactionGroups).length > 0 && ( -
- {Object.entries(reactionGroups).map(([emoji, { count, mine }]) => ( - onReact?.(message, ej)} - /> - ))} -
- )}
); } diff --git a/frontend/src/components/social/chat/MessageComposer.tsx b/frontend/src/components/social/chat/MessageComposer.tsx index af57fc6..5d90b02 100644 --- a/frontend/src/components/social/chat/MessageComposer.tsx +++ b/frontend/src/components/social/chat/MessageComposer.tsx @@ -1,8 +1,9 @@ import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { FiSend, FiX, FiPaperclip, FiFile } from "react-icons/fi"; +import { FiSend, FiX, FiPaperclip, FiFile, FiCornerUpLeft } from "react-icons/fi"; import type { Message } from "@/api/generated/private/models/message"; import Button from "@/components/ui/Button"; +import { mediaUrl } from "@/utils/mediaUrl"; interface Props { disabled?: boolean; @@ -98,16 +99,46 @@ export default function MessageComposer({ return (
- {replyTo && ( -
- - {t("chat.composer.replyTo", { snippet: (replyTo.content ?? "").slice(0, 60) })} - - -
- )} + {replyTo && (() => { + const replySender = replyTo.sender as { id: number; username: string; avatar: string | null } | null; + const replyMedia = (replyTo.media_files ?? []) as { id: number; file: string; media_type: string }[]; + const firstMedia = replyMedia[0]; + return ( +
+ + {firstMedia?.media_type === "IMAGE" && ( + + )} + {firstMedia && firstMedia.media_type !== "IMAGE" && ( + 📎 + )} +
+

+ @{replySender?.username ?? "…"} +

+

+ {replyTo.content + ? replyTo.content.slice(0, 80) + : firstMedia + ? firstMedia.media_type === "IMAGE" ? "Fotka" : "Soubor" + : "…"} +

+
+ +
+ ); + })()} {/* File previews */} {files.length > 0 && ( diff --git a/frontend/src/components/social/hub/HubCard.tsx b/frontend/src/components/social/hub/HubCard.tsx new file mode 100644 index 0000000..b132c5e --- /dev/null +++ b/frontend/src/components/social/hub/HubCard.tsx @@ -0,0 +1,39 @@ +import { Link } from "react-router-dom"; +import { FiUsers, FiCheck } from "react-icons/fi"; +import type { Hub } from "@/api/generated/private/models/hub"; +import Avatar from "@/components/ui/Avatar"; + +interface Props { + hub: Hub; + isMember: boolean; +} + +export default function HubCard({ hub, isMember }: Props) { + return ( + + +
+
+ {hub.name} + {isMember && ( + + Člen + + )} +
+ {hub.description && ( +

+ {hub.description} +

+ )} +
+ + {hub.members?.length ?? 0} členů +
+
+ + ); +} diff --git a/frontend/src/components/social/hub/HubHeader.tsx b/frontend/src/components/social/hub/HubHeader.tsx new file mode 100644 index 0000000..137a635 --- /dev/null +++ b/frontend/src/components/social/hub/HubHeader.tsx @@ -0,0 +1,83 @@ +import { Link } from "react-router-dom"; +import { FiSettings, FiUsers, FiGlobe, FiLock } from "react-icons/fi"; +import type { Hub } from "@/api/generated/private/models/hub"; +import Avatar from "@/components/ui/Avatar"; +import Button from "@/components/ui/Button"; +import { mediaUrl } from "@/utils/mediaUrl"; + +interface Props { + hub: Hub; + isMember: boolean; + isOwner: boolean; + isModerator: boolean; + joining?: boolean; + onJoin: () => void; + onLeave: () => void; +} + +export default function HubHeader({ hub, isMember, isOwner, isModerator, joining, onJoin, onLeave }: Props) { + const canManage = isOwner || isModerator; + + return ( +
+ {/* Banner */} +
+ {hub.banner && ( + + )} +
+ + {/* Avatar + info row */} +
+ {/* Avatar overlapping banner */} +
+ +
+ + {/* Action buttons — top-right */} +
+ {canManage && ( + + + + )} + {isOwner ? null : isMember ? ( + + ) : ( + + )} +
+ + {/* Name + meta — with left margin to clear the avatar */} +
+

+ h/{hub.name} +

+
+ + + {hub.members?.length ?? 0} členů + + + {hub.is_public ? : } + {hub.is_public ? "Veřejný" : "Soukromý"} + +
+ {hub.description && ( +

{hub.description}

+ )} +
+
+
+ ); +} diff --git a/frontend/src/components/social/hub/Tags.tsx b/frontend/src/components/social/hub/Tags.tsx index 0debb65..aee9860 100644 --- a/frontend/src/components/social/hub/Tags.tsx +++ b/frontend/src/components/social/hub/Tags.tsx @@ -1 +1,38 @@ -/* TAGS created inside hub */ \ No newline at end of file +import type { Tags as HubTag } from "@/api/generated/private/models/tags"; + +interface Props { + tags: HubTag[]; + activeTag?: number; + onSelect: (id: number | undefined) => void; +} + +export default function HubTags({ tags, activeTag, onSelect }: Props) { + if (tags.length === 0) return null; + + return ( +
+ {tags.map((tag) => { + const isActive = activeTag === tag.id; + return ( + + ); + })} +
+ ); +} diff --git a/frontend/src/components/social/posts/MediaGallery.tsx b/frontend/src/components/social/posts/MediaGallery.tsx index 0c359e8..30c082e 100644 --- a/frontend/src/components/social/posts/MediaGallery.tsx +++ b/frontend/src/components/social/posts/MediaGallery.tsx @@ -193,7 +193,7 @@ function MediaItem({ if (mime.startsWith("video/")) { return (
{ e.stopPropagation(); onOpen(); }}> -