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:
2026-06-07 00:24:21 +02:00
parent 6422fefe46
commit cb23abeb5f
43 changed files with 1522 additions and 321 deletions

View File

@@ -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 \"^$\")"
] ]
} }
} }

View File

@@ -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):

View File

@@ -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:

View File

@@ -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(

View File

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

View File

@@ -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):

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 */

View File

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

View File

@@ -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 */

View File

@@ -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[];

View File

@@ -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[];

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

View 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[];
}

View File

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

View File

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

View File

@@ -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 */

View File

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

View File

@@ -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 */

View File

@@ -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[];

View File

@@ -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[];

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

View 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[];
}

View 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;

View File

@@ -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">

View File

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

View File

@@ -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 && (

View 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>
);
}

View 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>
);
}

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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