feat(social): GET /social/explore, explore tab, feed filters all/following/groups (S1.5, S1.6)

This commit is contained in:
senke 2026-02-21 05:31:12 +01:00
parent b572863847
commit 28e6642fa6
11 changed files with 219 additions and 29 deletions

View file

@ -1,6 +1,7 @@
import { useSocialView } from './useSocialView';
import { SocialViewSidebar } from './SocialViewSidebar';
import { SocialViewFeed } from './SocialViewFeed';
import { SocialViewExplore } from './SocialViewExplore';
import { SocialViewTrending } from './SocialViewTrending';
import { SocialViewSkeleton } from './SocialViewSkeleton';
import { SocialViewError } from './SocialViewError';
@ -8,16 +9,12 @@ import type { SocialViewProps } from './types';
import type { SocialTabKey } from './types';
export function SocialView({ onViewProfile }: SocialViewProps) {
const { activeTab, setActiveTab, feedItems, loading, error, retry, playTrack, loadMore, hasMore, isLoadingMore } = useSocialView();
const { activeTab, setActiveTab, feedFilter, setFeedFilter, feedItems, loading, error, retry, playTrack, loadMore, hasMore, isLoadingMore } = useSocialView();
if (loading) {
if (loading && activeTab === 'feed') {
return <SocialViewSkeleton />;
}
if (error) {
return <SocialViewError onRetry={retry} />;
}
return (
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 animate-fadeIn pb-20 min-h-layout-page">
<SocialViewSidebar
@ -26,14 +23,24 @@ export function SocialView({ onViewProfile }: SocialViewProps) {
onProfileClick={() => onViewProfile(null)}
/>
<SocialViewFeed
items={feedItems}
loading={false}
onPlayTrack={playTrack}
onLoadMore={loadMore}
hasMore={hasMore ?? false}
isLoadingMore={isLoadingMore}
/>
{activeTab === 'explore' ? (
<SocialViewExplore />
) : error ? (
<div className="col-span-1 lg:col-span-6">
<SocialViewError onRetry={retry} />
</div>
) : (
<SocialViewFeed
items={feedItems}
loading={false}
onPlayTrack={playTrack}
onLoadMore={loadMore}
hasMore={hasMore ?? false}
isLoadingMore={isLoadingMore}
feedFilter={feedFilter}
onFeedFilterChange={setFeedFilter}
/>
)}
<SocialViewTrending />
</div>

View file

@ -0,0 +1,83 @@
import { useQuery } from '@tanstack/react-query';
import { Card } from '@/components/ui/card';
import { Hash, Users } from 'lucide-react';
import { Skeleton } from '@/components/ui/skeleton';
import { socialService } from '@/services/socialService';
export function SocialViewExplore() {
const { data, isLoading, isError } = useQuery({
queryKey: ['social', 'explore'],
queryFn: () => socialService.getExplore(),
staleTime: 5 * 60 * 1000,
});
const trending = data?.trending ?? [];
const suggestedUsers = data?.suggestedUsers ?? [];
return (
<div className="col-span-1 lg:col-span-6 space-y-6">
<h2 className="text-2xl font-bold text-foreground mb-1 tracking-tight">Explore</h2>
<p className="text-muted-foreground text-xs mb-6">Trending hashtags and suggested users</p>
<Card variant="glass" className="p-4 border-white/5 bg-black/20 backdrop-blur-xl">
<h3 className="font-bold text-sm text-foreground uppercase tracking-wider mb-4 flex items-center gap-2">
<Hash className="w-4 h-4 text-primary" /> Trending
</h3>
{isLoading ? (
<div className="flex flex-wrap gap-2">
{[1, 2, 3, 4, 5].map((i) => (
<Skeleton key={i} className="h-6 w-20 rounded" />
))}
</div>
) : (
<div className="flex flex-wrap gap-2">
{trending.length > 0 ? (
trending.map((t) => (
<span
key={t.tag}
className="text-xs bg-muted px-2 py-1 rounded text-muted-foreground cursor-pointer hover:text-foreground hover:bg-muted/80 transition-all"
>
{t.tag} ({t.count})
</span>
))
) : (
<p className="text-sm text-muted-foreground">No trending tags yet.</p>
)}
</div>
)}
</Card>
<Card variant="glass" className="p-4 border-white/5 bg-black/20 backdrop-blur-xl">
<h3 className="font-bold text-sm text-foreground uppercase tracking-wider mb-4 flex items-center gap-2">
<Users className="w-4 h-4 text-primary" /> Suggested Users
</h3>
{isLoading ? (
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-12 w-full rounded" />
))}
</div>
) : (
<div>
{suggestedUsers.length > 0 ? (
<div className="space-y-2">
{(suggestedUsers as { id: string; username?: string }[]).map((u) => (
<div key={u.id} className="flex items-center gap-3 p-2 rounded-lg hover:bg-muted/50">
<div className="w-8 h-8 rounded-full bg-muted" />
<span className="text-sm font-medium">{u.username ?? u.id}</span>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">No suggestions for now.</p>
)}
</div>
)}
</Card>
{isError && (
<p className="text-xs text-muted-foreground">Could not load explore data.</p>
)}
</div>
);
}

View file

@ -24,9 +24,11 @@ interface SocialViewFeedProps {
onLoadMore?: () => void;
hasMore?: boolean;
isLoadingMore?: boolean;
feedFilter?: 'all' | 'following' | 'groups';
onFeedFilterChange?: (filter: 'all' | 'following' | 'groups') => void;
}
export function SocialViewFeed({ items, loading, onPlayTrack, onLoadMore, hasMore, isLoadingMore }: SocialViewFeedProps) {
export function SocialViewFeed({ items, loading, onPlayTrack, onLoadMore, hasMore, isLoadingMore, feedFilter = 'all', onFeedFilterChange }: SocialViewFeedProps) {
const [hasNewPosts, setHasNewPosts] = useState(false);
// Simulate periodic new-posts availability
@ -46,7 +48,25 @@ export function SocialViewFeed({ items, loading, onPlayTrack, onLoadMore, hasMor
<div className="col-span-1 lg:col-span-6 space-y-6">
<div className="mb-4">
<h2 className="text-2xl font-bold text-foreground mb-1 tracking-tight">Community Feed</h2>
<p className="text-muted-foreground text-xs">New uploads from the network</p>
<p className="text-muted-foreground text-xs mb-3">New uploads from the network</p>
{onFeedFilterChange && (
<div className="flex gap-2">
{(['all', 'following', 'groups'] as const).map((f) => (
<button
key={f}
type="button"
onClick={() => onFeedFilterChange(f)}
className={`text-xs px-3 py-1.5 rounded-lg transition-colors ${
feedFilter === f
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground hover:text-foreground'
}`}
>
{f === 'all' ? 'All' : f === 'following' ? 'Following' : 'Groups'}
</button>
))}
</div>
)}
</div>
{/* New Posts Banner */}

View file

@ -1,6 +1,6 @@
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { TrendingUp, Users } from 'lucide-react';
import { Compass, TrendingUp, Users } from 'lucide-react';
import type { SocialTabKey } from './types';
interface SocialViewSidebarProps {
@ -48,7 +48,15 @@ export function SocialViewSidebar({
<TrendingUp className="w-4 h-4" /> Fresh Tracks
</Button>
<Button
variant="ghost"
variant={activeTab === 'explore' ? 'outline' : 'ghost'}
size="sm"
className="w-full justify-start"
onClick={() => onTabChange('explore')}
>
<Compass className="w-4 h-4" /> Explore
</Button>
<Button
variant={activeTab === 'communities' ? 'outline' : 'ghost'}
size="sm"
className="w-full justify-start"
onClick={() => onTabChange('communities')}

View file

@ -1,4 +1,4 @@
export type SocialTabKey = 'feed' | 'communities';
export type SocialTabKey = 'feed' | 'explore' | 'communities';
export interface SocialViewProps {
onViewProfile: (userId: string | null) => void;

View file

@ -8,6 +8,7 @@ import type { SocialTabKey } from './types';
export function useSocialView() {
const { playTrack } = useAudio();
const [activeTab, setActiveTab] = useState<SocialTabKey>('feed');
const [feedFilter, setFeedFilter] = useState<'all' | 'following' | 'groups'>('all');
const {
data,
@ -19,11 +20,12 @@ export function useSocialView() {
isFetchingNextPage,
refetch,
} = useInfiniteQuery({
queryKey: ['socialFeed'],
queryKey: ['socialFeed', feedFilter],
queryFn: async ({ pageParam }) => {
const res = await socialService.getFeed({
limit: 20,
cursor: pageParam as string | undefined,
type: feedFilter,
});
return res;
},
@ -43,6 +45,8 @@ export function useSocialView() {
return {
activeTab,
setActiveTab,
feedFilter,
setFeedFilter,
feedItems,
loading: isLoading,
error: isError,

View file

@ -5,6 +5,20 @@
import { http, HttpResponse } from 'msw';
export const handlersSocial = [
http.get('*/api/v1/social/explore', () => {
return HttpResponse.json({
success: true,
data: {
trending: [
{ tag: '#Techno', count: 42 },
{ tag: '#Synthwave', count: 28 },
{ tag: '#NewGear', count: 18 },
],
suggested_users: [],
},
});
}),
http.get('*/api/v1/social/trending', () => {
return HttpResponse.json({
success: true,

View file

@ -20,9 +20,18 @@ export interface SocialFeedItem {
}
export const socialService = {
getFeed: async (params?: { cursor?: string; limit?: number }) => {
getExplore: async () => {
const response = await apiClient.get<{ trending: { tag: string; count: number }[]; suggested_users: unknown[] }>('/social/explore');
const data = response.data as { trending?: { tag: string; count: number }[]; suggested_users?: unknown[] };
return {
trending: data?.trending ?? [],
suggestedUsers: data?.suggested_users ?? [],
};
},
getFeed: async (params?: { cursor?: string; limit?: number; type?: 'all' | 'following' | 'groups' }) => {
const response = await apiClient.get<{ items?: BackendFeedItem[]; next_cursor?: string } | BackendFeedItem[]>('/social/feed', {
params: { limit: params?.limit ?? 20, cursor: params?.cursor },
params: { limit: params?.limit ?? 20, cursor: params?.cursor, type: params?.type ?? 'all' },
});
const payload = response.data as { items?: BackendFeedItem[]; next_cursor?: string } | BackendFeedItem[];
const raw = Array.isArray(payload) ? payload : payload?.items ?? [];

View file

@ -18,7 +18,12 @@ func (r *APIRouter) setupSocialRoutes(router *gin.RouterGroup) {
social := router.Group("/social")
{
social.GET("/feed", socialHandler.GetFeed)
if r.config.AuthMiddleware != nil {
social.GET("/feed", r.config.AuthMiddleware.OptionalAuth(), socialHandler.GetFeed)
} else {
social.GET("/feed", socialHandler.GetFeed)
}
social.GET("/explore", socialHandler.GetExplore)
social.GET("/trending", socialHandler.GetTrending)
social.GET("/posts/user/:user_id", socialHandler.GetPostsByUser)
social.GET("/groups", groupHandler.ListGroups)

View file

@ -16,7 +16,7 @@ import (
// SocialService gère les interactions sociales
type SocialService interface {
CreatePost(ctx context.Context, userID uuid.UUID, content string, attachments map[string]uuid.UUID) (*Post, error)
GetGlobalFeed(ctx context.Context, limit, offset int) ([]FeedItem, error)
GetGlobalFeed(ctx context.Context, limit, offset int, feedType string, userID *uuid.UUID) ([]FeedItem, error)
GetUserFeed(ctx context.Context, userID uuid.UUID, limit, offset int) ([]FeedItem, error)
GetPostsByUser(ctx context.Context, userID uuid.UUID, limit, offset int) ([]Post, error)
@ -78,10 +78,15 @@ func (s *Service) CreatePost(ctx context.Context, userID uuid.UUID, content stri
return post, nil
}
// GetGlobalFeed récupère un flux d'activité global (S1.2: enrichi avec actor_name, actor_avatar, track)
func (s *Service) GetGlobalFeed(ctx context.Context, limit, offset int) ([]FeedItem, error) {
// GetGlobalFeed récupère un flux d'activité global (S1.2, S1.6: enrichi, type filter)
func (s *Service) GetGlobalFeed(ctx context.Context, limit, offset int, feedType string, userID *uuid.UUID) ([]FeedItem, error) {
query := s.db.WithContext(ctx).Model(&Post{})
if feedType == "following" && userID != nil {
// Posts from users that current user follows
query = query.Joins("INNER JOIN follows ON follows.followed_id = posts.user_id AND follows.follower_id = ?", *userID)
}
var posts []Post
if err := s.db.WithContext(ctx).Order("created_at desc").Limit(limit).Offset(offset).Find(&posts).Error; err != nil {
if err := query.Order("posts.created_at desc").Limit(limit).Offset(offset).Find(&posts).Error; err != nil {
return nil, err
}

View file

@ -155,8 +155,13 @@ func (h *SocialHandler) AddComment(c *gin.Context) {
RespondSuccess(c, http.StatusCreated, comment)
}
// GetFeed récupère le feed global (S1.2, S1.4: cursor, limit)
// GetFeed récupère le feed global (S1.2, S1.4, S1.6: cursor, limit, type filter)
func (h *SocialHandler) GetFeed(c *gin.Context) {
feedType := c.DefaultQuery("type", "all") // all | following | groups
if feedType != "all" && feedType != "following" && feedType != "groups" {
feedType = "all"
}
limitParam := c.DefaultQuery("limit", "20")
limit := 20
if l, err := strconv.Atoi(limitParam); err == nil && l > 0 {
@ -175,7 +180,15 @@ func (h *SocialHandler) GetFeed(c *gin.Context) {
}
}
feed, err := h.service.GetGlobalFeed(c.Request.Context(), limit, offset)
var userID *uuid.UUID
if feedType == "following" {
if uid, ok := GetUserIDUUID(c); ok {
userID = &uid
} else {
feedType = "all" // Fallback if not authenticated
}
}
feed, err := h.service.GetGlobalFeed(c.Request.Context(), limit, offset, feedType, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get feed"})
return
@ -190,6 +203,28 @@ func (h *SocialHandler) GetFeed(c *gin.Context) {
})
}
// GetExplore returns trending hashtags and suggested users (S1.5)
func (h *SocialHandler) GetExplore(c *gin.Context) {
limitParam := c.DefaultQuery("limit", "10")
limit := 10
if l, err := strconv.Atoi(limitParam); err == nil && l > 0 {
limit = l
if limit > 50 {
limit = 50
}
}
tags, err := h.service.GetTrendingHashtags(c.Request.Context(), limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get explore data"})
return
}
// suggested_users: placeholder - can add users not followed with most followers
RespondSuccess(c, http.StatusOK, gin.H{
"trending": tags,
"suggested_users": []interface{}{},
})
}
// GetTrending returns trending hashtags from recent posts (v0.203 Lot L)
func (h *SocialHandler) GetTrending(c *gin.Context) {
limitParam := c.DefaultQuery("limit", "10")