feat(social): GET /social/explore, explore tab, feed filters all/following/groups (S1.5, S1.6)
This commit is contained in:
parent
b572863847
commit
28e6642fa6
11 changed files with 219 additions and 29 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
export type SocialTabKey = 'feed' | 'communities';
|
||||
export type SocialTabKey = 'feed' | 'explore' | 'communities';
|
||||
|
||||
export interface SocialViewProps {
|
||||
onViewProfile: (userId: string | null) => void;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 ?? [];
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Reference in a new issue