init
This commit is contained in:
493
web/src/components/PostList.tsx
Normal file
493
web/src/components/PostList.tsx
Normal file
@@ -0,0 +1,493 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Card,
|
||||
Badge,
|
||||
Table,
|
||||
Avatar,
|
||||
Button,
|
||||
Space,
|
||||
Tag,
|
||||
Typography,
|
||||
Select,
|
||||
Drawer,
|
||||
Form
|
||||
} from 'antd';
|
||||
import {
|
||||
UserOutlined,
|
||||
VerifiedOutlined,
|
||||
ShopOutlined,
|
||||
FilterOutlined,
|
||||
YoutubeOutlined,
|
||||
InstagramOutlined,
|
||||
LinkedinOutlined,
|
||||
FacebookOutlined,
|
||||
GlobalOutlined,
|
||||
MessageOutlined
|
||||
} from '@ant-design/icons';
|
||||
import { format } from 'date-fns';
|
||||
import { postsApi } from '../utils/api';
|
||||
|
||||
// API response type definition based on backend structure
|
||||
interface ApiPost {
|
||||
post_id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
platform: string;
|
||||
post_url: string;
|
||||
published_at: string;
|
||||
updated_at: string;
|
||||
influencer_id: string;
|
||||
views_count?: number;
|
||||
likes_count?: number;
|
||||
comments_count?: number;
|
||||
shares_count?: number;
|
||||
influencer?: {
|
||||
influencer_id: string;
|
||||
name: string;
|
||||
platform: string;
|
||||
profile_url: string;
|
||||
followers_count: number;
|
||||
};
|
||||
}
|
||||
|
||||
// Frontend representation of a post
|
||||
interface FrontendPost {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
author: string;
|
||||
authorType: 'influencer' | 'brand' | 'official';
|
||||
platform: 'youtube' | 'instagram' | 'facebook' | 'linkedin' | 'tiktok';
|
||||
contentType: 'post' | 'video' | 'reel' | 'short';
|
||||
timestamp: string;
|
||||
engagement: {
|
||||
views?: number;
|
||||
likes?: number;
|
||||
comments?: number;
|
||||
shares?: number;
|
||||
};
|
||||
url?: string;
|
||||
}
|
||||
|
||||
interface PostListProps {
|
||||
influencerId?: string; // Optional influencer ID to filter posts by influencer
|
||||
projectId?: string; // Optional project ID to filter posts by project
|
||||
}
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
const { Option } = Select;
|
||||
|
||||
const PostList: React.FC<PostListProps> = ({ influencerId, projectId }) => {
|
||||
const navigate = useNavigate();
|
||||
const [posts, setPosts] = useState<FrontendPost[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedPost, setSelectedPost] = useState<FrontendPost | null>(null);
|
||||
const [platformFilter, setPlatformFilter] = useState<string>('all');
|
||||
const [contentTypeFilter, setContentTypeFilter] = useState<string>('all');
|
||||
const [showFilters, setShowFilters] = useState<boolean>(false);
|
||||
|
||||
// Fetch posts data
|
||||
useEffect(() => {
|
||||
const fetchPosts = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Build query parameters
|
||||
const params: Record<string, string | number> = {
|
||||
limit: 50,
|
||||
offset: 0
|
||||
};
|
||||
|
||||
if (influencerId) {
|
||||
params.influencer_id = influencerId;
|
||||
}
|
||||
|
||||
if (projectId) {
|
||||
params.project_id = projectId;
|
||||
}
|
||||
|
||||
const response = await postsApi.getPosts(params);
|
||||
|
||||
// Process returned data
|
||||
const apiPosts: ApiPost[] = response.data.posts || [];
|
||||
|
||||
// Transform API posts to frontend format
|
||||
const processedPosts: FrontendPost[] = apiPosts.map((apiPost) => {
|
||||
// Determine content type based on post data
|
||||
let contentType: FrontendPost['contentType'] = 'post';
|
||||
if (apiPost.platform === 'youtube') {
|
||||
contentType = 'video';
|
||||
} else if (
|
||||
apiPost.platform === 'instagram' &&
|
||||
apiPost.post_url?.includes('/reels/')
|
||||
) {
|
||||
contentType = 'reel';
|
||||
}
|
||||
|
||||
return {
|
||||
id: apiPost.post_id,
|
||||
title: apiPost.title || 'Untitled Post',
|
||||
description: apiPost.description || '',
|
||||
author: apiPost.influencer?.name || 'Unknown',
|
||||
authorType: 'influencer',
|
||||
platform: apiPost.platform as FrontendPost['platform'],
|
||||
contentType,
|
||||
timestamp: apiPost.published_at,
|
||||
engagement: {
|
||||
views: apiPost.views_count,
|
||||
likes: apiPost.likes_count,
|
||||
comments: apiPost.comments_count,
|
||||
shares: apiPost.shares_count
|
||||
},
|
||||
url: apiPost.post_url
|
||||
};
|
||||
});
|
||||
|
||||
setPosts(processedPosts);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch posts:', err);
|
||||
setError('Failed to load posts. Please try again later.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchPosts();
|
||||
}, [influencerId, projectId]);
|
||||
|
||||
// Filter posts based on selected filters
|
||||
const filteredPosts = posts.filter((post) => {
|
||||
if (platformFilter !== 'all' && post.platform !== platformFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (contentTypeFilter !== 'all' && post.contentType !== contentTypeFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// Get platform icon
|
||||
const getPlatformIcon = (platform: string) => {
|
||||
switch (platform) {
|
||||
case 'youtube':
|
||||
return <YoutubeOutlined style={{ color: '#FF0000' }} />;
|
||||
case 'instagram':
|
||||
return <InstagramOutlined style={{ color: '#E1306C' }} />;
|
||||
case 'facebook':
|
||||
return <FacebookOutlined style={{ color: '#1877F2' }} />;
|
||||
case 'linkedin':
|
||||
return <LinkedinOutlined style={{ color: '#0A66C2' }} />;
|
||||
default:
|
||||
return <GlobalOutlined />;
|
||||
}
|
||||
};
|
||||
|
||||
// Get content type badge
|
||||
const getContentTypeBadge = (contentType?: string) => {
|
||||
if (!contentType) return null;
|
||||
|
||||
switch (contentType) {
|
||||
case 'post':
|
||||
return <Tag color="blue">Post</Tag>;
|
||||
case 'video':
|
||||
return <Tag color="green">Video</Tag>;
|
||||
case 'reel':
|
||||
return <Tag color="purple">Reel</Tag>;
|
||||
case 'short':
|
||||
return <Tag color="orange">Short</Tag>;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Get author type badge
|
||||
const getAuthorTypeBadge = (authorType: string) => {
|
||||
switch (authorType) {
|
||||
case 'influencer':
|
||||
return <Badge count={<UserOutlined style={{ color: '#1890ff' }} />} />;
|
||||
case 'brand':
|
||||
return <Badge count={<ShopOutlined style={{ color: '#52c41a' }} />} />;
|
||||
case 'official':
|
||||
return <Badge count={<VerifiedOutlined style={{ color: '#722ed1' }} />} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Get platform name
|
||||
const getPlatformName = (platform: string) => {
|
||||
switch (platform) {
|
||||
case 'youtube':
|
||||
return 'YouTube';
|
||||
case 'instagram':
|
||||
return 'Instagram';
|
||||
case 'facebook':
|
||||
return 'Facebook';
|
||||
case 'linkedin':
|
||||
return 'LinkedIn';
|
||||
case 'tiktok':
|
||||
return 'TikTok';
|
||||
default:
|
||||
return platform;
|
||||
}
|
||||
};
|
||||
|
||||
// Handle post click to view comments
|
||||
const handleViewComments = (postId: string) => {
|
||||
navigate(`/comments?post_id=${postId}`);
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return <div>{error}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card
|
||||
title="Posts"
|
||||
extra={
|
||||
<Space>
|
||||
<Button
|
||||
icon={<FilterOutlined />}
|
||||
onClick={() => setShowFilters(true)}
|
||||
>
|
||||
Filters
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
loading={loading}
|
||||
>
|
||||
<Table
|
||||
dataSource={filteredPosts}
|
||||
rowKey="id"
|
||||
pagination={{ pageSize: 10 }}
|
||||
onRow={(post) => ({
|
||||
onClick: () => handleViewComments(post.id),
|
||||
style: { cursor: 'pointer' }
|
||||
})}
|
||||
columns={[
|
||||
{
|
||||
title: 'Post',
|
||||
dataIndex: 'title',
|
||||
key: 'title',
|
||||
render: (_: unknown, post: FrontendPost) => (
|
||||
<div>
|
||||
<Space align="start">
|
||||
{getPlatformIcon(post.platform)}
|
||||
<div>
|
||||
<div>
|
||||
<Text strong>{post.title}</Text> {' '}
|
||||
{getContentTypeBadge(post.contentType)}
|
||||
</div>
|
||||
<div>
|
||||
<Text type="secondary" ellipsis={{ tooltip: post.description }}>
|
||||
{post.description?.length > 80
|
||||
? `${post.description.substring(0, 80)}...`
|
||||
: post.description}
|
||||
</Text>
|
||||
</div>
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<Space size={16}>
|
||||
<Text type="secondary">
|
||||
{format(new Date(post.timestamp), 'MMM d, yyyy')}
|
||||
</Text>
|
||||
{post.url && (
|
||||
<a
|
||||
href={post.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()} // Prevent row click when clicking the link
|
||||
>
|
||||
View Post
|
||||
</a>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
</Space>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Author',
|
||||
dataIndex: 'author',
|
||||
key: 'author',
|
||||
render: (_: unknown, post: FrontendPost) => (
|
||||
<Space>
|
||||
<Avatar icon={<UserOutlined />} />
|
||||
<Text>{post.author}</Text>
|
||||
{getAuthorTypeBadge(post.authorType)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Platform',
|
||||
dataIndex: 'platform',
|
||||
key: 'platform',
|
||||
render: (platform: string) => getPlatformName(platform),
|
||||
},
|
||||
{
|
||||
title: 'Engagement',
|
||||
key: 'engagement',
|
||||
render: (_: unknown, post: FrontendPost) => (
|
||||
<Space direction="vertical" size={0}>
|
||||
{post.engagement.views !== undefined && (
|
||||
<Text>Views: {post.engagement.views.toLocaleString()}</Text>
|
||||
)}
|
||||
{post.engagement.likes !== undefined && (
|
||||
<Text>Likes: {post.engagement.likes.toLocaleString()}</Text>
|
||||
)}
|
||||
{post.engagement.comments !== undefined && (
|
||||
<Text>Comments: {post.engagement.comments.toLocaleString()}</Text>
|
||||
)}
|
||||
{post.engagement.shares !== undefined && (
|
||||
<Text>Shares: {post.engagement.shares.toLocaleString()}</Text>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Actions',
|
||||
key: 'actions',
|
||||
render: (_: unknown, post: FrontendPost) => (
|
||||
<Space>
|
||||
<Button
|
||||
type="link"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation(); // Prevent row click
|
||||
setSelectedPost(post);
|
||||
}}
|
||||
>
|
||||
Details
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
icon={<MessageOutlined />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation(); // Prevent row click
|
||||
handleViewComments(post.id);
|
||||
}}
|
||||
>
|
||||
Comments
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Filters Drawer */}
|
||||
<Drawer
|
||||
title="Filters"
|
||||
placement="right"
|
||||
onClose={() => setShowFilters(false)}
|
||||
open={showFilters}
|
||||
width={300}
|
||||
>
|
||||
<Form layout="vertical">
|
||||
<Form.Item label="Platform">
|
||||
<Select
|
||||
value={platformFilter}
|
||||
onChange={(value: string) => setPlatformFilter(value)}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<Option value="all">All Platforms</Option>
|
||||
<Option value="youtube">YouTube</Option>
|
||||
<Option value="instagram">Instagram</Option>
|
||||
<Option value="facebook">Facebook</Option>
|
||||
<Option value="linkedin">LinkedIn</Option>
|
||||
<Option value="tiktok">TikTok</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="Content Type">
|
||||
<Select
|
||||
value={contentTypeFilter}
|
||||
onChange={(value: string) => setContentTypeFilter(value)}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<Option value="all">All Types</Option>
|
||||
<Option value="post">Post</Option>
|
||||
<Option value="video">Video</Option>
|
||||
<Option value="reel">Reel</Option>
|
||||
<Option value="short">Short</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Drawer>
|
||||
|
||||
{/* Post Detail Drawer */}
|
||||
<Drawer
|
||||
title="Post Details"
|
||||
placement="right"
|
||||
onClose={() => setSelectedPost(null)}
|
||||
open={!!selectedPost}
|
||||
width={500}
|
||||
>
|
||||
{selectedPost && (
|
||||
<div>
|
||||
<Title level={4}>{selectedPost.title}</Title>
|
||||
<Space>
|
||||
{getPlatformIcon(selectedPost.platform)}
|
||||
<Text>{getPlatformName(selectedPost.platform)}</Text>
|
||||
{getContentTypeBadge(selectedPost.contentType)}
|
||||
</Space>
|
||||
|
||||
<div style={{ margin: '16px 0' }}>
|
||||
<Text>{selectedPost.description}</Text>
|
||||
</div>
|
||||
|
||||
<div style={{ margin: '16px 0' }}>
|
||||
<Text strong>Author:</Text> {selectedPost.author} {getAuthorTypeBadge(selectedPost.authorType)}
|
||||
</div>
|
||||
|
||||
<div style={{ margin: '16px 0' }}>
|
||||
<Text strong>Published:</Text> {format(new Date(selectedPost.timestamp), 'PPP')}
|
||||
</div>
|
||||
|
||||
<Card title="Engagement Statistics">
|
||||
<Space direction="vertical" size={8} style={{ width: '100%' }}>
|
||||
{selectedPost.engagement.views !== undefined && (
|
||||
<div>
|
||||
<Text strong>Views:</Text> {selectedPost.engagement.views.toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
{selectedPost.engagement.likes !== undefined && (
|
||||
<div>
|
||||
<Text strong>Likes:</Text> {selectedPost.engagement.likes.toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
{selectedPost.engagement.comments !== undefined && (
|
||||
<div>
|
||||
<Text strong>Comments:</Text> {selectedPost.engagement.comments.toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
{selectedPost.engagement.shares !== undefined && (
|
||||
<div>
|
||||
<Text strong>Shares:</Text> {selectedPost.engagement.shares.toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
{selectedPost.url && (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<Button type="primary" href={selectedPost.url} target="_blank">
|
||||
View Original Post
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PostList;
|
||||
Reference in New Issue
Block a user