Improve chat replies, hubs API & UI
Backend: enrich message reply data (include created_at and media_files) and ensure chat owners are treated as members; tighten/extend permission checks and message query filters; fix hub routers so moderators/tags routes are resolved before hub detail; accept hub id from request.data in hub permission/tag views; add PostHub serializer and expose hub_detail on posts. Frontend: update generated API models (postHub, replyTo, members_detail, hub_detail); add hub-related pages/routes and components (HubCard, HubHeader, Tags) and a hub posts feed hook; rework message UI and composer to show richer reply previews (media thumbnails, timestamps), adjust video preload to metadata; add tag selection UI to PostComposer and wire hub tags fetching. Also: minor UI/UX improvements and generated model exports updated to match backend changes.
This commit is contained in:
@@ -9,7 +9,8 @@
|
|||||||
"Bash(npx eslint *)",
|
"Bash(npx eslint *)",
|
||||||
"Bash(python -c ' *)",
|
"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)",
|
"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 \"^$\")"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,11 @@ class IsChatMember(IsAuthenticated):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def has_object_permission(self, request, view, obj):
|
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):
|
class CanManageChat(IsAuthenticated):
|
||||||
|
|||||||
@@ -43,11 +43,12 @@ class MessageHistorySerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
class ReplyToSerializer(serializers.ModelSerializer):
|
class ReplyToSerializer(serializers.ModelSerializer):
|
||||||
sender = MessageSenderSerializer(read_only=True)
|
sender = MessageSenderSerializer(read_only=True)
|
||||||
|
media_files = MessageFileSerializer(many=True, read_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Message
|
model = Message
|
||||||
fields = ['id', 'content', 'sender']
|
fields = ['id', 'content', 'sender', 'created_at', 'media_files']
|
||||||
read_only_fields = ['id', 'content', 'sender']
|
read_only_fields = ['id', 'content', 'sender', 'created_at', 'media_files']
|
||||||
|
|
||||||
|
|
||||||
class MessageSerializer(serializers.ModelSerializer):
|
class MessageSerializer(serializers.ModelSerializer):
|
||||||
@@ -69,23 +70,34 @@ class MessageSerializer(serializers.ModelSerializer):
|
|||||||
if not reply_to_id:
|
if not reply_to_id:
|
||||||
return None
|
return None
|
||||||
try:
|
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:
|
except Message.DoesNotExist:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
sender_data = None
|
sender_data = None
|
||||||
if msg.sender:
|
if msg.sender:
|
||||||
from django.conf import settings
|
|
||||||
avatar = (settings.MEDIA_URL + msg.sender.avatar.name) if msg.sender.avatar else None
|
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}
|
sender_data = {'id': msg.sender.id, 'username': msg.sender.username, 'avatar': avatar}
|
||||||
else:
|
else:
|
||||||
sender_data = {'id': 0, 'username': '…', 'avatar': None}
|
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 {
|
return {
|
||||||
'id': msg.id,
|
'id': msg.id,
|
||||||
# content=None signals the frontend to show the deleted tombstone
|
|
||||||
'content': None if msg.is_deleted else msg.content,
|
'content': None if msg.is_deleted else msg.content,
|
||||||
'sender': sender_data,
|
'sender': sender_data,
|
||||||
|
'created_at': msg.created_at.isoformat(),
|
||||||
|
'media_files': media_files_data,
|
||||||
}
|
}
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|||||||
@@ -54,6 +54,10 @@ class ChatViewSet(viewsets.ModelViewSet):
|
|||||||
|
|
||||||
def perform_create(self, serializer):
|
def perform_create(self, serializer):
|
||||||
chat = 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:
|
if chat.chat_type == Chat.ChatType.DM:
|
||||||
other = chat.members.exclude(pk=self.request.user.pk).first()
|
other = chat.members.exclude(pk=self.request.user.pk).first()
|
||||||
if other:
|
if other:
|
||||||
@@ -192,8 +196,7 @@ class MessageViewSet(viewsets.ModelViewSet):
|
|||||||
qs = Message.objects.select_related('sender', 'chat').prefetch_related('media_files', 'reactions')
|
qs = Message.objects.select_related('sender', 'chat').prefetch_related('media_files', 'reactions')
|
||||||
if user.is_superuser:
|
if user.is_superuser:
|
||||||
return qs
|
return qs
|
||||||
# Only messages from chats the user is a member of
|
return qs.filter(Q(chat__members=user) | Q(chat__owner=user)).distinct()
|
||||||
return qs.filter(chat__members=user)
|
|
||||||
|
|
||||||
def perform_update(self, serializer):
|
def perform_update(self, serializer):
|
||||||
message = serializer.instance
|
message = serializer.instance
|
||||||
@@ -233,7 +236,7 @@ class MessageViewSet(viewsets.ModelViewSet):
|
|||||||
ser.is_valid(raise_exception=True)
|
ser.is_valid(raise_exception=True)
|
||||||
|
|
||||||
chat = ser.validated_data['chat']
|
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.')
|
raise PermissionDenied('You are not a member of this chat.')
|
||||||
|
|
||||||
message = Message.objects.create(
|
message = Message.objects.create(
|
||||||
|
|||||||
@@ -1,9 +1,19 @@
|
|||||||
|
from django.urls import include, path
|
||||||
from rest_framework.routers import DefaultRouter
|
from rest_framework.routers import DefaultRouter
|
||||||
from .views import HubViewSet, HubPermissionViewSet, TagsViewSet
|
from .views import HubViewSet, HubPermissionViewSet, TagsViewSet
|
||||||
|
|
||||||
router = DefaultRouter()
|
hub_router = DefaultRouter()
|
||||||
router.register('', HubViewSet, basename='hub')
|
hub_router.register('', HubViewSet, basename='hub')
|
||||||
router.register('moderators', HubPermissionViewSet, basename='hub-moderator')
|
|
||||||
router.register('tags', TagsViewSet, basename='hub-tag')
|
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -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']
|
filterset_fields = ['user', 'changing_name', 'changing_description', 'changing_icon', 'changing_banner', 'managing_members', 'managing_posts', 'managing_chats']
|
||||||
|
|
||||||
def _get_hub(self):
|
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)
|
return Hub.objects.get(pk=hub_id)
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
@@ -279,10 +283,16 @@ class TagsViewSet(viewsets.ModelViewSet):
|
|||||||
ordering = ['name']
|
ordering = ['name']
|
||||||
|
|
||||||
def _get_hub(self):
|
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)
|
return Hub.objects.get(pk=hub_id)
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
|
if self.kwargs.get('pk'):
|
||||||
|
return Tags.objects.all()
|
||||||
return Tags.objects.filter(hub=self._get_hub())
|
return Tags.objects.filter(hub=self._get_hub())
|
||||||
|
|
||||||
def perform_create(self, serializer):
|
def perform_create(self, serializer):
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from django.contrib.auth import get_user_model
|
|||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from .models import Post, PostContent, PostVote, PostSave
|
from .models import Post, PostContent, PostVote, PostSave
|
||||||
from social.hubs.serializers import TagsSerializer
|
from social.hubs.serializers import TagsSerializer
|
||||||
|
from social.hubs.models import Hub
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
|
|
||||||
@@ -21,10 +22,17 @@ class PostContentSerializer(serializers.ModelSerializer):
|
|||||||
read_only_fields = ['mime_type']
|
read_only_fields = ['mime_type']
|
||||||
|
|
||||||
|
|
||||||
|
class PostHubSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = Hub
|
||||||
|
fields = ['id', 'name', 'icon']
|
||||||
|
|
||||||
|
|
||||||
class PostSerializer(serializers.ModelSerializer):
|
class PostSerializer(serializers.ModelSerializer):
|
||||||
contents = PostContentSerializer(many=True, read_only=True)
|
contents = PostContentSerializer(many=True, read_only=True)
|
||||||
tags = TagsSerializer(many=True, read_only=True)
|
tags = TagsSerializer(many=True, read_only=True)
|
||||||
author_detail = AuthorMinimalSerializer(source='author', read_only=True)
|
author_detail = AuthorMinimalSerializer(source='author', read_only=True)
|
||||||
|
hub_detail = PostHubSerializer(source='hub', read_only=True)
|
||||||
vote_score = serializers.SerializerMethodField()
|
vote_score = serializers.SerializerMethodField()
|
||||||
user_vote = serializers.SerializerMethodField()
|
user_vote = serializers.SerializerMethodField()
|
||||||
reply_count = serializers.IntegerField(read_only=True, default=0)
|
reply_count = serializers.IntegerField(read_only=True, default=0)
|
||||||
@@ -36,7 +44,7 @@ class PostSerializer(serializers.ModelSerializer):
|
|||||||
fields = [
|
fields = [
|
||||||
'id', 'content', 'created_at', 'updated_at',
|
'id', 'content', 'created_at', 'updated_at',
|
||||||
'author', 'author_detail',
|
'author', 'author_detail',
|
||||||
'hub', 'reply_to',
|
'hub', 'hub_detail', 'reply_to',
|
||||||
'tags', 'contents',
|
'tags', 'contents',
|
||||||
'vote_score', 'user_vote', 'reply_count', 'is_saved', 'save_count',
|
'vote_score', 'user_vote', 'reply_count', 'is_saved', 'save_count',
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ import FeedPage from "./pages/social/FeedPage";
|
|||||||
import PostPage from "./pages/social/PostPage";
|
import PostPage from "./pages/social/PostPage";
|
||||||
import HubsPage from "./pages/social/HubsPage";
|
import HubsPage from "./pages/social/HubsPage";
|
||||||
import HubPage from "./pages/social/HubPage";
|
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 ProfilePage from "./pages/social/ProfilePage";
|
||||||
import UserProfilePage from "./pages/social/UserProfilePage";
|
import UserProfilePage from "./pages/social/UserProfilePage";
|
||||||
import SavedPage from "./pages/social/SavedPage";
|
import SavedPage from "./pages/social/SavedPage";
|
||||||
@@ -63,7 +65,9 @@ export default function App() {
|
|||||||
<Route path="feed" element={<FeedPage />} />
|
<Route path="feed" element={<FeedPage />} />
|
||||||
<Route path="post/:id" element={<PostPage />} />
|
<Route path="post/:id" element={<PostPage />} />
|
||||||
<Route path="hubs" element={<HubsPage />} />
|
<Route path="hubs" element={<HubsPage />} />
|
||||||
|
<Route path="hub/create" element={<HubCreatePage />} />
|
||||||
<Route path="hub/:id" element={<HubPage />} />
|
<Route path="hub/:id" element={<HubPage />} />
|
||||||
|
<Route path="hub/:id/settings" element={<HubSettingsPage />} />
|
||||||
<Route path="profile" element={<ProfilePage />} />
|
<Route path="profile" element={<ProfilePage />} />
|
||||||
<Route path="profile/:username" element={<UserProfilePage />} />
|
<Route path="profile/:username" element={<UserProfilePage />} />
|
||||||
<Route path="saved" element={<SavedPage />} />
|
<Route path="saved" element={<SavedPage />} />
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
* OpenAPI spec version: 0.0.0
|
* OpenAPI spec version: 0.0.0
|
||||||
*/
|
*/
|
||||||
import type { ChatTypeEnum } from "./chatTypeEnum";
|
import type { ChatTypeEnum } from "./chatTypeEnum";
|
||||||
|
import type { MessageSender } from "./messageSender";
|
||||||
|
|
||||||
export interface Chat {
|
export interface Chat {
|
||||||
readonly id: number;
|
readonly id: number;
|
||||||
@@ -23,4 +24,5 @@ export interface Chat {
|
|||||||
readonly created_at: Date;
|
readonly created_at: Date;
|
||||||
readonly updated_at: Date;
|
readonly updated_at: Date;
|
||||||
readonly unread_count: number;
|
readonly unread_count: number;
|
||||||
|
readonly members_detail: readonly MessageSender[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ export * from "./paymentRead";
|
|||||||
export * from "./platformCount";
|
export * from "./platformCount";
|
||||||
export * from "./post";
|
export * from "./post";
|
||||||
export * from "./postContent";
|
export * from "./postContent";
|
||||||
|
export * from "./postHub";
|
||||||
export * from "./postVote";
|
export * from "./postVote";
|
||||||
export * from "./product";
|
export * from "./product";
|
||||||
export * from "./productImage";
|
export * from "./productImage";
|
||||||
@@ -134,6 +135,7 @@ export * from "./productMiniForWishlist";
|
|||||||
export * from "./qualityCount";
|
export * from "./qualityCount";
|
||||||
export * from "./reasonChoiceEnum";
|
export * from "./reasonChoiceEnum";
|
||||||
export * from "./refund";
|
export * from "./refund";
|
||||||
|
export * from "./replyTo";
|
||||||
export * from "./reviewSerializerPublic";
|
export * from "./reviewSerializerPublic";
|
||||||
export * from "./roleEnum";
|
export * from "./roleEnum";
|
||||||
export * from "./shippingMethodEnum";
|
export * from "./shippingMethodEnum";
|
||||||
|
|||||||
@@ -6,19 +6,13 @@
|
|||||||
import type { MessageFile } from "./messageFile";
|
import type { MessageFile } from "./messageFile";
|
||||||
import type { MessageReaction } from "./messageReaction";
|
import type { MessageReaction } from "./messageReaction";
|
||||||
import type { MessageSender } from "./messageSender";
|
import type { MessageSender } from "./messageSender";
|
||||||
|
import type { ReplyTo } from "./replyTo";
|
||||||
export interface ReplyToPreview {
|
|
||||||
readonly id: number;
|
|
||||||
content?: string;
|
|
||||||
readonly sender: MessageSender;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Message {
|
export interface Message {
|
||||||
readonly id: number;
|
readonly id: number;
|
||||||
readonly chat: number;
|
readonly chat: number;
|
||||||
readonly sender: MessageSender;
|
readonly sender: MessageSender;
|
||||||
/** @nullable */
|
readonly reply_to: ReplyTo;
|
||||||
readonly reply_to: ReplyToPreview | null;
|
|
||||||
content?: string;
|
content?: string;
|
||||||
readonly is_edited: boolean;
|
readonly is_edited: boolean;
|
||||||
/** @nullable */
|
/** @nullable */
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
* OpenAPI spec version: 0.0.0
|
* OpenAPI spec version: 0.0.0
|
||||||
*/
|
*/
|
||||||
import type { ChatTypeEnum } from "./chatTypeEnum";
|
import type { ChatTypeEnum } from "./chatTypeEnum";
|
||||||
|
import type { MessageSender } from "./messageSender";
|
||||||
|
|
||||||
export interface PatchedChat {
|
export interface PatchedChat {
|
||||||
readonly id?: number;
|
readonly id?: number;
|
||||||
@@ -23,4 +24,5 @@ export interface PatchedChat {
|
|||||||
readonly created_at?: Date;
|
readonly created_at?: Date;
|
||||||
readonly updated_at?: Date;
|
readonly updated_at?: Date;
|
||||||
readonly unread_count?: number;
|
readonly unread_count?: number;
|
||||||
|
readonly members_detail?: readonly MessageSender[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,13 +6,13 @@
|
|||||||
import type { MessageFile } from "./messageFile";
|
import type { MessageFile } from "./messageFile";
|
||||||
import type { MessageReaction } from "./messageReaction";
|
import type { MessageReaction } from "./messageReaction";
|
||||||
import type { MessageSender } from "./messageSender";
|
import type { MessageSender } from "./messageSender";
|
||||||
|
import type { ReplyTo } from "./replyTo";
|
||||||
|
|
||||||
export interface PatchedMessage {
|
export interface PatchedMessage {
|
||||||
readonly id?: number;
|
readonly id?: number;
|
||||||
readonly chat?: number;
|
readonly chat?: number;
|
||||||
readonly sender?: MessageSender;
|
readonly sender?: MessageSender;
|
||||||
/** @nullable */
|
readonly reply_to?: ReplyTo;
|
||||||
readonly reply_to?: number | null;
|
|
||||||
content?: string;
|
content?: string;
|
||||||
readonly is_edited?: boolean;
|
readonly is_edited?: boolean;
|
||||||
/** @nullable */
|
/** @nullable */
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
import type { AuthorMinimal } from "./authorMinimal";
|
import type { AuthorMinimal } from "./authorMinimal";
|
||||||
import type { PostContent } from "./postContent";
|
import type { PostContent } from "./postContent";
|
||||||
|
import type { PostHub } from "./postHub";
|
||||||
import type { Tags } from "./tags";
|
import type { Tags } from "./tags";
|
||||||
|
|
||||||
export interface PatchedPost {
|
export interface PatchedPost {
|
||||||
@@ -16,6 +17,7 @@ export interface PatchedPost {
|
|||||||
readonly author_detail?: AuthorMinimal;
|
readonly author_detail?: AuthorMinimal;
|
||||||
/** @nullable */
|
/** @nullable */
|
||||||
hub?: number | null;
|
hub?: number | null;
|
||||||
|
readonly hub_detail?: PostHub;
|
||||||
/** @nullable */
|
/** @nullable */
|
||||||
reply_to?: number | null;
|
reply_to?: number | null;
|
||||||
readonly tags?: readonly Tags[];
|
readonly tags?: readonly Tags[];
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
import type { AuthorMinimal } from "./authorMinimal";
|
import type { AuthorMinimal } from "./authorMinimal";
|
||||||
import type { PostContent } from "./postContent";
|
import type { PostContent } from "./postContent";
|
||||||
|
import type { PostHub } from "./postHub";
|
||||||
import type { Tags } from "./tags";
|
import type { Tags } from "./tags";
|
||||||
|
|
||||||
export interface Post {
|
export interface Post {
|
||||||
@@ -16,6 +17,7 @@ export interface Post {
|
|||||||
readonly author_detail: AuthorMinimal;
|
readonly author_detail: AuthorMinimal;
|
||||||
/** @nullable */
|
/** @nullable */
|
||||||
hub?: number | null;
|
hub?: number | null;
|
||||||
|
readonly hub_detail: PostHub;
|
||||||
/** @nullable */
|
/** @nullable */
|
||||||
reply_to?: number | null;
|
reply_to?: number | null;
|
||||||
readonly tags: readonly Tags[];
|
readonly tags: readonly Tags[];
|
||||||
|
|||||||
13
frontend/src/api/generated/private/models/postHub.ts
Normal file
13
frontend/src/api/generated/private/models/postHub.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
15
frontend/src/api/generated/private/models/replyTo.ts
Normal file
15
frontend/src/api/generated/private/models/replyTo.ts
Normal file
@@ -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[];
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
* OpenAPI spec version: 0.0.0
|
* OpenAPI spec version: 0.0.0
|
||||||
*/
|
*/
|
||||||
import type { ChatTypeEnum } from "./chatTypeEnum";
|
import type { ChatTypeEnum } from "./chatTypeEnum";
|
||||||
|
import type { MessageSender } from "./messageSender";
|
||||||
|
|
||||||
export interface Chat {
|
export interface Chat {
|
||||||
readonly id: number;
|
readonly id: number;
|
||||||
@@ -23,4 +24,5 @@ export interface Chat {
|
|||||||
readonly created_at: Date;
|
readonly created_at: Date;
|
||||||
readonly updated_at: Date;
|
readonly updated_at: Date;
|
||||||
readonly unread_count: number;
|
readonly unread_count: number;
|
||||||
|
readonly members_detail: readonly MessageSender[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ export * from "./paymentRead";
|
|||||||
export * from "./platformCount";
|
export * from "./platformCount";
|
||||||
export * from "./post";
|
export * from "./post";
|
||||||
export * from "./postContent";
|
export * from "./postContent";
|
||||||
|
export * from "./postHub";
|
||||||
export * from "./postVote";
|
export * from "./postVote";
|
||||||
export * from "./product";
|
export * from "./product";
|
||||||
export * from "./productImage";
|
export * from "./productImage";
|
||||||
@@ -116,6 +117,7 @@ export * from "./productMiniForWishlist";
|
|||||||
export * from "./qualityCount";
|
export * from "./qualityCount";
|
||||||
export * from "./reasonChoiceEnum";
|
export * from "./reasonChoiceEnum";
|
||||||
export * from "./refund";
|
export * from "./refund";
|
||||||
|
export * from "./replyTo";
|
||||||
export * from "./reviewSerializerPublic";
|
export * from "./reviewSerializerPublic";
|
||||||
export * from "./roleEnum";
|
export * from "./roleEnum";
|
||||||
export * from "./shippingMethodEnum";
|
export * from "./shippingMethodEnum";
|
||||||
|
|||||||
@@ -6,13 +6,13 @@
|
|||||||
import type { MessageFile } from "./messageFile";
|
import type { MessageFile } from "./messageFile";
|
||||||
import type { MessageReaction } from "./messageReaction";
|
import type { MessageReaction } from "./messageReaction";
|
||||||
import type { MessageSender } from "./messageSender";
|
import type { MessageSender } from "./messageSender";
|
||||||
|
import type { ReplyTo } from "./replyTo";
|
||||||
|
|
||||||
export interface Message {
|
export interface Message {
|
||||||
readonly id: number;
|
readonly id: number;
|
||||||
readonly chat: number;
|
readonly chat: number;
|
||||||
readonly sender: MessageSender;
|
readonly sender: MessageSender;
|
||||||
/** @nullable */
|
readonly reply_to: ReplyTo;
|
||||||
readonly reply_to: number | null;
|
|
||||||
content?: string;
|
content?: string;
|
||||||
readonly is_edited: boolean;
|
readonly is_edited: boolean;
|
||||||
/** @nullable */
|
/** @nullable */
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
* OpenAPI spec version: 0.0.0
|
* OpenAPI spec version: 0.0.0
|
||||||
*/
|
*/
|
||||||
import type { ChatTypeEnum } from "./chatTypeEnum";
|
import type { ChatTypeEnum } from "./chatTypeEnum";
|
||||||
|
import type { MessageSender } from "./messageSender";
|
||||||
|
|
||||||
export interface PatchedChat {
|
export interface PatchedChat {
|
||||||
readonly id?: number;
|
readonly id?: number;
|
||||||
@@ -23,4 +24,5 @@ export interface PatchedChat {
|
|||||||
readonly created_at?: Date;
|
readonly created_at?: Date;
|
||||||
readonly updated_at?: Date;
|
readonly updated_at?: Date;
|
||||||
readonly unread_count?: number;
|
readonly unread_count?: number;
|
||||||
|
readonly members_detail?: readonly MessageSender[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,13 +6,13 @@
|
|||||||
import type { MessageFile } from "./messageFile";
|
import type { MessageFile } from "./messageFile";
|
||||||
import type { MessageReaction } from "./messageReaction";
|
import type { MessageReaction } from "./messageReaction";
|
||||||
import type { MessageSender } from "./messageSender";
|
import type { MessageSender } from "./messageSender";
|
||||||
|
import type { ReplyTo } from "./replyTo";
|
||||||
|
|
||||||
export interface PatchedMessage {
|
export interface PatchedMessage {
|
||||||
readonly id?: number;
|
readonly id?: number;
|
||||||
readonly chat?: number;
|
readonly chat?: number;
|
||||||
readonly sender?: MessageSender;
|
readonly sender?: MessageSender;
|
||||||
/** @nullable */
|
readonly reply_to?: ReplyTo;
|
||||||
readonly reply_to?: number | null;
|
|
||||||
content?: string;
|
content?: string;
|
||||||
readonly is_edited?: boolean;
|
readonly is_edited?: boolean;
|
||||||
/** @nullable */
|
/** @nullable */
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
import type { AuthorMinimal } from "./authorMinimal";
|
import type { AuthorMinimal } from "./authorMinimal";
|
||||||
import type { PostContent } from "./postContent";
|
import type { PostContent } from "./postContent";
|
||||||
|
import type { PostHub } from "./postHub";
|
||||||
import type { Tags } from "./tags";
|
import type { Tags } from "./tags";
|
||||||
|
|
||||||
export interface PatchedPost {
|
export interface PatchedPost {
|
||||||
@@ -16,6 +17,7 @@ export interface PatchedPost {
|
|||||||
readonly author_detail?: AuthorMinimal;
|
readonly author_detail?: AuthorMinimal;
|
||||||
/** @nullable */
|
/** @nullable */
|
||||||
hub?: number | null;
|
hub?: number | null;
|
||||||
|
readonly hub_detail?: PostHub;
|
||||||
/** @nullable */
|
/** @nullable */
|
||||||
reply_to?: number | null;
|
reply_to?: number | null;
|
||||||
readonly tags?: readonly Tags[];
|
readonly tags?: readonly Tags[];
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
import type { AuthorMinimal } from "./authorMinimal";
|
import type { AuthorMinimal } from "./authorMinimal";
|
||||||
import type { PostContent } from "./postContent";
|
import type { PostContent } from "./postContent";
|
||||||
|
import type { PostHub } from "./postHub";
|
||||||
import type { Tags } from "./tags";
|
import type { Tags } from "./tags";
|
||||||
|
|
||||||
export interface Post {
|
export interface Post {
|
||||||
@@ -16,6 +17,7 @@ export interface Post {
|
|||||||
readonly author_detail: AuthorMinimal;
|
readonly author_detail: AuthorMinimal;
|
||||||
/** @nullable */
|
/** @nullable */
|
||||||
hub?: number | null;
|
hub?: number | null;
|
||||||
|
readonly hub_detail: PostHub;
|
||||||
/** @nullable */
|
/** @nullable */
|
||||||
reply_to?: number | null;
|
reply_to?: number | null;
|
||||||
readonly tags: readonly Tags[];
|
readonly tags: readonly Tags[];
|
||||||
|
|||||||
13
frontend/src/api/generated/public/models/postHub.ts
Normal file
13
frontend/src/api/generated/public/models/postHub.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
15
frontend/src/api/generated/public/models/replyTo.ts
Normal file
15
frontend/src/api/generated/public/models/replyTo.ts
Normal file
@@ -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[];
|
||||||
|
}
|
||||||
23
frontend/src/api/social/hubFeed.ts
Normal file
23
frontend/src/api/social/hubFeed.ts
Normal file
@@ -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<CursorPaginated<Post>>({
|
||||||
|
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;
|
||||||
@@ -188,7 +188,8 @@ function ChatMediaItem({
|
|||||||
src={url}
|
src={url}
|
||||||
muted
|
muted
|
||||||
playsInline
|
playsInline
|
||||||
preload="none"
|
preload="metadata"
|
||||||
|
onLoadedMetadata={(e) => { (e.target as HTMLVideoElement).currentTime = 0.001; }}
|
||||||
className="h-full w-full object-cover pointer-events-none"
|
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="absolute inset-0 flex items-center justify-center bg-black/20">
|
||||||
|
|||||||
@@ -10,9 +10,12 @@ import IconButton from "@/components/ui/IconButton";
|
|||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
import { canDeleteMessage } from "@/hooks/usePermissions";
|
import { canDeleteMessage } from "@/hooks/usePermissions";
|
||||||
import { formatRelative } from "@/utils/relativeTime";
|
import { formatRelative } from "@/utils/relativeTime";
|
||||||
|
import { mediaUrl } from "@/utils/mediaUrl";
|
||||||
import { apiSocialMessagesDestroy } from "@/api/generated/private/chat/chat";
|
import { apiSocialMessagesDestroy } from "@/api/generated/private/chat/chat";
|
||||||
|
|
||||||
type MemberInfo = { id: number; username: string; avatar: string | null };
|
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 {
|
interface Props {
|
||||||
message: MessageModel;
|
message: MessageModel;
|
||||||
@@ -90,100 +93,153 @@ export default function Message({ message, chat, onReply, onReact, highlighted,
|
|||||||
<div
|
<div
|
||||||
id={`msg-${message.id}`}
|
id={`msg-${message.id}`}
|
||||||
className={[
|
className={[
|
||||||
"group grid gap-x-2 gap-y-0 px-4 py-1.5",
|
"group flex items-end gap-2 px-4 py-1.5",
|
||||||
isOwn
|
isOwn ? "flex-row-reverse" : "",
|
||||||
? "grid-cols-[1fr_auto_28px]"
|
|
||||||
: "grid-cols-[28px_minmax(0,70%)_auto]",
|
|
||||||
].join(" ")}
|
].join(" ")}
|
||||||
>
|
>
|
||||||
{/* Row 1 – reply preview */}
|
{/* Avatar */}
|
||||||
{replyTo && (
|
<div className="shrink-0 self-start mt-1">
|
||||||
<div
|
|
||||||
className={[
|
|
||||||
"col-start-2 row-start-1 flex items-center gap-2",
|
|
||||||
"opacity-50 transition-opacity hover:opacity-90",
|
|
||||||
isOwn ? "justify-end" : "",
|
|
||||||
].join(" ")}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
role={replyDeleted ? undefined : "button"}
|
|
||||||
tabIndex={replyDeleted ? undefined : 0}
|
|
||||||
onClick={replyDeleted ? undefined : handleJump}
|
|
||||||
onKeyDown={replyDeleted ? undefined : (e) => 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 ? (
|
|
||||||
<span className="whitespace-nowrap italic text-brand-text/40">{t("chat.room.deletedMessage")}</span>
|
|
||||||
) : (
|
|
||||||
<span>
|
|
||||||
<span className="font-bold text-brand-accent/80">@{replyTo.sender?.username ?? "…"}</span>
|
|
||||||
<span className="ml-1 italic text-brand-text/60">{(replyTo.content ?? "").slice(0, 80) || "…"}</span>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<FiCornerUpLeft size={18} className="mr-3 shrink-0 text-brand-accent/50" />
|
|
||||||
{!isOwn && sender?.username && (
|
|
||||||
<span className="shrink-0 text-[11px] font-medium text-brand-accent/80">{sender.username}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Row 2 – avatar */}
|
|
||||||
<div className={`row-start-2 self-start mt-1 ${isOwn ? "col-start-3" : "col-start-1"}`}>
|
|
||||||
<Avatar name={sender?.username} src={sender?.avatar} size={28} />
|
<Avatar name={sender?.username} src={sender?.avatar} size={28} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Row 2 – bubble */}
|
{/* Message body column: reply → bubble → timestamp → reactions */}
|
||||||
<div
|
<div
|
||||||
className={[
|
className={[
|
||||||
"col-start-2 row-start-2 w-fit overflow-hidden rounded-2xl text-sm",
|
"flex min-w-0 max-w-[70%] flex-col gap-0.5",
|
||||||
highlighted ? "msg-flash" : "",
|
isOwn ? "items-end" : "items-start",
|
||||||
!hasMedia ? "max-w-full wrap-break-word whitespace-pre-wrap" : "max-w-xs",
|
|
||||||
hasText ? "glass" : "",
|
|
||||||
hasText && !hasMedia ? "px-3 py-2" : "",
|
|
||||||
hasText && hasMedia ? "px-3 pt-2 pb-0" : "",
|
|
||||||
isOwn
|
|
||||||
? `rounded-br-sm ${hasText ? "bg-brand-accent text-brand-bg" : ""}`
|
|
||||||
: `rounded-bl-sm ${hasText ? "bg-brand-bgLight/70 text-brand-text" : ""}`,
|
|
||||||
].join(" ")}
|
].join(" ")}
|
||||||
>
|
>
|
||||||
{hasText && <p className="wrap-break-word whitespace-pre-wrap">{message.content}</p>}
|
{/* Reply preview */}
|
||||||
{hasMedia && (
|
{replyTo && (() => {
|
||||||
<div className={hasText ? "-mx-3 mt-2" : ""}>
|
const ext = replyTo as unknown as ExtendedReply;
|
||||||
<ChatMediaGallery files={message.media_files} />
|
const firstMedia = ext.media_files?.[0];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role={replyDeleted ? undefined : "button"}
|
||||||
|
tabIndex={replyDeleted ? undefined : 0}
|
||||||
|
onClick={replyDeleted ? undefined : handleJump}
|
||||||
|
onKeyDown={replyDeleted ? undefined : (e) => 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 ? (
|
||||||
|
<span className="text-xs italic text-brand-text/40">{t("chat.room.deletedMessage")}</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Sender + time */}
|
||||||
|
<div className="flex items-center gap-1.5 mb-0.5">
|
||||||
|
<span className="text-xs font-semibold text-brand-accent/80 truncate">
|
||||||
|
{replyTo.sender?.username ?? "…"}
|
||||||
|
</span>
|
||||||
|
{ext.created_at && (
|
||||||
|
<span className="shrink-0 text-[10px] text-brand-text/40">
|
||||||
|
· {formatRelative(ext.created_at)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Content: thumbnail + text */}
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{firstMedia?.media_type === "IMAGE" && (
|
||||||
|
<img
|
||||||
|
src={mediaUrl(firstMedia.file)}
|
||||||
|
className="h-8 w-8 shrink-0 rounded object-cover opacity-80"
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{firstMedia && firstMedia.media_type !== "IMAGE" && (
|
||||||
|
<span className="shrink-0 text-[11px] text-brand-text/50">📎</span>
|
||||||
|
)}
|
||||||
|
<span className="truncate text-xs italic text-brand-text/50">
|
||||||
|
{replyTo.content
|
||||||
|
? replyTo.content.slice(0, 80)
|
||||||
|
: firstMedia
|
||||||
|
? firstMedia.media_type === "IMAGE" ? "Fotka" : "Soubor"
|
||||||
|
: "…"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{/* Bubble */}
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
"w-fit overflow-hidden rounded-2xl text-sm",
|
||||||
|
highlighted ? "msg-flash" : "",
|
||||||
|
!hasMedia ? "max-w-full wrap-break-word whitespace-pre-wrap" : "max-w-xs",
|
||||||
|
hasText ? "glass" : "",
|
||||||
|
hasText && !hasMedia ? "px-3 py-2" : "",
|
||||||
|
hasText && hasMedia ? "px-3 pt-2 pb-0" : "",
|
||||||
|
isOwn
|
||||||
|
? `rounded-br-sm ${hasText ? "bg-brand-accent text-brand-bg" : ""}`
|
||||||
|
: `rounded-bl-sm ${hasText ? "bg-brand-bgLight/70 text-brand-text" : ""}`,
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
{hasText && <p className="wrap-break-word whitespace-pre-wrap">{message.content}</p>}
|
||||||
|
{hasMedia && (
|
||||||
|
<div className={hasText ? "-mx-3 mt-2" : ""}>
|
||||||
|
<ChatMediaGallery files={message.media_files} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Timestamp */}
|
||||||
|
<div className="flex items-center gap-2 text-[11px] text-brand-text/50">
|
||||||
|
<time dateTime={String(message.created_at)}>{formatRelative(message.created_at)}</time>
|
||||||
|
{message.is_edited && <span>· {t("chat.room.edited")}</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reactions */}
|
||||||
|
{Object.keys(reactionGroups).length > 0 && (
|
||||||
|
<div className={["flex flex-wrap gap-1", isOwn ? "justify-end" : ""].join(" ")}>
|
||||||
|
{Object.entries(reactionGroups).map(([emoji, { count, mine }]) => (
|
||||||
|
<ReactionPill
|
||||||
|
key={emoji}
|
||||||
|
emoji={emoji}
|
||||||
|
count={count}
|
||||||
|
mine={mine}
|
||||||
|
allGroups={reactionGroups}
|
||||||
|
currentUserId={user?.id}
|
||||||
|
isOwn={isOwn}
|
||||||
|
onRemove={(ej) => onReact?.(message, ej)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Row 2 – ⋮ trigger: actions slide left, ⋮ morphs to ✕ */}
|
{/* ⋮ trigger — always sits directly next to the bubble */}
|
||||||
<div
|
<div
|
||||||
ref={menuRef}
|
ref={menuRef}
|
||||||
className={[
|
className={[
|
||||||
"row-start-2 self-center relative flex items-center",
|
hasMedia ? "self-center" : "self-start mt-1",
|
||||||
isOwn ? "col-start-1 ml-auto" : "col-start-3",
|
"relative flex shrink-0 items-center",
|
||||||
|
isOwn ? "flex-row-reverse" : "",
|
||||||
menuOpen ? "opacity-100" : "opacity-0 group-hover:opacity-100",
|
menuOpen ? "opacity-100" : "opacity-0 group-hover:opacity-100",
|
||||||
"transition-opacity",
|
"transition-opacity",
|
||||||
].join(" ")}
|
].join(" ")}
|
||||||
>
|
>
|
||||||
{/* Emoji picker — floats above the trigger row */}
|
{/* Emoji picker */}
|
||||||
{pickerOpen && (
|
{pickerOpen && (
|
||||||
<EmojiPicker
|
<EmojiPicker
|
||||||
className="absolute bottom-full mb-2 right-0 z-20"
|
className={["absolute bottom-full mb-2 z-20", isOwn ? "right-0" : "left-0"].join(" ")}
|
||||||
onSelect={(emoji) => { onReact?.(message, emoji); }}
|
onSelect={(emoji) => { onReact?.(message, emoji); }}
|
||||||
onClose={() => setPickerOpen(false)}
|
onClose={() => setPickerOpen(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Actions slide out to the left */}
|
{/* Actions slide out away from the bubble */}
|
||||||
<div
|
<div
|
||||||
className={[
|
className={[
|
||||||
"flex items-center gap-0.5 overflow-hidden transition-[max-width] duration-200 ease-out",
|
"flex items-center gap-0.5 overflow-hidden transition-[max-width] duration-200 ease-out",
|
||||||
|
isOwn ? "flex-row-reverse" : "",
|
||||||
menuOpen ? "max-w-32" : "max-w-0",
|
menuOpen ? "max-w-32" : "max-w-0",
|
||||||
].join(" ")}
|
].join(" ")}
|
||||||
>
|
>
|
||||||
@@ -206,7 +262,7 @@ export default function Message({ message, chat, onReply, onReact, highlighted,
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Toggle button — ⋮ rotates into ✕ */}
|
{/* Toggle ⋮ / ✕ */}
|
||||||
<button
|
<button
|
||||||
onClick={() => { setMenuOpen((v) => !v); if (menuOpen) setPickerOpen(false); }}
|
onClick={() => { setMenuOpen((v) => !v); if (menuOpen) setPickerOpen(false); }}
|
||||||
className="relative flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-brand-text/60 hover:bg-brand-lines/20 hover:text-brand-text transition-colors"
|
className="relative flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-brand-text/60 hover:bg-brand-lines/20 hover:text-brand-text transition-colors"
|
||||||
@@ -222,40 +278,6 @@ export default function Message({ message, chat, onReply, onReact, highlighted,
|
|||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Row 3 – timestamp */}
|
|
||||||
<div
|
|
||||||
className={[
|
|
||||||
"col-start-2 row-start-3 mt-0.5 flex items-center gap-2 text-[11px] text-brand-text/50",
|
|
||||||
isOwn ? "justify-self-end" : "",
|
|
||||||
].join(" ")}
|
|
||||||
>
|
|
||||||
<time dateTime={String(message.created_at)}>{formatRelative(message.created_at)}</time>
|
|
||||||
{message.is_edited && <span>· {t("chat.room.edited")}</span>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Row 4 – reactions grouped by emoji */}
|
|
||||||
{Object.keys(reactionGroups).length > 0 && (
|
|
||||||
<div
|
|
||||||
className={[
|
|
||||||
"col-start-2 row-start-4 mt-1 flex flex-wrap gap-1",
|
|
||||||
isOwn ? "justify-self-end" : "",
|
|
||||||
].join(" ")}
|
|
||||||
>
|
|
||||||
{Object.entries(reactionGroups).map(([emoji, { count, mine }]) => (
|
|
||||||
<ReactionPill
|
|
||||||
key={emoji}
|
|
||||||
emoji={emoji}
|
|
||||||
count={count}
|
|
||||||
mine={mine}
|
|
||||||
allGroups={reactionGroups}
|
|
||||||
currentUserId={user?.id}
|
|
||||||
isOwn={isOwn}
|
|
||||||
onRemove={(ej) => onReact?.(message, ej)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
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 type { Message } from "@/api/generated/private/models/message";
|
||||||
import Button from "@/components/ui/Button";
|
import Button from "@/components/ui/Button";
|
||||||
|
import { mediaUrl } from "@/utils/mediaUrl";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
@@ -98,16 +99,46 @@ export default function MessageComposer({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="border-t border-brand-lines/15 bg-brand-bg/60 px-4 py-3">
|
<form onSubmit={handleSubmit} className="border-t border-brand-lines/15 bg-brand-bg/60 px-4 py-3">
|
||||||
{replyTo && (
|
{replyTo && (() => {
|
||||||
<div className="mb-2 flex items-center justify-between gap-2 rounded-xl border border-brand-lines/15 bg-brand-bgLight/40 px-3 py-1.5 text-xs text-brand-text/80">
|
const replySender = replyTo.sender as { id: number; username: string; avatar: string | null } | null;
|
||||||
<span className="truncate">
|
const replyMedia = (replyTo.media_files ?? []) as { id: number; file: string; media_type: string }[];
|
||||||
{t("chat.composer.replyTo", { snippet: (replyTo.content ?? "").slice(0, 60) })}
|
const firstMedia = replyMedia[0];
|
||||||
</span>
|
return (
|
||||||
<button type="button" onClick={onCancelReply} className="rounded-full p-1 hover:bg-brand-lines/10" aria-label={t("chat.composer.cancelReply")}>
|
<div className="mb-3 flex items-center gap-3 rounded-xl border border-brand-lines/15 bg-brand-bgLight/40 px-4 py-3">
|
||||||
<FiX size={12} />
|
<FiCornerUpLeft size={18} className="shrink-0 text-brand-accent/60" />
|
||||||
</button>
|
{firstMedia?.media_type === "IMAGE" && (
|
||||||
</div>
|
<img
|
||||||
)}
|
src={mediaUrl(firstMedia.file)}
|
||||||
|
className="h-14 w-14 shrink-0 rounded-lg object-cover opacity-80"
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{firstMedia && firstMedia.media_type !== "IMAGE" && (
|
||||||
|
<span className="shrink-0 text-xl">📎</span>
|
||||||
|
)}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-semibold text-brand-accent truncate">
|
||||||
|
@{replySender?.username ?? "…"}
|
||||||
|
</p>
|
||||||
|
<p className="truncate text-sm text-brand-text/50 italic">
|
||||||
|
{replyTo.content
|
||||||
|
? replyTo.content.slice(0, 80)
|
||||||
|
: firstMedia
|
||||||
|
? firstMedia.media_type === "IMAGE" ? "Fotka" : "Soubor"
|
||||||
|
: "…"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancelReply}
|
||||||
|
className="shrink-0 rounded-full p-1.5 text-brand-text/40 hover:bg-brand-lines/10 hover:text-brand-text/80"
|
||||||
|
aria-label={t("chat.composer.cancelReply")}
|
||||||
|
>
|
||||||
|
<FiX size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* File previews */}
|
{/* File previews */}
|
||||||
{files.length > 0 && (
|
{files.length > 0 && (
|
||||||
|
|||||||
39
frontend/src/components/social/hub/HubCard.tsx
Normal file
39
frontend/src/components/social/hub/HubCard.tsx
Normal file
@@ -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 (
|
||||||
|
<Link
|
||||||
|
to={`/social/hub/${hub.id}`}
|
||||||
|
className="flex items-start gap-3 rounded-xl border border-brand-lines/15 bg-brand-bgLight/30 p-3 transition-colors hover:bg-brand-lines/10 hover:border-brand-lines/30"
|
||||||
|
>
|
||||||
|
<Avatar name={hub.name} src={hub.icon ?? undefined} size={44} />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="truncate text-sm font-semibold text-brand-text">{hub.name}</span>
|
||||||
|
{isMember && (
|
||||||
|
<span className="shrink-0 flex items-center gap-0.5 rounded-full bg-brand-accent/15 px-1.5 py-0.5 text-[10px] font-medium text-brand-accent">
|
||||||
|
<FiCheck size={9} /> Člen
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{hub.description && (
|
||||||
|
<p className="mt-0.5 line-clamp-2 text-xs text-brand-text/55 leading-relaxed">
|
||||||
|
{hub.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-1.5 flex items-center gap-1 text-[11px] text-brand-text/40">
|
||||||
|
<FiUsers size={11} />
|
||||||
|
<span>{hub.members?.length ?? 0} členů</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
83
frontend/src/components/social/hub/HubHeader.tsx
Normal file
83
frontend/src/components/social/hub/HubHeader.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="w-full">
|
||||||
|
{/* Banner */}
|
||||||
|
<div className="relative h-32 w-full overflow-hidden bg-gradient-to-br from-brand-accent/30 to-brand-bgLight">
|
||||||
|
{hub.banner && (
|
||||||
|
<img
|
||||||
|
src={mediaUrl(hub.banner) ?? undefined}
|
||||||
|
alt=""
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Avatar + info row */}
|
||||||
|
<div className="relative px-4 pb-3">
|
||||||
|
{/* Avatar overlapping banner */}
|
||||||
|
<div className="absolute -top-8 left-4 rounded-full border-4 border-brand-bg bg-brand-bg">
|
||||||
|
<Avatar name={hub.name} src={hub.icon ?? undefined} size={64} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action buttons — top-right */}
|
||||||
|
<div className="flex justify-end gap-2 pt-2 pb-1">
|
||||||
|
{canManage && (
|
||||||
|
<Link to={`/social/hub/${hub.id}/settings`}>
|
||||||
|
<Button variant="ghost" size="sm" leftIcon={<FiSettings size={14} />}>
|
||||||
|
Nastavení
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{isOwner ? null : isMember ? (
|
||||||
|
<Button variant="secondary" size="sm" onClick={onLeave} loading={joining}>
|
||||||
|
Odejít
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button variant="primary" size="sm" onClick={onJoin} loading={joining}>
|
||||||
|
Připojit se
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Name + meta — with left margin to clear the avatar */}
|
||||||
|
<div className="mt-1 pl-[88px] sm:pl-0 sm:mt-1">
|
||||||
|
<h1 className="text-xl font-bold text-brand-text leading-tight">
|
||||||
|
<span className="text-brand-accent/60 font-normal">h/</span>{hub.name}
|
||||||
|
</h1>
|
||||||
|
<div className="mt-1 flex flex-wrap items-center gap-3 text-xs text-brand-text/60">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<FiUsers size={12} />
|
||||||
|
{hub.members?.length ?? 0} členů
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
{hub.is_public ? <FiGlobe size={12} /> : <FiLock size={12} />}
|
||||||
|
{hub.is_public ? "Veřejný" : "Soukromý"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{hub.description && (
|
||||||
|
<p className="mt-2 text-sm text-brand-text/70 leading-relaxed">{hub.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1 +1,38 @@
|
|||||||
/* TAGS created inside hub */
|
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 (
|
||||||
|
<div className="flex flex-wrap gap-1.5 px-4 py-2 border-b border-brand-lines/10">
|
||||||
|
{tags.map((tag) => {
|
||||||
|
const isActive = activeTag === tag.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tag.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelect(isActive ? undefined : tag.id)}
|
||||||
|
className="flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-medium transition-all"
|
||||||
|
style={
|
||||||
|
isActive
|
||||||
|
? { backgroundColor: tag.color + "33", borderColor: tag.color, color: tag.color }
|
||||||
|
: { backgroundColor: "transparent", borderColor: tag.color + "55", color: tag.color + "cc" }
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="h-2 w-2 shrink-0 rounded-full"
|
||||||
|
style={{ backgroundColor: tag.color }}
|
||||||
|
/>
|
||||||
|
{tag.name}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -193,7 +193,7 @@ function MediaItem({
|
|||||||
if (mime.startsWith("video/")) {
|
if (mime.startsWith("video/")) {
|
||||||
return (
|
return (
|
||||||
<div className="relative aspect-square w-full bg-black cursor-pointer" onClick={(e) => { e.stopPropagation(); onOpen(); }}>
|
<div className="relative aspect-square w-full bg-black cursor-pointer" onClick={(e) => { e.stopPropagation(); onOpen(); }}>
|
||||||
<video src={url} muted playsInline preload="none" className="h-full w-full object-cover pointer-events-none" />
|
<video src={url} muted playsInline preload="metadata" onLoadedMetadata={(e) => { (e.target as HTMLVideoElement).currentTime = 0.001; }} 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="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">
|
<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>
|
<svg viewBox="0 0 24 24" fill="white" className="h-5 w-5 translate-x-0.5"><path d="M8 5v14l11-7z"/></svg>
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ interface Props {
|
|||||||
post: EnrichedPost;
|
post: EnrichedPost;
|
||||||
variant?: "compact" | "default" | "focused";
|
variant?: "compact" | "default" | "focused";
|
||||||
clickable?: boolean;
|
clickable?: boolean;
|
||||||
|
hideHubBadge?: boolean;
|
||||||
onReplyClick?: () => void;
|
onReplyClick?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,6 +43,7 @@ export default function Post({
|
|||||||
post,
|
post,
|
||||||
variant = "default",
|
variant = "default",
|
||||||
clickable = true,
|
clickable = true,
|
||||||
|
hideHubBadge = false,
|
||||||
onReplyClick,
|
onReplyClick,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { t } = useTranslation("social");
|
const { t } = useTranslation("social");
|
||||||
@@ -122,13 +124,13 @@ export default function Post({
|
|||||||
>
|
>
|
||||||
@{displayName}
|
@{displayName}
|
||||||
</Link>
|
</Link>
|
||||||
{post.hub != null && (
|
{post.hub_detail && !hideHubBadge && (
|
||||||
<Link
|
<Link
|
||||||
to={`/social/hub/${post.hub}`}
|
to={`/social/hub/${post.hub_detail.id}`}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
className="rounded-full bg-brand-boxes/40 px-2 py-0.5 text-xs text-brand-text hover:bg-brand-boxes/60"
|
className="text-xs font-medium text-brand-accent/70 hover:text-brand-accent hover:underline"
|
||||||
>
|
>
|
||||||
{t("hub.badge")}
|
h/{post.hub_detail.name}
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
<span className="text-brand-text/50">·</span>
|
<span className="text-brand-text/50">·</span>
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { FiSend, FiPaperclip, FiX, FiPlay, FiFile } from "react-icons/fi";
|
import { FiSend, FiPaperclip, FiX, FiPlay, FiFile, FiTag, FiSearch } from "react-icons/fi";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import Textarea from "@/components/ui/Textarea";
|
import Textarea from "@/components/ui/Textarea";
|
||||||
import Button from "@/components/ui/Button";
|
import Button from "@/components/ui/Button";
|
||||||
import Spinner from "@/components/ui/Spinner";
|
import Spinner from "@/components/ui/Spinner";
|
||||||
import FormErrorBanner from "@/components/ui/FormErrorBanner";
|
import FormErrorBanner from "@/components/ui/FormErrorBanner";
|
||||||
import { applyServerErrors } from "@/utils/formErrors";
|
import { applyServerErrors } from "@/utils/formErrors";
|
||||||
import { apiSocialPostsCreate } from "@/api/generated/private/posts/posts";
|
import { apiSocialPostsCreate, apiSocialPostsTagsAttachCreate } from "@/api/generated/private/posts/posts";
|
||||||
|
import { useApiSocialHubsTagsList } from "@/api/generated/private/hubs/hubs";
|
||||||
import { privateApi } from "@/api/privateClient";
|
import { privateApi } from "@/api/privateClient";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -27,8 +28,17 @@ export default function PostComposer({ parentId, hubId, onPosted }: Props) {
|
|||||||
const [rootError, setRootError] = useState<string | undefined>();
|
const [rootError, setRootError] = useState<string | undefined>();
|
||||||
const [files, setFiles] = useState<File[]>([]);
|
const [files, setFiles] = useState<File[]>([]);
|
||||||
const [previews, setPreviews] = useState<string[]>([]);
|
const [previews, setPreviews] = useState<string[]>([]);
|
||||||
|
const [selectedTags, setSelectedTags] = useState<Set<number>>(new Set());
|
||||||
|
const [tagModalOpen, setTagModalOpen] = useState(false);
|
||||||
|
const [tagSearch, setTagSearch] = useState("");
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const { data: tagsData } = useApiSocialHubsTagsList(
|
||||||
|
hubId ? { hub: hubId } : undefined,
|
||||||
|
{ query: { enabled: !!hubId } },
|
||||||
|
);
|
||||||
|
const hubTags = tagsData?.results ?? [];
|
||||||
|
|
||||||
const form = useForm<ComposerForm>({
|
const form = useForm<ComposerForm>({
|
||||||
defaultValues: { content: "" },
|
defaultValues: { content: "" },
|
||||||
});
|
});
|
||||||
@@ -51,6 +61,15 @@ export default function PostComposer({ parentId, hubId, onPosted }: Props) {
|
|||||||
setPreviews((prev) => prev.filter((_, i) => i !== index));
|
setPreviews((prev) => prev.filter((_, i) => i !== index));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleTag(id: number) {
|
||||||
|
setSelectedTags((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function onSubmit(values: ComposerForm) {
|
async function onSubmit(values: ComposerForm) {
|
||||||
setRootError(undefined);
|
setRootError(undefined);
|
||||||
clearErrors();
|
clearErrors();
|
||||||
@@ -61,16 +80,20 @@ export default function PostComposer({ parentId, hubId, onPosted }: Props) {
|
|||||||
reply_to: parentId ?? null,
|
reply_to: parentId ?? null,
|
||||||
} as Parameters<typeof apiSocialPostsCreate>[0]);
|
} as Parameters<typeof apiSocialPostsCreate>[0]);
|
||||||
|
|
||||||
// Upload each file to the new post
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.append("file", file);
|
fd.append("file", file);
|
||||||
await privateApi.post(`/api/social/posts/${created.id}/media/`, fd);
|
await privateApi.post(`/api/social/posts/${created.id}/media/`, fd);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const tagId of selectedTags) {
|
||||||
|
await apiSocialPostsTagsAttachCreate(String(created.id), { tag_id: tagId });
|
||||||
|
}
|
||||||
|
|
||||||
previews.forEach((url) => URL.revokeObjectURL(url));
|
previews.forEach((url) => URL.revokeObjectURL(url));
|
||||||
setFiles([]);
|
setFiles([]);
|
||||||
setPreviews([]);
|
setPreviews([]);
|
||||||
|
setSelectedTags(new Set());
|
||||||
reset({ content: "" });
|
reset({ content: "" });
|
||||||
await queryClient.invalidateQueries({ queryKey: ["social", "posts"] });
|
await queryClient.invalidateQueries({ queryKey: ["social", "posts"] });
|
||||||
onPosted?.();
|
onPosted?.();
|
||||||
@@ -84,112 +107,264 @@ export default function PostComposer({ parentId, hubId, onPosted }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const hasContent = !!content?.trim() || files.length > 0;
|
const hasContent = !!content?.trim() || files.length > 0;
|
||||||
|
const filteredTags = hubTags.filter((t) =>
|
||||||
|
t.name.toLowerCase().includes(tagSearch.toLowerCase()),
|
||||||
|
);
|
||||||
|
const selectedTagObjects = hubTags.filter((t) => selectedTags.has(t.id));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<>
|
||||||
onSubmit={handleSubmit(onSubmit, onInvalid)}
|
<form
|
||||||
className="border-b border-brand-lines/10 px-4 py-3"
|
onSubmit={handleSubmit(onSubmit, onInvalid)}
|
||||||
noValidate
|
className="border-b border-brand-lines/10 px-4 py-3"
|
||||||
>
|
noValidate
|
||||||
<FormErrorBanner message={rootError} className="mb-2" />
|
>
|
||||||
|
<FormErrorBanner message={rootError} className="mb-2" />
|
||||||
|
|
||||||
<Textarea
|
<Textarea
|
||||||
placeholder={
|
placeholder={
|
||||||
parentId
|
parentId
|
||||||
? t("post.compose.replyPlaceholder")
|
? t("post.compose.replyPlaceholder")
|
||||||
: t("post.compose.placeholder")
|
: t("post.compose.placeholder")
|
||||||
}
|
}
|
||||||
rows={3}
|
rows={3}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
error={errors.content?.message}
|
error={errors.content?.message}
|
||||||
{...register("content", {
|
{...register("content", {
|
||||||
validate: (v) => files.length > 0 || v.trim().length > 0 || true,
|
validate: (v) => files.length > 0 || v.trim().length > 0 || true,
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* File previews */}
|
{/* File previews */}
|
||||||
{previews.length > 0 && (
|
{previews.length > 0 && (
|
||||||
<div className="mt-2 flex flex-wrap gap-2">
|
<div className="mt-2 flex flex-wrap gap-2">
|
||||||
{previews.map((src, i) => {
|
{previews.map((src, i) => {
|
||||||
const file = files[i];
|
const file = files[i];
|
||||||
const isVideo = file?.type.startsWith("video/");
|
const isVideo = file?.type.startsWith("video/");
|
||||||
const isImage = file?.type.startsWith("image/");
|
const isImage = file?.type.startsWith("image/");
|
||||||
return (
|
return (
|
||||||
<div key={src} className="relative">
|
<div key={src} className="relative">
|
||||||
{isVideo ? (
|
{isVideo ? (
|
||||||
<div className="relative h-20 w-20">
|
<div className="relative h-20 w-20">
|
||||||
<video
|
<video
|
||||||
src={src}
|
src={src}
|
||||||
className="h-20 w-20 rounded-xl object-cover border border-brand-lines/20 bg-black"
|
className="h-20 w-20 rounded-xl object-cover border border-brand-lines/20 bg-black"
|
||||||
muted
|
muted
|
||||||
playsInline
|
playsInline
|
||||||
preload="none"
|
preload="metadata"
|
||||||
/>
|
onLoadedMetadata={(e) => { (e.target as HTMLVideoElement).currentTime = 0.001; }}
|
||||||
<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 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>
|
</div>
|
||||||
</div>
|
) : isImage ? (
|
||||||
) : isImage ? (
|
<img
|
||||||
<img
|
src={src}
|
||||||
src={src}
|
alt=""
|
||||||
alt=""
|
className="h-20 w-20 rounded-xl object-cover border border-brand-lines/20"
|
||||||
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">
|
||||||
<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" />
|
||||||
<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">
|
||||||
<span className="w-full truncate text-center text-[10px] text-brand-text/60 leading-tight">
|
{file?.name}
|
||||||
{file?.name}
|
</span>
|
||||||
</span>
|
</div>
|
||||||
</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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Selected tags display */}
|
||||||
|
{selectedTagObjects.length > 0 && (
|
||||||
|
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||||
|
{selectedTagObjects.map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag.id}
|
||||||
|
className="flex items-center gap-1 rounded-full border px-2.5 py-0.5 text-xs font-medium"
|
||||||
|
style={{ borderColor: tag.color ?? undefined, color: tag.color ?? undefined, backgroundColor: (tag.color ?? "#6366f1") + "22" }}
|
||||||
|
>
|
||||||
|
<span className="h-1.5 w-1.5 rounded-full" style={{ backgroundColor: tag.color ?? undefined }} />
|
||||||
|
{tag.name}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => removeFile(i)}
|
onClick={() => toggleTag(tag.id)}
|
||||||
className="absolute -right-1.5 -top-1.5 flex h-5 w-5 items-center justify-center rounded-full bg-brand-bg border border-brand-lines/30 text-brand-text/70 hover:text-brand-text shadow"
|
className="ml-0.5 opacity-60 hover:opacity-100"
|
||||||
aria-label={t("post.compose.removeImage")}
|
|
||||||
>
|
>
|
||||||
<FiX size={11} />
|
<FiX size={10} />
|
||||||
</button>
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-2 flex items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className="inline-flex items-center justify-center h-9 w-9 rounded-xl border border-brand-lines/25 bg-brand-bgLight/40 text-brand-text/60 hover:bg-brand-lines/10 hover:text-brand-accent transition-colors disabled:opacity-40"
|
||||||
|
aria-label={t("post.compose.attachImage")}
|
||||||
|
title={t("post.compose.attachImage")}
|
||||||
|
>
|
||||||
|
<FiPaperclip size={16} />
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="*/*"
|
||||||
|
multiple
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Tag picker button — only when inside a hub with tags */}
|
||||||
|
{hubTags.length > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
onClick={() => { setTagSearch(""); setTagModalOpen(true); }}
|
||||||
|
className="inline-flex items-center gap-1.5 h-9 rounded-xl border border-brand-lines/25 bg-brand-bgLight/40 px-3 text-xs font-medium text-brand-text/60 hover:bg-brand-lines/10 hover:text-brand-accent transition-colors disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<FiTag size={14} />
|
||||||
|
Přidat tagy
|
||||||
|
{selectedTags.size > 0 && (
|
||||||
|
<span className="flex h-4 w-4 items-center justify-center rounded-full bg-brand-accent text-[10px] font-bold text-white">
|
||||||
|
{selectedTags.size}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting || !hasContent}
|
||||||
|
leftIcon={isSubmitting ? <Spinner size={14} /> : <FiSend size={14} />}
|
||||||
|
>
|
||||||
|
{isSubmitting
|
||||||
|
? t("post.compose.submitting")
|
||||||
|
: t("post.compose.submit")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Tag picker modal */}
|
||||||
|
{tagModalOpen && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"
|
||||||
|
onClick={() => setTagModalOpen(false)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-full max-w-sm rounded-2xl border border-brand-lines/20 bg-brand-bg shadow-2xl"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between border-b border-brand-lines/10 px-5 py-4">
|
||||||
|
<h2 className="text-base font-semibold text-brand-text">Přidat tagy</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTagModalOpen(false)}
|
||||||
|
className="rounded-full p-1.5 text-brand-text/50 hover:bg-brand-lines/10 hover:text-brand-text transition-colors"
|
||||||
|
>
|
||||||
|
<FiX size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<div className="px-5 pt-4 pb-2">
|
||||||
|
<div className="flex items-center gap-2 rounded-xl border border-brand-lines/25 bg-brand-bgLight/40 px-3 py-2">
|
||||||
|
<FiSearch size={15} className="shrink-0 text-brand-text/40" />
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={tagSearch}
|
||||||
|
onChange={(e) => setTagSearch(e.target.value)}
|
||||||
|
placeholder="Hledat..."
|
||||||
|
className="flex-1 bg-transparent text-sm text-brand-text placeholder:text-brand-text/40 focus:outline-none"
|
||||||
|
/>
|
||||||
|
{tagSearch && (
|
||||||
|
<button type="button" onClick={() => setTagSearch("")} className="text-brand-text/40 hover:text-brand-text/70">
|
||||||
|
<FiX size={13} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
</div>
|
||||||
})}
|
|
||||||
|
{/* Tag list */}
|
||||||
|
<div className="max-h-72 overflow-y-auto px-5 py-2">
|
||||||
|
{filteredTags.length === 0 && (
|
||||||
|
<p className="py-6 text-center text-sm text-brand-text/40">Žádné tagy.</p>
|
||||||
|
)}
|
||||||
|
{filteredTags.map((tag) => {
|
||||||
|
const active = selectedTags.has(tag.id);
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={tag.id}
|
||||||
|
className="flex cursor-pointer items-center gap-3 rounded-xl px-2 py-2.5 hover:bg-brand-lines/5 transition-colors"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={active}
|
||||||
|
onChange={() => toggleTag(tag.id)}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
{/* Custom checkbox */}
|
||||||
|
<span
|
||||||
|
className={[
|
||||||
|
"flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-2 transition-colors",
|
||||||
|
active ? "border-transparent" : "border-brand-lines/30",
|
||||||
|
].join(" ")}
|
||||||
|
style={{ backgroundColor: active ? (tag.color ?? "#6366f1") : undefined }}
|
||||||
|
>
|
||||||
|
{active && (
|
||||||
|
<svg width="10" height="8" viewBox="0 0 10 8" fill="none">
|
||||||
|
<path d="M1 4l3 3 5-6" stroke="white" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{/* Tag pill preview */}
|
||||||
|
<span
|
||||||
|
className="rounded-full border px-3 py-0.5 text-xs font-semibold"
|
||||||
|
style={{
|
||||||
|
borderColor: tag.color ?? undefined,
|
||||||
|
color: tag.color ?? undefined,
|
||||||
|
backgroundColor: (tag.color ?? "#6366f1") + "22",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tag.name}
|
||||||
|
</span>
|
||||||
|
{tag.description && (
|
||||||
|
<span className="ml-auto truncate text-xs text-brand-text/40">{tag.description}</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="flex justify-end gap-2 border-t border-brand-lines/10 px-5 py-4">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setTagModalOpen(false)}>
|
||||||
|
Zrušit
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" size="sm" onClick={() => setTagModalOpen(false)}>
|
||||||
|
Přidat
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
<div className="mt-2 flex items-center justify-between gap-2">
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
{/* Image attach button */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={isSubmitting}
|
|
||||||
onClick={() => fileInputRef.current?.click()}
|
|
||||||
className="inline-flex items-center justify-center h-9 w-9 rounded-xl border border-brand-lines/25 bg-brand-bgLight/40 text-brand-text/60 hover:bg-brand-lines/10 hover:text-brand-accent transition-colors disabled:opacity-40"
|
|
||||||
aria-label={t("post.compose.attachImage")}
|
|
||||||
title={t("post.compose.attachImage")}
|
|
||||||
>
|
|
||||||
<FiPaperclip size={16} />
|
|
||||||
</button>
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
accept="*/*"
|
|
||||||
multiple
|
|
||||||
className="hidden"
|
|
||||||
onChange={handleFileChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
disabled={isSubmitting || !hasContent}
|
|
||||||
leftIcon={isSubmitting ? <Spinner size={14} /> : <FiSend size={14} />}
|
|
||||||
>
|
|
||||||
{isSubmitting
|
|
||||||
? t("post.compose.submitting")
|
|
||||||
: t("post.compose.submit")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
32
frontend/src/hooks/useInfiniteHubPosts.ts
Normal file
32
frontend/src/hooks/useInfiniteHubPosts.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
|
import { apiSocialHubPostsCursor, hubPostsQueryKey } from "@/api/social/hubFeed";
|
||||||
|
|
||||||
|
function extractCursor(nextUrl: string | null): string | null {
|
||||||
|
if (!nextUrl) return null;
|
||||||
|
try {
|
||||||
|
const url = new URL(nextUrl, "http://placeholder");
|
||||||
|
return url.searchParams.get("cursor");
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Opts {
|
||||||
|
hubId: number;
|
||||||
|
tag?: number;
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useInfiniteHubPosts({ hubId, tag, enabled = true }: Opts) {
|
||||||
|
const query = useInfiniteQuery({
|
||||||
|
queryKey: hubPostsQueryKey(hubId, tag),
|
||||||
|
enabled: enabled && Number.isFinite(hubId),
|
||||||
|
initialPageParam: null as string | null,
|
||||||
|
queryFn: ({ pageParam, signal }) =>
|
||||||
|
apiSocialHubPostsCursor({ hub: hubId, cursor: pageParam, tag }, signal),
|
||||||
|
getNextPageParam: (last) => extractCursor(last.next),
|
||||||
|
});
|
||||||
|
|
||||||
|
const posts = query.data?.pages.flatMap((p) => p.results) ?? [];
|
||||||
|
return { ...query, posts };
|
||||||
|
}
|
||||||
@@ -45,7 +45,7 @@ export function canDeleteMessage(
|
|||||||
chat?: Chat | null,
|
chat?: Chat | null,
|
||||||
): boolean {
|
): boolean {
|
||||||
if (!user) return false;
|
if (!user) return false;
|
||||||
if (message.sender != null && user.id === message.sender) return true;
|
if (message.sender?.id === user.id) return true;
|
||||||
if (isSuperuser(user)) return true;
|
if (isSuperuser(user)) return true;
|
||||||
if (chat?.owner === user.id) return true;
|
if (chat?.owner === user.id) return true;
|
||||||
if (chat?.moderators?.includes(user.id)) return true;
|
if (chat?.moderators?.includes(user.id)) return true;
|
||||||
|
|||||||
@@ -1,66 +1,120 @@
|
|||||||
import { Link, useParams } from "react-router-dom";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import { FiArrowLeft } from "react-icons/fi";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { useApiSocialHubsRetrieve } from "@/api/generated/private/hubs/hubs";
|
import {
|
||||||
import { useApiSocialPostsList } from "@/api/generated/private/posts/posts";
|
useApiSocialHubsRetrieve,
|
||||||
|
getApiSocialHubsRetrieveQueryKey,
|
||||||
|
useApiSocialHubsJoinCreate,
|
||||||
|
useApiSocialHubsLeaveCreate,
|
||||||
|
} from "@/api/generated/private/hubs/hubs";
|
||||||
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
|
import { useInfiniteHubPosts } from "@/hooks/useInfiniteHubPosts";
|
||||||
|
import { useIntersectionLoader } from "@/hooks/useIntersectionLoader";
|
||||||
|
import HubHeader from "@/components/social/hub/HubHeader";
|
||||||
|
import HubTags from "@/components/social/hub/Tags";
|
||||||
import Post from "@/components/social/posts/Post";
|
import Post from "@/components/social/posts/Post";
|
||||||
import Avatar from "@/components/ui/Avatar";
|
import PostComposer from "@/components/social/posts/PostComposer";
|
||||||
import Spinner from "@/components/ui/Spinner";
|
import Spinner from "@/components/ui/Spinner";
|
||||||
import EmptyState from "@/components/ui/EmptyState";
|
import EmptyState from "@/components/ui/EmptyState";
|
||||||
|
|
||||||
export default function HubPage() {
|
export default function HubPage() {
|
||||||
const { t } = useTranslation("social");
|
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const hubId = Number(id);
|
const hubId = Number(id);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
const { data: hub, isLoading } = useApiSocialHubsRetrieve(String(hubId));
|
const [activeTag, setActiveTag] = useState<number | undefined>(undefined);
|
||||||
const { data: postsData, isLoading: postsLoading } = useApiSocialPostsList(
|
|
||||||
Number.isFinite(hubId) ? { hub: hubId } : undefined,
|
const { data: hub, isLoading: hubLoading } = useApiSocialHubsRetrieve(String(hubId));
|
||||||
|
|
||||||
|
const isMember = !!(user && hub?.members?.includes(user.id));
|
||||||
|
const isOwner = !!(user && hub?.owner === user.id);
|
||||||
|
const isModerator = !!(user && hub?.moderators?.some((m) => m.user === user.id));
|
||||||
|
|
||||||
|
const joinMutation = useApiSocialHubsJoinCreate({
|
||||||
|
mutation: {
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: getApiSocialHubsRetrieveQueryKey(String(hubId)) });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const leaveMutation = useApiSocialHubsLeaveCreate({
|
||||||
|
mutation: {
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: getApiSocialHubsRetrieveQueryKey(String(hubId)) });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
posts,
|
||||||
|
isLoading: postsLoading,
|
||||||
|
hasNextPage,
|
||||||
|
isFetchingNextPage,
|
||||||
|
fetchNextPage,
|
||||||
|
refetch,
|
||||||
|
} = useInfiniteHubPosts({ hubId, tag: activeTag, enabled: Number.isFinite(hubId) });
|
||||||
|
|
||||||
|
const sentinelRef = useIntersectionLoader<HTMLDivElement>(
|
||||||
|
() => { if (hasNextPage && !isFetchingNextPage) void fetchNextPage(); },
|
||||||
|
{ enabled: hasNextPage && !postsLoading },
|
||||||
);
|
);
|
||||||
const posts = postsData?.results ?? [];
|
|
||||||
|
|
||||||
if (isLoading) {
|
if (hubLoading) {
|
||||||
return (
|
return <div className="flex justify-center py-10"><Spinner size={28} /></div>;
|
||||||
<div className="flex justify-center py-10">
|
|
||||||
<Spinner size={28} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!hub) {
|
if (!hub) {
|
||||||
return <EmptyState title="Hub nenalezen" />;
|
return <EmptyState title="Hub nenalezen" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<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">
|
<HubHeader
|
||||||
<Link
|
hub={hub}
|
||||||
to="/social/hubs"
|
isMember={isMember}
|
||||||
className="rounded-full p-1 text-brand-text hover:bg-brand-lines/10"
|
isOwner={isOwner}
|
||||||
aria-label={t("common:back", { defaultValue: "Zpět" })}
|
isModerator={isModerator}
|
||||||
>
|
joining={joinMutation.isPending || leaveMutation.isPending}
|
||||||
<FiArrowLeft size={20} />
|
onJoin={() => joinMutation.mutate({ id: String(hubId) })}
|
||||||
</Link>
|
onLeave={() => leaveMutation.mutate({ id: String(hubId) })}
|
||||||
<Avatar name={hub.name} src={hub.icon ?? undefined} size={36} />
|
/>
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<h1 className="truncate text-lg font-bold text-brand-text">{hub.name}</h1>
|
|
||||||
{hub.description && (
|
|
||||||
<p className="truncate text-xs text-brand-text/60">{hub.description}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{postsLoading && (
|
{/* Tag filter pills */}
|
||||||
<div className="flex justify-center py-6">
|
{(hub.tags?.length ?? 0) > 0 && (
|
||||||
<Spinner size={24} />
|
<HubTags
|
||||||
</div>
|
tags={hub.tags ?? []}
|
||||||
|
activeTag={activeTag}
|
||||||
|
onSelect={setActiveTag}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!postsLoading && posts.length === 0 && <EmptyState message="—" />}
|
{/* Composer — members and owner */}
|
||||||
|
{(isMember || isOwner) && <PostComposer hubId={hubId} onPosted={() => void refetch()} />}
|
||||||
|
|
||||||
|
{/* Posts */}
|
||||||
|
{postsLoading && (
|
||||||
|
<div className="flex justify-center py-6"><Spinner size={24} /></div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!postsLoading && posts.length === 0 && (
|
||||||
|
<EmptyState message="Zatím žádné příspěvky." />
|
||||||
|
)}
|
||||||
|
|
||||||
{posts.map((p) => (
|
{posts.map((p) => (
|
||||||
<Post key={p.id} post={p} />
|
<Post
|
||||||
|
key={p.id}
|
||||||
|
post={p}
|
||||||
|
hideHubBadge
|
||||||
|
onReplyClick={() => navigate(`/social/post/${p.id}`)}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{hasNextPage && (
|
||||||
|
<div ref={sentinelRef} className="flex justify-center py-6 text-sm text-brand-text/60">
|
||||||
|
{isFetchingNextPage ? <Spinner size={20} /> : "Načíst více"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,54 +1,105 @@
|
|||||||
import { Link } from "react-router-dom";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { FiUsers } from "react-icons/fi";
|
import { FiSearch, FiPlus, FiUsers } from "react-icons/fi";
|
||||||
import { useApiSocialHubsList } from "@/api/generated/private/hubs/hubs";
|
import { useApiSocialHubsList } from "@/api/generated/private/hubs/hubs";
|
||||||
import Avatar from "@/components/ui/Avatar";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
|
import HubCard from "@/components/social/hub/HubCard";
|
||||||
import Spinner from "@/components/ui/Spinner";
|
import Spinner from "@/components/ui/Spinner";
|
||||||
import EmptyState from "@/components/ui/EmptyState";
|
import EmptyState from "@/components/ui/EmptyState";
|
||||||
|
import Button from "@/components/ui/Button";
|
||||||
|
|
||||||
export default function HubsPage() {
|
export default function HubsPage() {
|
||||||
const { t } = useTranslation("social");
|
const navigate = useNavigate();
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
|
||||||
const { data, isLoading } = useApiSocialHubsList(undefined);
|
const { data, isLoading } = useApiSocialHubsList(undefined);
|
||||||
const hubs = data?.results ?? [];
|
const hubs = data?.results ?? [];
|
||||||
|
|
||||||
|
const joined = hubs.filter((h) => h.members?.includes(user?.id as number));
|
||||||
|
const discover = hubs.filter((h) => !h.members?.includes(user?.id as number));
|
||||||
|
|
||||||
|
const q = search.trim().toLowerCase();
|
||||||
|
const filteredDiscover = q
|
||||||
|
? discover.filter(
|
||||||
|
(h) =>
|
||||||
|
h.name.toLowerCase().includes(q) ||
|
||||||
|
(h.description ?? "").toLowerCase().includes(q),
|
||||||
|
)
|
||||||
|
: discover;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<header className="sticky top-0 z-10 border-b border-brand-lines/10 bg-brand-bg/80 px-4 py-3 backdrop-blur">
|
<header className="sticky top-0 z-10 flex items-center justify-between gap-3 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.hubs")}</h1>
|
<h1 className="text-lg font-bold text-brand-text">Huby</h1>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="primary"
|
||||||
|
leftIcon={<FiPlus size={14} />}
|
||||||
|
onClick={() => navigate("/social/hub/create")}
|
||||||
|
>
|
||||||
|
Vytvořit
|
||||||
|
</Button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<div className="px-4 py-3 border-b border-brand-lines/10">
|
||||||
|
<div className="flex items-center gap-2 rounded-xl border border-brand-lines/20 bg-brand-bgLight/40 px-3 py-2">
|
||||||
|
<FiSearch size={15} className="shrink-0 text-brand-text/40" />
|
||||||
|
<input
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder="Hledat huby…"
|
||||||
|
className="min-w-0 flex-1 bg-transparent text-sm text-brand-text placeholder:text-brand-text/40 outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="flex justify-center py-10">
|
<div className="flex justify-center py-10">
|
||||||
<Spinner size={28} />
|
<Spinner size={28} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isLoading && hubs.length === 0 && (
|
{!isLoading && (
|
||||||
<EmptyState icon={<FiUsers />} message="—" />
|
<>
|
||||||
)}
|
{/* Joined */}
|
||||||
|
{joined.length > 0 && (
|
||||||
<ul>
|
<section className="px-4 py-4">
|
||||||
{hubs.map((hub) => (
|
<h2 className="mb-3 text-xs font-semibold uppercase tracking-wider text-brand-text/50">
|
||||||
<li key={hub.id}>
|
Moje huby
|
||||||
<Link
|
</h2>
|
||||||
to={`/social/hub/${hub.id}`}
|
<div className="flex flex-col gap-2">
|
||||||
className="flex items-center gap-3 border-b border-brand-lines/10 px-4 py-3 hover:bg-brand-lines/5 transition-colors"
|
{joined.map((hub) => (
|
||||||
>
|
<HubCard key={hub.id} hub={hub} isMember />
|
||||||
<Avatar name={hub.name} src={hub.icon ?? undefined} size={40} />
|
))}
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="truncate text-sm font-semibold text-brand-text">
|
|
||||||
{hub.name}
|
|
||||||
</div>
|
|
||||||
{hub.description && (
|
|
||||||
<div className="truncate text-xs text-brand-text/60">
|
|
||||||
{hub.description}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</section>
|
||||||
</li>
|
)}
|
||||||
))}
|
|
||||||
</ul>
|
{/* Divider */}
|
||||||
|
{joined.length > 0 && <div className="mx-4 border-t border-brand-lines/10" />}
|
||||||
|
|
||||||
|
{/* Discover */}
|
||||||
|
<section className="px-4 py-4">
|
||||||
|
<h2 className="mb-3 text-xs font-semibold uppercase tracking-wider text-brand-text/50">
|
||||||
|
Objevovat
|
||||||
|
</h2>
|
||||||
|
{filteredDiscover.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={<FiUsers />}
|
||||||
|
message={search ? "Žádné huby neodpovídají vyhledávání." : "Zatím žádné další huby."}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{filteredDiscover.map((hub) => (
|
||||||
|
<HubCard key={hub.id} hub={hub} isMember={false} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -317,7 +317,7 @@ export default function ChatRoomPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Beginning-of-chat banner — shown only once all pages are confirmed loaded */}
|
{/* Beginning-of-chat banner — shown only once all pages are confirmed loaded */}
|
||||||
{!hasNextPage && !isLoading && messages.length > 0 && chat && (
|
{!hasNextPage && !isLoading && chat && (
|
||||||
<div className="flex flex-col items-center gap-3 px-6 py-10 text-center">
|
<div className="flex flex-col items-center gap-3 px-6 py-10 text-center">
|
||||||
<Avatar
|
<Avatar
|
||||||
name={chat.name ?? `Chat ${chatId}`}
|
name={chat.name ?? `Chat ${chatId}`}
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { FiArrowLeft, FiImage } from "react-icons/fi";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { useApiSocialHubsCreate } from "@/api/generated/private/hubs/hubs";
|
||||||
|
import Button from "@/components/ui/Button";
|
||||||
|
import FormErrorBanner from "@/components/ui/FormErrorBanner";
|
||||||
|
|
||||||
|
interface FormValues {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
is_public: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HubCreatePage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [icon, setIcon] = useState<File | null>(null);
|
||||||
|
const [banner, setBanner] = useState<File | null>(null);
|
||||||
|
const [iconPreview, setIconPreview] = useState<string | null>(null);
|
||||||
|
const [bannerPreview, setBannerPreview] = useState<string | null>(null);
|
||||||
|
const [serverError, setServerError] = useState<string | null>(null);
|
||||||
|
const iconRef = useRef<HTMLInputElement>(null);
|
||||||
|
const bannerRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const { register, handleSubmit, formState: { errors } } = useForm<FormValues>({
|
||||||
|
defaultValues: { name: "", description: "", is_public: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const mutation = useApiSocialHubsCreate({
|
||||||
|
mutation: {
|
||||||
|
onSuccess: (hub) => navigate(`/social/hub/${hub.id}`),
|
||||||
|
onError: () => setServerError("Nepodařilo se vytvořit hub. Zkontroluj zadané hodnoty."),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function pickFile(setter: (f: File) => void, previewSetter: (s: string) => void) {
|
||||||
|
return (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const f = e.target.files?.[0];
|
||||||
|
if (!f) return;
|
||||||
|
setter(f);
|
||||||
|
previewSetter(URL.createObjectURL(f));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSubmit(values: FormValues) {
|
||||||
|
setServerError(null);
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("name", values.name);
|
||||||
|
form.append("description", values.description);
|
||||||
|
form.append("is_public", String(values.is_public));
|
||||||
|
if (icon) form.append("icon", icon);
|
||||||
|
if (banner) form.append("banner", banner);
|
||||||
|
mutation.mutate({ data: form as unknown as Parameters<typeof mutation.mutate>[0]["data"] });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-xl">
|
||||||
|
<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">Vytvořit hub</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-5 px-4 py-6">
|
||||||
|
{serverError && <FormErrorBanner message={serverError} />}
|
||||||
|
|
||||||
|
{/* Banner picker */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-brand-text/60">Banner</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => bannerRef.current?.click()}
|
||||||
|
className="relative flex h-28 w-full items-center justify-center overflow-hidden rounded-xl border-2 border-dashed border-brand-lines/30 bg-brand-bgLight/30 text-brand-text/40 transition-colors hover:border-brand-accent/40 hover:text-brand-accent/60"
|
||||||
|
>
|
||||||
|
{bannerPreview ? (
|
||||||
|
<img src={bannerPreview} alt="" className="h-full w-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<span className="flex flex-col items-center gap-1 text-xs">
|
||||||
|
<FiImage size={22} />
|
||||||
|
Přidat banner
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<input ref={bannerRef} type="file" accept="image/*" className="hidden" onChange={pickFile(setBanner, setBannerPreview)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Icon picker */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-brand-text/60">Ikona</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => iconRef.current?.click()}
|
||||||
|
className="relative flex h-20 w-20 items-center justify-center overflow-hidden rounded-full border-2 border-dashed border-brand-lines/30 bg-brand-bgLight/30 text-brand-text/40 transition-colors hover:border-brand-accent/40 hover:text-brand-accent/60"
|
||||||
|
>
|
||||||
|
{iconPreview ? (
|
||||||
|
<img src={iconPreview} alt="" className="h-full w-full object-cover rounded-full" />
|
||||||
|
) : (
|
||||||
|
<FiImage size={20} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<input ref={iconRef} type="file" accept="image/*" className="hidden" onChange={pickFile(setIcon, setIconPreview)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Name */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-brand-text/60">
|
||||||
|
Název <span className="text-red-400">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
{...register("name", { required: "Název je povinný." })}
|
||||||
|
placeholder="název-hubu"
|
||||||
|
className="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/40 focus:border-brand-accent focus:outline-none"
|
||||||
|
/>
|
||||||
|
{errors.name && <p className="mt-1 text-xs text-red-400">{errors.name.message}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-brand-text/60">Popis</label>
|
||||||
|
<textarea
|
||||||
|
{...register("description")}
|
||||||
|
rows={3}
|
||||||
|
placeholder="O čem je tento hub?"
|
||||||
|
className="w-full resize-none rounded-xl border border-brand-lines/25 bg-brand-bgLight/40 px-3 py-2 text-sm text-brand-text placeholder:text-brand-text/40 focus:border-brand-accent focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Visibility */}
|
||||||
|
<label className="flex cursor-pointer items-center justify-between gap-3 rounded-xl border border-brand-lines/15 bg-brand-bgLight/30 px-4 py-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-brand-text">Veřejný hub</p>
|
||||||
|
<p className="text-xs text-brand-text/50">Kdokoli ho může najít a přidat se.</p>
|
||||||
|
</div>
|
||||||
|
<input type="checkbox" {...register("is_public")} className="h-4 w-4 accent-brand-accent" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<Button type="submit" variant="primary" fullWidth loading={mutation.isPending}>
|
||||||
|
Vytvořit hub
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,381 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { FiArrowLeft, FiImage, FiTrash2, FiPlus, FiX } from "react-icons/fi";
|
||||||
|
import {
|
||||||
|
useApiSocialHubsRetrieve,
|
||||||
|
getApiSocialHubsRetrieveQueryKey,
|
||||||
|
useApiSocialHubsPartialUpdate,
|
||||||
|
useApiSocialHubsDestroy,
|
||||||
|
useApiSocialHubsModeratorsList,
|
||||||
|
getApiSocialHubsModeratorsListQueryKey,
|
||||||
|
useApiSocialHubsModeratorsCreate,
|
||||||
|
useApiSocialHubsModeratorsPartialUpdate,
|
||||||
|
useApiSocialHubsModeratorsDestroy,
|
||||||
|
useApiSocialHubsTagsList,
|
||||||
|
getApiSocialHubsTagsListQueryKey,
|
||||||
|
useApiSocialHubsTagsCreate,
|
||||||
|
useApiSocialHubsTagsDestroy,
|
||||||
|
} from "@/api/generated/private/hubs/hubs";
|
||||||
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
|
import Button from "@/components/ui/Button";
|
||||||
|
import Spinner from "@/components/ui/Spinner";
|
||||||
|
import EmptyState from "@/components/ui/EmptyState";
|
||||||
|
import Avatar from "@/components/ui/Avatar";
|
||||||
|
|
||||||
|
type Tab = "general" | "moderators" | "tags";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// General tab
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
interface GeneralForm {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
is_public: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function GeneralTab({ hubId }: { hubId: number }) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { data: hub } = useApiSocialHubsRetrieve(String(hubId));
|
||||||
|
const [icon, setIcon] = useState<File | null>(null);
|
||||||
|
const [banner, setBanner] = useState<File | null>(null);
|
||||||
|
const [iconPreview, setIconPreview] = useState<string | null>(null);
|
||||||
|
const [bannerPreview, setBannerPreview] = useState<string | null>(null);
|
||||||
|
const iconRef = useRef<HTMLInputElement>(null);
|
||||||
|
const bannerRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const { register, handleSubmit, reset } = useForm<GeneralForm>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (hub) reset({ name: hub.name, description: hub.description ?? "", is_public: hub.is_public });
|
||||||
|
}, [hub, reset]);
|
||||||
|
|
||||||
|
const mutation = useApiSocialHubsPartialUpdate({
|
||||||
|
mutation: {
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: getApiSocialHubsRetrieveQueryKey(String(hubId)) });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function pickFile(setter: (f: File) => void, previewSetter: (s: string) => void) {
|
||||||
|
return (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const f = e.target.files?.[0];
|
||||||
|
if (!f) return;
|
||||||
|
setter(f);
|
||||||
|
previewSetter(URL.createObjectURL(f));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSubmit(values: GeneralForm) {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("name", values.name);
|
||||||
|
form.append("description", values.description);
|
||||||
|
form.append("is_public", String(values.is_public));
|
||||||
|
if (icon) form.append("icon", icon);
|
||||||
|
if (banner) form.append("banner", banner);
|
||||||
|
mutation.mutate({ id: String(hubId), data: form as unknown as Parameters<typeof mutation.mutate>[0]["data"] });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-5 py-4">
|
||||||
|
{/* Banner */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-brand-text/60">Banner</label>
|
||||||
|
<button type="button" onClick={() => bannerRef.current?.click()}
|
||||||
|
className="relative flex h-28 w-full items-center justify-center overflow-hidden rounded-xl border-2 border-dashed border-brand-lines/30 bg-brand-bgLight/30 text-brand-text/40 hover:border-brand-accent/40 hover:text-brand-accent/60 transition-colors">
|
||||||
|
{bannerPreview || hub?.banner
|
||||||
|
? <img src={bannerPreview ?? hub?.banner ?? undefined} alt="" className="h-full w-full object-cover" />
|
||||||
|
: <span className="flex flex-col items-center gap-1 text-xs"><FiImage size={22} />Změnit banner</span>}
|
||||||
|
</button>
|
||||||
|
<input ref={bannerRef} type="file" accept="image/*" className="hidden" onChange={pickFile(setBanner, setBannerPreview)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Icon */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-brand-text/60">Ikona</label>
|
||||||
|
<button type="button" onClick={() => iconRef.current?.click()}
|
||||||
|
className="relative flex h-20 w-20 items-center justify-center overflow-hidden rounded-full border-2 border-dashed border-brand-lines/30 bg-brand-bgLight/30 text-brand-text/40 hover:border-brand-accent/40 hover:text-brand-accent/60 transition-colors">
|
||||||
|
{iconPreview || hub?.icon
|
||||||
|
? <img src={iconPreview ?? hub?.icon ?? undefined} alt="" className="h-full w-full object-cover rounded-full" />
|
||||||
|
: <FiImage size={20} />}
|
||||||
|
</button>
|
||||||
|
<input ref={iconRef} type="file" accept="image/*" className="hidden" onChange={pickFile(setIcon, setIconPreview)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Name */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-brand-text/60">Název</label>
|
||||||
|
<input {...register("name")} className="w-full rounded-xl border border-brand-lines/25 bg-brand-bgLight/40 px-3 py-2 text-sm text-brand-text focus:border-brand-accent focus:outline-none" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-brand-text/60">Popis</label>
|
||||||
|
<textarea {...register("description")} rows={3}
|
||||||
|
className="w-full resize-none rounded-xl border border-brand-lines/25 bg-brand-bgLight/40 px-3 py-2 text-sm text-brand-text focus:border-brand-accent focus:outline-none" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Visibility */}
|
||||||
|
<label className="flex cursor-pointer items-center justify-between gap-3 rounded-xl border border-brand-lines/15 bg-brand-bgLight/30 px-4 py-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-brand-text">Veřejný hub</p>
|
||||||
|
<p className="text-xs text-brand-text/50">Kdokoli ho může najít a přidat se.</p>
|
||||||
|
</div>
|
||||||
|
<input type="checkbox" {...register("is_public")} className="h-4 w-4 accent-brand-accent" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<Button type="submit" variant="primary" loading={mutation.isPending}>Uložit změny</Button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Moderators tab
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const PERM_LABELS: Record<string, string> = {
|
||||||
|
changing_name: "Název",
|
||||||
|
changing_description: "Popis",
|
||||||
|
changing_icon: "Ikona",
|
||||||
|
changing_banner: "Banner",
|
||||||
|
managing_members: "Členové",
|
||||||
|
managing_posts: "Příspěvky",
|
||||||
|
managing_chats: "Chaty",
|
||||||
|
};
|
||||||
|
|
||||||
|
function ModeratorsTab({ hubId }: { hubId: number }) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [newUserId, setNewUserId] = useState("");
|
||||||
|
|
||||||
|
const { data } = useApiSocialHubsModeratorsList({ hub: hubId });
|
||||||
|
const mods = data?.results ?? [];
|
||||||
|
|
||||||
|
const createMod = useApiSocialHubsModeratorsCreate({
|
||||||
|
mutation: { onSuccess: () => { setNewUserId(""); void queryClient.invalidateQueries({ queryKey: getApiSocialHubsModeratorsListQueryKey({ hub: hubId }) }); } },
|
||||||
|
});
|
||||||
|
const updateMod = useApiSocialHubsModeratorsPartialUpdate({
|
||||||
|
mutation: { onSuccess: () => { void queryClient.invalidateQueries({ queryKey: getApiSocialHubsModeratorsListQueryKey({ hub: hubId }) }); } },
|
||||||
|
});
|
||||||
|
const deleteMod = useApiSocialHubsModeratorsDestroy({
|
||||||
|
mutation: { onSuccess: () => { void queryClient.invalidateQueries({ queryKey: getApiSocialHubsModeratorsListQueryKey({ hub: hubId }) }); } },
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4 py-4">
|
||||||
|
{/* Add moderator */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
value={newUserId}
|
||||||
|
onChange={(e) => setNewUserId(e.target.value)}
|
||||||
|
placeholder="ID uživatele"
|
||||||
|
className="flex-1 rounded-xl border border-brand-lines/25 bg-brand-bgLight/40 px-3 py-2 text-sm text-brand-text focus:border-brand-accent focus:outline-none"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="primary"
|
||||||
|
leftIcon={<FiPlus size={14} />}
|
||||||
|
loading={createMod.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
const uid = Number(newUserId);
|
||||||
|
if (!Number.isFinite(uid)) return;
|
||||||
|
createMod.mutate({ data: { hub: hubId, user: uid } as never });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Přidat
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mods.length === 0 && <EmptyState message="Žádní moderátoři." />}
|
||||||
|
|
||||||
|
{mods.map((mod) => (
|
||||||
|
<div key={mod.id} className="rounded-xl border border-brand-lines/15 bg-brand-bgLight/30 p-4">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<span className="text-sm font-semibold text-brand-text">Uživatel #{mod.user}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => deleteMod.mutate({ id: String(mod.id) })}
|
||||||
|
className="rounded-full p-1.5 text-red-400/70 hover:bg-red-400/10 hover:text-red-400"
|
||||||
|
>
|
||||||
|
<FiTrash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{Object.keys(PERM_LABELS).map((key) => (
|
||||||
|
<label key={key} className="flex cursor-pointer items-center gap-2 text-xs text-brand-text/70">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
defaultChecked={(mod as Record<string, unknown>)[key] as boolean}
|
||||||
|
className="h-3.5 w-3.5 accent-brand-accent"
|
||||||
|
onChange={(e) => {
|
||||||
|
updateMod.mutate({ id: String(mod.id), data: { [key]: e.target.checked } as never });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{PERM_LABELS[key]}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tags tab
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function TagsTab({ hubId }: { hubId: number }) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [color, setColor] = useState("#6366f1");
|
||||||
|
const [desc, setDesc] = useState("");
|
||||||
|
|
||||||
|
const { data } = useApiSocialHubsTagsList({ hub: hubId });
|
||||||
|
const tags = data?.results ?? [];
|
||||||
|
|
||||||
|
const createTag = useApiSocialHubsTagsCreate({
|
||||||
|
mutation: {
|
||||||
|
onSuccess: () => { setName(""); setDesc(""); void queryClient.invalidateQueries({ queryKey: getApiSocialHubsTagsListQueryKey({ hub: hubId }) }); },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const deleteTag = useApiSocialHubsTagsDestroy({
|
||||||
|
mutation: { onSuccess: () => { void queryClient.invalidateQueries({ queryKey: getApiSocialHubsTagsListQueryKey({ hub: hubId }) }); } },
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4 py-4">
|
||||||
|
{/* Create tag form */}
|
||||||
|
<div className="rounded-xl border border-brand-lines/15 bg-brand-bgLight/30 p-4 flex flex-col gap-3">
|
||||||
|
<p className="text-xs font-semibold text-brand-text/60 uppercase tracking-wider">Nový tag</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input type="color" value={color} onChange={(e) => setColor(e.target.value)}
|
||||||
|
className="h-9 w-9 shrink-0 cursor-pointer rounded-lg border border-brand-lines/20 bg-transparent p-0.5" />
|
||||||
|
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="Název tagu"
|
||||||
|
className="flex-1 rounded-xl border border-brand-lines/25 bg-brand-bgLight/40 px-3 py-2 text-sm text-brand-text focus:border-brand-accent focus:outline-none" />
|
||||||
|
</div>
|
||||||
|
<input value={desc} onChange={(e) => setDesc(e.target.value)} placeholder="Popis (nepovinný)"
|
||||||
|
className="w-full rounded-xl border border-brand-lines/25 bg-brand-bgLight/40 px-3 py-2 text-sm text-brand-text focus:border-brand-accent focus:outline-none" />
|
||||||
|
<Button size="sm" variant="primary" loading={createTag.isPending}
|
||||||
|
onClick={() => name && createTag.mutate({ data: { hub: hubId, name, color, description: desc } as never })}>
|
||||||
|
Přidat tag
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tags.length === 0 && <EmptyState message="Žádné tagy." />}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{tags.map((tag) => (
|
||||||
|
<div key={tag.id} className="flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-medium"
|
||||||
|
style={{ borderColor: tag.color + "55", color: tag.color }}>
|
||||||
|
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: tag.color }} />
|
||||||
|
{tag.name}
|
||||||
|
<button type="button" onClick={() => deleteTag.mutate({ id: String(tag.id) })}
|
||||||
|
className="ml-0.5 opacity-60 hover:opacity-100">
|
||||||
|
<FiX size={11} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Settings page shell
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export default function HubSettingsPage() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const hubId = Number(id);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
const [tab, setTab] = useState<Tab>("general");
|
||||||
|
const { data: hub, isLoading } = useApiSocialHubsRetrieve(String(hubId));
|
||||||
|
|
||||||
|
const isOwner = !!(user && hub?.owner === user.id);
|
||||||
|
const isModerator = !!(user && hub?.moderators?.some((m) => m.user === user.id));
|
||||||
|
|
||||||
|
// Redirect if no permission
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLoading && hub && !isOwner && !isModerator) {
|
||||||
|
navigate(`/social/hub/${hubId}`, { replace: true });
|
||||||
|
}
|
||||||
|
}, [isLoading, hub, isOwner, isModerator, hubId, navigate]);
|
||||||
|
|
||||||
|
const destroyMutation = useApiSocialHubsDestroy({
|
||||||
|
mutation: {
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: getApiSocialHubsRetrieveQueryKey(String(hubId)) });
|
||||||
|
navigate("/social/hubs", { replace: true });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) return <div className="flex justify-center py-10"><Spinner size={28} /></div>;
|
||||||
|
if (!hub) return <EmptyState title="Hub nenalezen" />;
|
||||||
|
|
||||||
|
const TABS: { key: Tab; label: string }[] = [
|
||||||
|
{ key: "general", label: "Obecné" },
|
||||||
|
{ key: "moderators", label: "Moderátoři" },
|
||||||
|
{ key: "tags", label: "Tagy" },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-xl">
|
||||||
|
<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(`/social/hub/${hubId}`)}
|
||||||
|
className="rounded-full p-1 text-brand-text hover:bg-brand-lines/10">
|
||||||
|
<FiArrowLeft size={20} />
|
||||||
|
</button>
|
||||||
|
<Avatar name={hub.name} src={hub.icon ?? undefined} size={28} />
|
||||||
|
<h1 className="text-lg font-bold text-brand-text truncate">Nastavení · {hub.name}</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Tab bar */}
|
||||||
|
<div className="flex border-b border-brand-lines/10 px-4">
|
||||||
|
{TABS.map(({ key, label }) => (
|
||||||
|
<button key={key} type="button" onClick={() => setTab(key)}
|
||||||
|
className={[
|
||||||
|
"px-4 py-2.5 text-sm font-medium border-b-2 transition-colors",
|
||||||
|
tab === key
|
||||||
|
? "border-brand-accent text-brand-accent"
|
||||||
|
: "border-transparent text-brand-text/50 hover:text-brand-text/80",
|
||||||
|
].join(" ")}>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-4">
|
||||||
|
{tab === "general" && <GeneralTab hubId={hubId} />}
|
||||||
|
{tab === "moderators" && <ModeratorsTab hubId={hubId} />}
|
||||||
|
{tab === "tags" && <TagsTab hubId={hubId} />}
|
||||||
|
|
||||||
|
{/* Danger zone — only on general tab */}
|
||||||
|
{isOwner && tab === "general" && (
|
||||||
|
<div className="mt-6 mb-8 rounded-xl border border-red-400/20 bg-red-400/5 p-4">
|
||||||
|
<p className="mb-1 text-sm font-semibold text-red-400">Nebezpečná zóna</p>
|
||||||
|
<p className="mb-3 text-xs text-brand-text/50">Tato akce je nevratná. Všechny příspěvky budou smazány.</p>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
|
leftIcon={<FiTrash2 size={14} />}
|
||||||
|
loading={destroyMutation.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm(`Opravdu smazat hub "${hub.name}"? Tato akce je nevratná.`)) {
|
||||||
|
destroyMutation.mutate({ id: String(hubId) });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Smazat hub
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user