Files
promote/web/src/components/PostList.tsx
2025-03-11 13:05:51 +08:00

795 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Card,
Badge,
Table,
Avatar,
Button,
Space,
Tag,
Typography,
Select,
Drawer,
Form,
Empty,
Spin
} from 'antd';
import {
UserOutlined,
VerifiedOutlined,
ShopOutlined,
FilterOutlined,
YoutubeOutlined,
InstagramOutlined,
LinkedinOutlined,
FacebookOutlined,
GlobalOutlined,
MessageOutlined
} from '@ant-design/icons';
import { format } from 'date-fns';
import supabase from '../utils/supabase';
// 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);
const [totalPosts, setTotalPosts] = useState<number>(0);
const [offset, setOffset] = useState<number>(0);
const [hasMore, setHasMore] = useState<boolean>(true);
const [isLoadingMore, setIsLoadingMore] = useState<boolean>(false);
const observer = useRef<IntersectionObserver | null>(null);
const loadMoreRef = useRef<HTMLDivElement>(null);
// Fetch posts data
const fetchPosts = async () => {
setLoading(true);
setError(null);
setPosts([]);
setOffset(0);
setHasMore(true);
try {
// Build query parameters
const params = new URLSearchParams();
const pageSize = 20;
params.append('limit', pageSize.toString());
params.append('offset', '0');
params.append('sort', 'published_at');
params.append('order', 'desc');
if (platformFilter && platformFilter !== 'all') {
params.append('platform', platformFilter);
}
if (influencerId) {
params.append('influencer_id', influencerId);
}
if (projectId) {
params.append('project_id', projectId);
}
// Get auth token from Supabase
const { data: { session } } = await supabase.auth.getSession();
const token = session?.access_token;
if (!token) {
console.error('Authentication token not found');
setError('Authentication required. Please log in again.');
setLoading(false);
// Fall back to mock data if no token
const mockData = getMockPosts();
setPosts(mockData);
setTotalPosts(mockData.length);
setHasMore(false);
return;
}
// Make API request
const response = await fetch(`http://localhost:4000/api/posts?${params.toString()}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
}
});
if (response.status === 401) {
console.error('Authentication failed: Unauthorized');
setError('Your session has expired. Please log in again.');
// Fall back to mock data on auth error
const mockData = getMockPosts();
setPosts(mockData);
setTotalPosts(mockData.length);
setHasMore(false);
setLoading(false);
return;
}
if (!response.ok) {
throw new Error(`API request failed with status: ${response.status}`);
}
const data = await response.json();
if (data && Array.isArray(data.posts)) {
// Transform API posts to frontend format
const frontendPosts: FrontendPost[] = data.posts.map((apiPost: ApiPost) => ({
id: apiPost.post_id,
title: apiPost.title || 'Untitled Post',
description: apiPost.description || '',
author: apiPost.influencer?.name || 'Unknown Author',
authorType: 'influencer', // Default to influencer
platform: apiPost.platform as FrontendPost['platform'], // Cast to expected platform type
contentType: determineContentType(apiPost),
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(frontendPosts);
setTotalPosts(data.total || frontendPosts.length);
setOffset(frontendPosts.length);
setHasMore(frontendPosts.length === pageSize);
} else {
console.warn('Invalid data format received from API, using mock data');
// Fall back to mock data if API response format is unexpected
const mockData = getMockPosts();
setPosts(mockData);
setTotalPosts(mockData.length);
setHasMore(false);
}
} catch (err) {
console.error('Error fetching posts:', err);
setError('Failed to fetch posts. Using mock data instead.');
// Fall back to mock data on error
const mockData = getMockPosts();
setPosts(mockData);
setTotalPosts(mockData.length);
setHasMore(false);
} finally {
setLoading(false);
}
};
// 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}`);
};
// 根据帖子信息确定内容类型
const determineContentType = (apiPost: ApiPost): 'post' | 'video' | 'reel' | 'short' => {
const platform = apiPost.platform?.toLowerCase();
const url = apiPost.post_url?.toLowerCase() || '';
if (platform === 'youtube') {
if (url.includes('/shorts/')) {
return 'short';
}
return 'video';
}
if (platform === 'instagram') {
if (url.includes('/reel/')) {
return 'reel';
}
return 'post';
}
if (platform === 'tiktok') {
return 'short';
}
return 'post';
};
// Helper function to get mock posts (extracted from useMockData)
const getMockPosts = (): FrontendPost[] => {
return [
{
id: '1',
title: 'How to Get Started with React',
description: 'A beginner-friendly guide to React development',
author: 'Jane Smith',
authorType: 'influencer',
platform: 'youtube',
contentType: 'video',
timestamp: '2023-05-15T14:30:00Z',
engagement: {
views: 15000,
likes: 1200,
comments: 85,
shares: 320
},
url: 'https://youtube.com/watch?v=abc123'
},
// ... add more mock posts as needed
];
};
// 加载更多数据的函数
const loadMorePosts = useCallback(async () => {
if (isLoadingMore || !hasMore) return;
console.log('Loading more posts from offset:', offset);
setIsLoadingMore(true);
try {
// 构建查询参数
const params = new URLSearchParams();
const pageSize = 20;
params.append('limit', pageSize.toString());
params.append('offset', offset.toString());
params.append('sort', 'published_at');
params.append('order', 'desc');
if (platformFilter && platformFilter !== 'all') {
params.append('platform', platformFilter);
}
if (influencerId) {
params.append('influencer_id', influencerId);
}
if (projectId) {
params.append('project_id', projectId);
}
// 获取认证token
const token = await getAuthToken();
if (!token) {
console.error('Authentication token not found');
setError('Authentication required. Please log in again.');
setIsLoadingMore(false);
return;
}
console.log('Fetching more posts with params:', params.toString());
// 发送API请求
const response = await fetch(`http://localhost:4000/api/posts?${params.toString()}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
}
});
if (response.status === 401) {
console.error('Authentication failed: Unauthorized');
setError('Your session has expired. Please log in again.');
setIsLoadingMore(false);
return;
}
if (!response.ok) {
throw new Error(`API request failed with status: ${response.status}`);
}
const data = await response.json();
console.log('Received more posts:', data?.posts?.length || 0);
if (data && Array.isArray(data.posts)) {
// 转换API返回的数据为前端需要的格式
const newPosts: FrontendPost[] = data.posts.map((apiPost: ApiPost) => ({
id: apiPost.post_id,
title: apiPost.title || 'Untitled Post',
description: apiPost.description || '',
author: apiPost.influencer?.name || 'Unknown Author',
authorType: 'influencer', // 默认为influencer类型
platform: apiPost.platform as FrontendPost['platform'],
contentType: determineContentType(apiPost),
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(prevPosts => [...prevPosts, ...newPosts]);
setTotalPosts(data.total || totalPosts);
setOffset(prevOffset => prevOffset + newPosts.length);
// 检查是否还有更多数据
setHasMore(newPosts.length === pageSize);
console.log('Updated posts count:', posts.length + newPosts.length);
} else {
// 如果没有更多数据
console.log('No more posts available');
setHasMore(false);
}
} catch (err) {
console.error('Error fetching more posts:', err);
setError('Failed to fetch more posts.');
} finally {
setIsLoadingMore(false);
}
}, [offset, platformFilter, influencerId, projectId, isLoadingMore, hasMore, totalPosts, posts.length]);
// 设置Intersection Observer来监测滚动
useEffect(() => {
if (loading) return;
if (observer.current) {
observer.current.disconnect();
}
observer.current = new IntersectionObserver(entries => {
if (entries[0].isIntersecting && hasMore && !isLoadingMore) {
console.log('Intersection observed, loading more posts...');
loadMorePosts();
}
}, {
rootMargin: '100px', // 提前100px触发加载
threshold: 0.1 // 降低阈值,使其更容易触发
});
if (loadMoreRef.current) {
observer.current.observe(loadMoreRef.current);
}
return () => {
if (observer.current) {
observer.current.disconnect();
}
};
}, [loading, hasMore, isLoadingMore, loadMorePosts]);
// 在组件加载时获取数据
useEffect(() => {
console.log('Fetching initial posts data...');
fetchPosts();
// 组件卸载时清理
return () => {
if (observer.current) {
observer.current.disconnect();
}
};
}, [influencerId, projectId, platformFilter]);
// 获取Supabase会话和token的函数
const getAuthToken = async () => {
try {
// 尝试从localStorage中查找Supabase token
// Supabase通常将token存储在以sb-开头的键中
const keys = Object.keys(localStorage);
const supabaseTokenKey = keys.find(key => key.startsWith('sb-') && localStorage.getItem(key)?.includes('access_token'));
if (supabaseTokenKey) {
try {
const supabaseData = JSON.parse(localStorage.getItem(supabaseTokenKey) || '{}');
return supabaseData.access_token;
} catch (e) {
console.error('Error parsing Supabase token:', e);
}
}
// 如果没有找到Supabase token尝试使用常规token
return localStorage.getItem('token');
} catch (error) {
console.error('Error getting auth token:', error);
return null;
}
};
if (error) {
return <div>{error}</div>;
}
return (
<div className="post-list-container">
<Card
title="Posts"
extra={
<Space>
<Button
icon={<FilterOutlined />}
onClick={() => setShowFilters(true)}
>
Filters
</Button>
</Space>
}
loading={loading}
className="mb-4"
>
<Table
dataSource={filteredPosts}
rowKey="id"
pagination={false}
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>
),
},
]}
scroll={{ x: 'max-content' }}
/>
{/* 无限滚动加载指示器 */}
<div
ref={loadMoreRef}
style={{
textAlign: 'center',
padding: '20px 0',
visibility: loading || !hasMore ? 'hidden' : 'visible'
}}
>
{isLoadingMore && (
<div className="py-4">
<Spin tip="Loading more posts..." />
</div>
)}
{!hasMore && posts.length > 0 && (
<div className="py-4 text-gray-500">
No more posts to load
</div>
)}
{!hasMore && posts.length === 0 && !loading && (
<Empty description="No posts found" />
)}
</div>
</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;