-
-
-
+ {/* File previews */}
+ {previews.length > 0 && (
+
+ {previews.map((src, i) => {
+ const file = files[i];
+ const isVideo = file?.type.startsWith("video/");
+ const isImage = file?.type.startsWith("image/");
+ return (
+
+ {isVideo ? (
+
+
-
- ) : isImage ? (
-

- ) : (
-
-
-
- {file?.name}
-
-
- )}
+ ) : isImage ? (
+

+ ) : (
+
+
+
+ {file?.name}
+
+
+ )}
+
+
+ );
+ })}
+
+ )}
+
+ {/* Selected tags display */}
+ {selectedTagObjects.length > 0 && (
+
+ {selectedTagObjects.map((tag) => (
+
+
+ {tag.name}
+
+ ))}
+
+ )}
+
+
+
+
+
+
+ {/* Tag picker button — only when inside a hub with tags */}
+ {hubTags.length > 0 && (
+
+ )}
+
+
+
:
}
+ >
+ {isSubmitting
+ ? t("post.compose.submitting")
+ : t("post.compose.submit")}
+
+
+
+
+ {/* Tag picker modal */}
+ {tagModalOpen && (
+
setTagModalOpen(false)}
+ >
+
e.stopPropagation()}
+ >
+ {/* Header */}
+
+
Přidat tagy
+
+
+
+ {/* Search */}
+
+
+
+ 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 && (
+
+ )}
- );
- })}
+
+
+ {/* Tag list */}
+
+ {filteredTags.length === 0 && (
+
Žádné tagy.
+ )}
+ {filteredTags.map((tag) => {
+ const active = selectedTags.has(tag.id);
+ return (
+
+ );
+ })}
+
+
+ {/* Footer */}
+
+
+
+
+
)}
-
-
-
- {/* Image attach button */}
-
-
-
-
-
:
}
- >
- {isSubmitting
- ? t("post.compose.submitting")
- : t("post.compose.submit")}
-
-
-
+ >
);
}
diff --git a/frontend/src/hooks/useInfiniteHubPosts.ts b/frontend/src/hooks/useInfiniteHubPosts.ts
new file mode 100644
index 0000000..5cd208b
--- /dev/null
+++ b/frontend/src/hooks/useInfiniteHubPosts.ts
@@ -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 };
+}
diff --git a/frontend/src/hooks/usePermissions.ts b/frontend/src/hooks/usePermissions.ts
index 33306d4..d8babed 100644
--- a/frontend/src/hooks/usePermissions.ts
+++ b/frontend/src/hooks/usePermissions.ts
@@ -45,7 +45,7 @@ export function canDeleteMessage(
chat?: Chat | null,
): boolean {
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 (chat?.owner === user.id) return true;
if (chat?.moderators?.includes(user.id)) return true;
diff --git a/frontend/src/pages/social/HubPage.tsx b/frontend/src/pages/social/HubPage.tsx
index 8173abe..fafc3ee 100644
--- a/frontend/src/pages/social/HubPage.tsx
+++ b/frontend/src/pages/social/HubPage.tsx
@@ -1,66 +1,120 @@
-import { Link, useParams } from "react-router-dom";
-import { useTranslation } from "react-i18next";
-import { FiArrowLeft } from "react-icons/fi";
-import { useApiSocialHubsRetrieve } from "@/api/generated/private/hubs/hubs";
-import { useApiSocialPostsList } from "@/api/generated/private/posts/posts";
+import { useState } from "react";
+import { useNavigate, useParams } from "react-router-dom";
+import { useQueryClient } from "@tanstack/react-query";
+import {
+ 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 Avatar from "@/components/ui/Avatar";
+import PostComposer from "@/components/social/posts/PostComposer";
import Spinner from "@/components/ui/Spinner";
import EmptyState from "@/components/ui/EmptyState";
export default function HubPage() {
- const { t } = useTranslation("social");
const { id } = useParams<{ id: string }>();
const hubId = Number(id);
+ const navigate = useNavigate();
+ const queryClient = useQueryClient();
+ const { user } = useAuth();
- const { data: hub, isLoading } = useApiSocialHubsRetrieve(String(hubId));
- const { data: postsData, isLoading: postsLoading } = useApiSocialPostsList(
- Number.isFinite(hubId) ? { hub: hubId } : undefined,
+ const [activeTag, setActiveTag] = useState
(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(
+ () => { if (hasNextPage && !isFetchingNextPage) void fetchNextPage(); },
+ { enabled: hasNextPage && !postsLoading },
);
- const posts = postsData?.results ?? [];
- if (isLoading) {
- return (
-
-
-
- );
+ if (hubLoading) {
+ return
;
}
-
if (!hub) {
return ;
}
return (
-
+
joinMutation.mutate({ id: String(hubId) })}
+ onLeave={() => leaveMutation.mutate({ id: String(hubId) })}
+ />
- {postsLoading && (
-
-
-
+ {/* Tag filter pills */}
+ {(hub.tags?.length ?? 0) > 0 && (
+
)}
- {!postsLoading && posts.length === 0 && }
+ {/* Composer — members and owner */}
+ {(isMember || isOwner) && void refetch()} />}
+
+ {/* Posts */}
+ {postsLoading && (
+
+ )}
+
+ {!postsLoading && posts.length === 0 && (
+
+ )}
{posts.map((p) => (
-
+ navigate(`/social/post/${p.id}`)}
+ />
))}
+
+ {hasNextPage && (
+
+ {isFetchingNextPage ? : "Načíst více"}
+
+ )}
);
}
diff --git a/frontend/src/pages/social/HubsPage.tsx b/frontend/src/pages/social/HubsPage.tsx
index 1296824..43b6b0e 100644
--- a/frontend/src/pages/social/HubsPage.tsx
+++ b/frontend/src/pages/social/HubsPage.tsx
@@ -1,54 +1,105 @@
-import { Link } from "react-router-dom";
-import { useTranslation } from "react-i18next";
-import { FiUsers } from "react-icons/fi";
+import { useState } from "react";
+import { useNavigate } from "react-router-dom";
+import { FiSearch, FiPlus, FiUsers } from "react-icons/fi";
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 EmptyState from "@/components/ui/EmptyState";
+import Button from "@/components/ui/Button";
export default function HubsPage() {
- const { t } = useTranslation("social");
+ const navigate = useNavigate();
+ const { user } = useAuth();
+ const [search, setSearch] = useState("");
+
const { data, isLoading } = useApiSocialHubsList(undefined);
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 (