mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-04-20 16:22:04 +00:00
trakt comment added
This commit is contained in:
parent
ca52a81141
commit
cc2e0308d7
4 changed files with 1406 additions and 19 deletions
913
src/components/metadata/CommentsSection.tsx
Normal file
913
src/components/metadata/CommentsSection.tsx
Normal file
|
|
@ -0,0 +1,913 @@
|
||||||
|
import React, { useCallback, useState } from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
StyleSheet,
|
||||||
|
ActivityIndicator,
|
||||||
|
TouchableOpacity,
|
||||||
|
FlatList,
|
||||||
|
Dimensions,
|
||||||
|
Modal,
|
||||||
|
Alert,
|
||||||
|
} from 'react-native';
|
||||||
|
import { MaterialIcons } from '@expo/vector-icons';
|
||||||
|
import { useTheme } from '../../contexts/ThemeContext';
|
||||||
|
import { TraktContentComment } from '../../services/traktService';
|
||||||
|
import { logger } from '../../utils/logger';
|
||||||
|
import { useTraktComments } from '../../hooks/useTraktComments';
|
||||||
|
|
||||||
|
const { width } = Dimensions.get('window');
|
||||||
|
|
||||||
|
interface CommentsSectionProps {
|
||||||
|
imdbId: string;
|
||||||
|
type: 'movie' | 'show';
|
||||||
|
season?: number;
|
||||||
|
episode?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CommentItemProps {
|
||||||
|
comment: TraktContentComment;
|
||||||
|
theme: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compact comment card for horizontal scrolling
|
||||||
|
const CompactCommentCard: React.FC<{
|
||||||
|
comment: TraktContentComment;
|
||||||
|
theme: any;
|
||||||
|
onPress: () => void;
|
||||||
|
isSpoilerRevealed: boolean;
|
||||||
|
onSpoilerPress: () => void;
|
||||||
|
}> = ({ comment, theme, onPress, isSpoilerRevealed, onSpoilerPress }) => {
|
||||||
|
// Safety check - ensure comment data exists
|
||||||
|
if (!comment || !comment.comment) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle missing user data gracefully
|
||||||
|
const user = comment.user || {};
|
||||||
|
const username = user.name || user.username || 'Anonymous';
|
||||||
|
|
||||||
|
// Handle spoiler content
|
||||||
|
const hasSpoiler = comment.spoiler;
|
||||||
|
const shouldBlurContent = hasSpoiler && !isSpoilerRevealed;
|
||||||
|
|
||||||
|
const truncatedComment = comment.comment.length > 100
|
||||||
|
? comment.comment.substring(0, 100) + '...'
|
||||||
|
: comment.comment;
|
||||||
|
|
||||||
|
// Format relative time
|
||||||
|
const formatRelativeTime = (dateString: string) => {
|
||||||
|
try {
|
||||||
|
const now = new Date();
|
||||||
|
const commentDate = new Date(dateString);
|
||||||
|
const diffMs = now.getTime() - commentDate.getTime();
|
||||||
|
const diffMins = Math.floor(diffMs / (1000 * 60));
|
||||||
|
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||||
|
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
|
if (diffMins < 1) return 'now';
|
||||||
|
if (diffMins < 60) return `${diffMins}m ago`;
|
||||||
|
if (diffHours < 24) return `${diffHours}h ago`;
|
||||||
|
if (diffDays < 7) return `${diffDays}d ago`;
|
||||||
|
|
||||||
|
// For older dates, show month/day
|
||||||
|
return commentDate.toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric'
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render stars for rating (convert 1-10 rating to 1-5 stars)
|
||||||
|
const renderCompactStars = (rating: number) => {
|
||||||
|
const stars = [];
|
||||||
|
const fullStars = Math.floor(rating / 2); // Convert 10-point scale to 5 stars
|
||||||
|
const hasHalfStar = rating % 2 >= 1;
|
||||||
|
|
||||||
|
// Add full stars
|
||||||
|
for (let i = 0; i < fullStars; i++) {
|
||||||
|
stars.push(
|
||||||
|
<MaterialIcons key={`full-${i}`} name="star" size={10} color="#FFD700" />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add half star if needed
|
||||||
|
if (hasHalfStar) {
|
||||||
|
stars.push(
|
||||||
|
<MaterialIcons key="half" name="star-half" size={10} color="#FFD700" />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add empty stars to make 5 total
|
||||||
|
const filledStars = fullStars + (hasHalfStar ? 1 : 0);
|
||||||
|
const emptyStars = 5 - filledStars;
|
||||||
|
for (let i = 0; i < emptyStars; i++) {
|
||||||
|
stars.push(
|
||||||
|
<MaterialIcons key={`empty-${i}`} name="star-border" size={10} color="#FFD700" />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return stars;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePress = () => {
|
||||||
|
if (hasSpoiler && !isSpoilerRevealed) {
|
||||||
|
onSpoilerPress();
|
||||||
|
} else {
|
||||||
|
onPress();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.compactCard, { backgroundColor: theme.colors.card, borderColor: theme.colors.border }]}
|
||||||
|
onPress={handlePress}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
{/* Header Section - Fixed at top */}
|
||||||
|
<View style={styles.compactHeader}>
|
||||||
|
<Text style={[styles.compactUsername, { color: theme.colors.highEmphasis }]}>
|
||||||
|
{username}
|
||||||
|
</Text>
|
||||||
|
{user.vip && (
|
||||||
|
<View style={styles.miniVipBadge}>
|
||||||
|
<Text style={styles.miniVipText}>VIP</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Rating - Show stars */}
|
||||||
|
{comment.user_stats?.rating && (
|
||||||
|
<View style={styles.compactRating}>
|
||||||
|
{renderCompactStars(comment.user_stats.rating)}
|
||||||
|
<Text style={[styles.compactRatingText, { color: theme.colors.mediumEmphasis }]}>
|
||||||
|
{comment.user_stats.rating}/10
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Comment Preview - Flexible area that fills space */}
|
||||||
|
<View style={[styles.commentContainer, shouldBlurContent ? styles.blurredContent : undefined]}>
|
||||||
|
<Text
|
||||||
|
style={[styles.compactComment, { color: theme.colors.highEmphasis }]}
|
||||||
|
numberOfLines={shouldBlurContent ? 3 : undefined}
|
||||||
|
ellipsizeMode="tail"
|
||||||
|
>
|
||||||
|
{shouldBlurContent ? '⚠️ This comment contains spoilers. Tap to reveal.' : truncatedComment}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Meta Info - Fixed at bottom */}
|
||||||
|
<View style={styles.compactMeta}>
|
||||||
|
<View style={styles.compactBadges}>
|
||||||
|
{comment.review && (
|
||||||
|
<View style={[styles.miniReviewBadgeContainer, { backgroundColor: theme.colors.primary }]}>
|
||||||
|
<Text style={styles.miniBadgeText}>Review</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{comment.spoiler && (
|
||||||
|
<View style={[styles.miniSpoilerBadgeContainer, { backgroundColor: theme.colors.error }]}>
|
||||||
|
<Text style={styles.miniBadgeText}>Spoiler</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<View style={styles.compactStats}>
|
||||||
|
<Text style={[styles.compactTime, { color: theme.colors.mediumEmphasis }]}>
|
||||||
|
{formatRelativeTime(comment.created_at)}
|
||||||
|
</Text>
|
||||||
|
{comment.likes > 0 && (
|
||||||
|
<Text style={[styles.compactStat, { color: theme.colors.mediumEmphasis }]}>
|
||||||
|
👍 {comment.likes}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{comment.replies > 0 && (
|
||||||
|
<Text style={[styles.compactStat, { color: theme.colors.mediumEmphasis }]}>
|
||||||
|
💬 {comment.replies}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Expanded comment modal
|
||||||
|
const ExpandedCommentModal: React.FC<{
|
||||||
|
comment: TraktContentComment | null;
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
theme: any;
|
||||||
|
isSpoilerRevealed: boolean;
|
||||||
|
onSpoilerPress: () => void;
|
||||||
|
}> = ({ comment, visible, onClose, theme, isSpoilerRevealed, onSpoilerPress }) => {
|
||||||
|
if (!comment) return null;
|
||||||
|
|
||||||
|
const user = comment.user || {};
|
||||||
|
const username = user.name || user.username || 'Anonymous User';
|
||||||
|
const hasSpoiler = comment.spoiler;
|
||||||
|
const shouldBlurModalContent = hasSpoiler && !isSpoilerRevealed;
|
||||||
|
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
try {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return 'Unknown date';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderStars = (rating: number | null) => {
|
||||||
|
if (rating === null) return null;
|
||||||
|
|
||||||
|
const stars = [];
|
||||||
|
const fullStars = Math.floor(rating / 2);
|
||||||
|
const hasHalfStar = rating % 2 >= 1;
|
||||||
|
|
||||||
|
for (let i = 0; i < fullStars; i++) {
|
||||||
|
stars.push(
|
||||||
|
<MaterialIcons key={`full-${i}`} name="star" size={16} color="#FFD700" />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasHalfStar) {
|
||||||
|
stars.push(
|
||||||
|
<MaterialIcons key="half" name="star-half" size={16} color="#FFD700" />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyStars = 5 - Math.ceil(rating / 2);
|
||||||
|
for (let i = 0; i < emptyStars; i++) {
|
||||||
|
stars.push(
|
||||||
|
<MaterialIcons key={`empty-${i}`} name="star-border" size={16} color="#FFD700" />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return stars;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
visible={visible}
|
||||||
|
transparent={true}
|
||||||
|
animationType="fade"
|
||||||
|
onRequestClose={onClose}
|
||||||
|
>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.modalOverlay}
|
||||||
|
activeOpacity={1}
|
||||||
|
onPress={onClose}
|
||||||
|
>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.modalContent, {
|
||||||
|
backgroundColor: theme.colors.darkGray || '#0A0C0C',
|
||||||
|
borderColor: theme.colors.border || '#CCCCCC',
|
||||||
|
borderWidth: 1
|
||||||
|
}]}
|
||||||
|
activeOpacity={1}
|
||||||
|
onPress={() => {}} // Prevent closing when clicking on modal content
|
||||||
|
>
|
||||||
|
{/* Close Button */}
|
||||||
|
<TouchableOpacity style={styles.closeButton} onPress={onClose}>
|
||||||
|
<MaterialIcons name="close" size={24} color={theme.colors.highEmphasis} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{/* User Info */}
|
||||||
|
<View style={styles.modalHeader}>
|
||||||
|
<View style={styles.userInfo}>
|
||||||
|
<Text style={[styles.modalUsername, { color: theme.colors.highEmphasis }]}>
|
||||||
|
{username}
|
||||||
|
</Text>
|
||||||
|
{user.vip && (
|
||||||
|
<View style={styles.vipBadge}>
|
||||||
|
<Text style={styles.vipText}>VIP</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<Text style={[styles.modalDate, { color: theme.colors.mediumEmphasis }]}>
|
||||||
|
{formatDate(comment.created_at)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Rating */}
|
||||||
|
{comment.user_stats?.rating && (
|
||||||
|
<View style={styles.modalRating}>
|
||||||
|
{renderStars(comment.user_stats.rating)}
|
||||||
|
<Text style={[styles.modalRatingText, { color: theme.colors.mediumEmphasis }]}>
|
||||||
|
{comment.user_stats.rating}/10
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Full Comment */}
|
||||||
|
{shouldBlurModalContent ? (
|
||||||
|
<View style={styles.spoilerContainer}>
|
||||||
|
<Text style={[styles.spoilerWarning, { color: theme.colors.error }]}>
|
||||||
|
⚠️ This comment contains spoilers
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.revealButton, { backgroundColor: theme.colors.primary }]}
|
||||||
|
onPress={onSpoilerPress}
|
||||||
|
>
|
||||||
|
<Text style={[styles.revealButtonText, { color: theme.colors.white }]}>
|
||||||
|
Reveal Spoilers
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<Text style={[styles.modalComment, { color: theme.colors.highEmphasis }]}>
|
||||||
|
{comment.comment}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Comment Meta */}
|
||||||
|
<View style={styles.modalMeta}>
|
||||||
|
{comment.review && (
|
||||||
|
<Text style={[styles.reviewBadge, { color: theme.colors.primary }]}>Review</Text>
|
||||||
|
)}
|
||||||
|
{comment.spoiler && (
|
||||||
|
<Text style={[styles.spoilerBadge, { color: theme.colors.error }]}>Spoiler</Text>
|
||||||
|
)}
|
||||||
|
<View style={styles.modalStats}>
|
||||||
|
{comment.likes > 0 && (
|
||||||
|
<View style={styles.likesContainer}>
|
||||||
|
<MaterialIcons name="thumb-up" size={16} color={theme.colors.mediumEmphasis} />
|
||||||
|
<Text style={[styles.likesText, { color: theme.colors.mediumEmphasis }]}>
|
||||||
|
{comment.likes}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{comment.replies > 0 && (
|
||||||
|
<View style={styles.repliesContainer}>
|
||||||
|
<MaterialIcons name="chat-bubble-outline" size={16} color={theme.colors.mediumEmphasis} />
|
||||||
|
<Text style={[styles.repliesText, { color: theme.colors.mediumEmphasis }]}>
|
||||||
|
{comment.replies}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CommentsSection: React.FC<CommentsSectionProps> = ({
|
||||||
|
imdbId,
|
||||||
|
type,
|
||||||
|
season,
|
||||||
|
episode,
|
||||||
|
}) => {
|
||||||
|
const { currentTheme } = useTheme();
|
||||||
|
const [selectedComment, setSelectedComment] = useState<TraktContentComment | null>(null);
|
||||||
|
const [modalVisible, setModalVisible] = useState(false);
|
||||||
|
const [revealedSpoilers, setRevealedSpoilers] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const {
|
||||||
|
comments,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
hasMore,
|
||||||
|
isAuthenticated,
|
||||||
|
loadMore,
|
||||||
|
refresh,
|
||||||
|
} = useTraktComments({
|
||||||
|
imdbId,
|
||||||
|
type: type === 'show' ? (season !== undefined && episode !== undefined ? 'episode' :
|
||||||
|
season !== undefined ? 'season' : 'show') : 'movie',
|
||||||
|
season,
|
||||||
|
episode,
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleCommentPress = useCallback((comment: TraktContentComment) => {
|
||||||
|
setSelectedComment(comment);
|
||||||
|
setModalVisible(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleModalClose = useCallback(() => {
|
||||||
|
setModalVisible(false);
|
||||||
|
setSelectedComment(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSpoilerPress = useCallback((comment: TraktContentComment) => {
|
||||||
|
Alert.alert(
|
||||||
|
'Spoiler Warning',
|
||||||
|
'This comment contains spoilers. Are you sure you want to reveal it?',
|
||||||
|
[
|
||||||
|
{
|
||||||
|
text: 'Cancel',
|
||||||
|
style: 'cancel',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Reveal Spoilers',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: () => {
|
||||||
|
setRevealedSpoilers(prev => new Set([...prev, comment.id.toString()]));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const renderComment = useCallback(({ item }: { item: TraktContentComment }) => (
|
||||||
|
<CompactCommentCard
|
||||||
|
comment={item}
|
||||||
|
theme={currentTheme}
|
||||||
|
onPress={() => handleCommentPress(item)}
|
||||||
|
isSpoilerRevealed={revealedSpoilers.has(item.id.toString())}
|
||||||
|
onSpoilerPress={() => handleSpoilerPress(item)}
|
||||||
|
/>
|
||||||
|
), [currentTheme, handleCommentPress, revealedSpoilers, handleSpoilerPress]);
|
||||||
|
|
||||||
|
const renderEmpty = useCallback(() => {
|
||||||
|
if (loading) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.emptyContainer}>
|
||||||
|
<MaterialIcons name="chat-bubble-outline" size={48} color={currentTheme.colors.mediumEmphasis} />
|
||||||
|
<Text style={[styles.emptyText, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||||
|
{error ? 'Comments unavailable' : 'No comments yet'}
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.emptySubtext, { color: currentTheme.colors.disabled }]}>
|
||||||
|
{error
|
||||||
|
? 'This content may not be in Trakt\'s database yet'
|
||||||
|
: 'Be the first to comment on Trakt.tv'
|
||||||
|
}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}, [loading, error, currentTheme]);
|
||||||
|
|
||||||
|
// Don't show section if not authenticated
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={[styles.title, { color: currentTheme.colors.highEmphasis }]}>
|
||||||
|
Trakt Comments
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<View style={[styles.errorContainer, { backgroundColor: currentTheme.colors.card }]}>
|
||||||
|
<MaterialIcons name="error-outline" size={20} color={currentTheme.colors.error} />
|
||||||
|
<Text style={[styles.errorText, { color: currentTheme.colors.error }]}>
|
||||||
|
{error}
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.retryButton, { borderColor: currentTheme.colors.error }]}
|
||||||
|
onPress={refresh}
|
||||||
|
>
|
||||||
|
<Text style={[styles.retryButtonText, { color: currentTheme.colors.error }]}>
|
||||||
|
Retry
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading && comments.length === 0 && (
|
||||||
|
<View style={styles.loadingContainer}>
|
||||||
|
<ActivityIndicator size="large" color={currentTheme.colors.primary} />
|
||||||
|
<Text style={[styles.loadingText, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||||
|
Loading comments...
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{comments.length === 0 ? (
|
||||||
|
renderEmpty()
|
||||||
|
) : (
|
||||||
|
<FlatList
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
data={comments}
|
||||||
|
keyExtractor={(item) => item?.id?.toString() || Math.random().toString()}
|
||||||
|
renderItem={renderComment}
|
||||||
|
contentContainerStyle={styles.horizontalList}
|
||||||
|
onEndReached={() => {
|
||||||
|
if (hasMore && !loading) {
|
||||||
|
loadMore();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onEndReachedThreshold={0.5}
|
||||||
|
ListFooterComponent={
|
||||||
|
hasMore ? (
|
||||||
|
<View style={styles.loadMoreContainer}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.loadMoreButton, { backgroundColor: currentTheme.colors.card }]}
|
||||||
|
onPress={loadMore}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<ActivityIndicator size="small" color={currentTheme.colors.primary} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Text style={[styles.loadMoreText, { color: currentTheme.colors.primary }]}>
|
||||||
|
Load More
|
||||||
|
</Text>
|
||||||
|
<MaterialIcons name="chevron-right" size={20} color={currentTheme.colors.primary} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Expanded Comment Modal */}
|
||||||
|
<ExpandedCommentModal
|
||||||
|
comment={selectedComment}
|
||||||
|
visible={modalVisible}
|
||||||
|
onClose={handleModalClose}
|
||||||
|
theme={currentTheme}
|
||||||
|
isSpoilerRevealed={selectedComment ? revealedSpoilers.has(selectedComment.id.toString()) : false}
|
||||||
|
onSpoilerPress={() => selectedComment && handleSpoilerPress(selectedComment)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
padding: 16,
|
||||||
|
marginBottom: 24,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: '600',
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
horizontalList: {
|
||||||
|
paddingRight: 16,
|
||||||
|
},
|
||||||
|
compactCard: {
|
||||||
|
width: 280,
|
||||||
|
height: 160,
|
||||||
|
padding: 12,
|
||||||
|
marginRight: 12,
|
||||||
|
borderRadius: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 4,
|
||||||
|
elevation: 3,
|
||||||
|
flexDirection: 'column',
|
||||||
|
},
|
||||||
|
compactHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
compactUsername: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
miniVipBadge: {
|
||||||
|
backgroundColor: '#FFD700',
|
||||||
|
paddingHorizontal: 4,
|
||||||
|
paddingVertical: 1,
|
||||||
|
borderRadius: 6,
|
||||||
|
marginLeft: 6,
|
||||||
|
},
|
||||||
|
miniVipText: {
|
||||||
|
fontSize: 9,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#000',
|
||||||
|
},
|
||||||
|
compactRating: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 8,
|
||||||
|
gap: 2,
|
||||||
|
},
|
||||||
|
compactRatingText: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '600',
|
||||||
|
marginLeft: 4,
|
||||||
|
},
|
||||||
|
commentContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
compactComment: {
|
||||||
|
fontSize: 14,
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
blurredContent: {
|
||||||
|
opacity: 0.3,
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.1)',
|
||||||
|
padding: 8,
|
||||||
|
borderRadius: 4,
|
||||||
|
},
|
||||||
|
compactMeta: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
},
|
||||||
|
compactBadges: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 4,
|
||||||
|
},
|
||||||
|
miniReviewBadgeContainer: {
|
||||||
|
paddingHorizontal: 6,
|
||||||
|
paddingVertical: 2,
|
||||||
|
borderRadius: 8,
|
||||||
|
},
|
||||||
|
miniSpoilerBadgeContainer: {
|
||||||
|
paddingHorizontal: 6,
|
||||||
|
paddingVertical: 2,
|
||||||
|
borderRadius: 8,
|
||||||
|
},
|
||||||
|
miniBadgeText: {
|
||||||
|
fontSize: 9,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#FFFFFF',
|
||||||
|
},
|
||||||
|
compactStats: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
compactStat: {
|
||||||
|
fontSize: 12,
|
||||||
|
},
|
||||||
|
compactTime: {
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
loadMoreContainer: {
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
},
|
||||||
|
commentItem: {
|
||||||
|
padding: 16,
|
||||||
|
marginBottom: 12,
|
||||||
|
borderRadius: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
commentHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
userInfo: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
username: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
vipBadge: {
|
||||||
|
backgroundColor: '#FFD700',
|
||||||
|
paddingHorizontal: 6,
|
||||||
|
paddingVertical: 2,
|
||||||
|
borderRadius: 10,
|
||||||
|
marginLeft: 8,
|
||||||
|
},
|
||||||
|
vipText: {
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#000',
|
||||||
|
},
|
||||||
|
date: {
|
||||||
|
fontSize: 12,
|
||||||
|
},
|
||||||
|
contentTitle: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '500',
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
ratingContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
ratingText: {
|
||||||
|
fontSize: 12,
|
||||||
|
marginLeft: 4,
|
||||||
|
},
|
||||||
|
commentText: {
|
||||||
|
fontSize: 15,
|
||||||
|
lineHeight: 22,
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
commentMeta: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
},
|
||||||
|
reviewBadge: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '600',
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 2,
|
||||||
|
borderRadius: 12,
|
||||||
|
backgroundColor: 'rgba(33, 150, 243, 0.1)',
|
||||||
|
},
|
||||||
|
spoilerBadge: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '600',
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 2,
|
||||||
|
borderRadius: 12,
|
||||||
|
backgroundColor: 'rgba(244, 67, 54, 0.1)',
|
||||||
|
marginLeft: 8,
|
||||||
|
},
|
||||||
|
metaRight: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
likesContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginRight: 12,
|
||||||
|
},
|
||||||
|
likesText: {
|
||||||
|
fontSize: 12,
|
||||||
|
marginLeft: 4,
|
||||||
|
},
|
||||||
|
repliesContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
repliesText: {
|
||||||
|
fontSize: 12,
|
||||||
|
marginLeft: 4,
|
||||||
|
},
|
||||||
|
loadMoreButton: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingVertical: 12,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
borderRadius: 8,
|
||||||
|
marginTop: 8,
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
loadMoreText: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '600',
|
||||||
|
marginRight: 8,
|
||||||
|
},
|
||||||
|
emptyContainer: {
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingVertical: 40,
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '500',
|
||||||
|
marginTop: 12,
|
||||||
|
},
|
||||||
|
emptySubtext: {
|
||||||
|
fontSize: 14,
|
||||||
|
marginTop: 4,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
emptyList: {
|
||||||
|
flexGrow: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
loadingContainer: {
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingVertical: 40,
|
||||||
|
},
|
||||||
|
loadingText: {
|
||||||
|
fontSize: 14,
|
||||||
|
marginTop: 12,
|
||||||
|
},
|
||||||
|
errorContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: 12,
|
||||||
|
borderRadius: 8,
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
errorText: {
|
||||||
|
fontSize: 14,
|
||||||
|
marginLeft: 8,
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
retryButton: {
|
||||||
|
borderWidth: 1,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 6,
|
||||||
|
borderRadius: 6,
|
||||||
|
marginLeft: 12,
|
||||||
|
},
|
||||||
|
retryButtonText: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
modalOverlay: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: 20,
|
||||||
|
},
|
||||||
|
modalContent: {
|
||||||
|
width: '90%',
|
||||||
|
maxWidth: 400,
|
||||||
|
maxHeight: '80%',
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 20,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 10 },
|
||||||
|
shadowOpacity: 0.3,
|
||||||
|
shadowRadius: 10,
|
||||||
|
},
|
||||||
|
closeButton: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 12,
|
||||||
|
right: 12,
|
||||||
|
padding: 8,
|
||||||
|
borderRadius: 20,
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.1)',
|
||||||
|
},
|
||||||
|
modalHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
marginBottom: 16,
|
||||||
|
paddingTop: 20,
|
||||||
|
},
|
||||||
|
modalUsername: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '600',
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
modalDate: {
|
||||||
|
fontSize: 12,
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
modalRating: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
modalRatingText: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '600',
|
||||||
|
marginLeft: 8,
|
||||||
|
},
|
||||||
|
modalComment: {
|
||||||
|
fontSize: 16,
|
||||||
|
lineHeight: 24,
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
spoilerContainer: {
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: 20,
|
||||||
|
},
|
||||||
|
spoilerWarning: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
textAlign: 'center',
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
revealButton: {
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingVertical: 12,
|
||||||
|
borderRadius: 8,
|
||||||
|
},
|
||||||
|
revealButtonText: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
modalMeta: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
},
|
||||||
|
modalStats: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
});
|
||||||
129
src/hooks/useTraktComments.ts
Normal file
129
src/hooks/useTraktComments.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { TraktService, TraktContentComment } from '../services/traktService';
|
||||||
|
import { logger } from '../utils/logger';
|
||||||
|
|
||||||
|
interface UseTraktCommentsProps {
|
||||||
|
imdbId: string;
|
||||||
|
tmdbId?: number;
|
||||||
|
type: 'movie' | 'show' | 'season' | 'episode';
|
||||||
|
season?: number;
|
||||||
|
episode?: number;
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useTraktComments = ({
|
||||||
|
imdbId,
|
||||||
|
tmdbId,
|
||||||
|
type,
|
||||||
|
season,
|
||||||
|
episode,
|
||||||
|
enabled = true
|
||||||
|
}: UseTraktCommentsProps) => {
|
||||||
|
const [comments, setComments] = useState<TraktContentComment[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [hasMore, setHasMore] = useState(false);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||||
|
|
||||||
|
const COMMENTS_PER_PAGE = 10;
|
||||||
|
|
||||||
|
// Check authentication status
|
||||||
|
useEffect(() => {
|
||||||
|
const checkAuth = async () => {
|
||||||
|
try {
|
||||||
|
const traktService = TraktService.getInstance();
|
||||||
|
const authenticated = await traktService.isAuthenticated();
|
||||||
|
setIsAuthenticated(authenticated);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[useTraktComments] Failed to check authentication:', error);
|
||||||
|
setIsAuthenticated(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (enabled) {
|
||||||
|
checkAuth();
|
||||||
|
}
|
||||||
|
}, [enabled]);
|
||||||
|
|
||||||
|
const loadComments = useCallback(async (pageNum: number = 1, append: boolean = false) => {
|
||||||
|
if (!enabled || !imdbId || !isAuthenticated) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const traktService = TraktService.getInstance();
|
||||||
|
let fetchedComments: TraktContentComment[] = [];
|
||||||
|
|
||||||
|
console.log(`[useTraktComments] Loading comments for ${type} - IMDb: ${imdbId}, TMDB: ${tmdbId}, page: ${pageNum}`);
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'movie':
|
||||||
|
fetchedComments = await traktService.getMovieComments(imdbId, tmdbId, pageNum, COMMENTS_PER_PAGE);
|
||||||
|
break;
|
||||||
|
case 'show':
|
||||||
|
fetchedComments = await traktService.getShowComments(imdbId, tmdbId, pageNum, COMMENTS_PER_PAGE);
|
||||||
|
break;
|
||||||
|
case 'season':
|
||||||
|
if (season !== undefined) {
|
||||||
|
fetchedComments = await traktService.getSeasonComments(imdbId, season, pageNum, COMMENTS_PER_PAGE);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'episode':
|
||||||
|
if (season !== undefined && episode !== undefined) {
|
||||||
|
fetchedComments = await traktService.getEpisodeComments(imdbId, season, episode, pageNum, COMMENTS_PER_PAGE);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if there are more comments (basic heuristic: if we got the full page, there might be more)
|
||||||
|
setHasMore(fetchedComments.length === COMMENTS_PER_PAGE);
|
||||||
|
|
||||||
|
setComments(prevComments => {
|
||||||
|
if (append) {
|
||||||
|
const newComments = [...prevComments, ...fetchedComments];
|
||||||
|
console.log(`[useTraktComments] Appended ${fetchedComments.length} comments, total: ${newComments.length}`);
|
||||||
|
return newComments;
|
||||||
|
} else {
|
||||||
|
console.log(`[useTraktComments] Loaded ${fetchedComments.length} comments`);
|
||||||
|
return fetchedComments;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setPage(pageNum);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[useTraktComments] Failed to load comments:', error);
|
||||||
|
setError(error instanceof Error ? error.message : 'Failed to load comments');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [enabled, imdbId, tmdbId, type, season, episode, isAuthenticated]);
|
||||||
|
|
||||||
|
const loadMore = useCallback(() => {
|
||||||
|
if (!loading && hasMore && isAuthenticated) {
|
||||||
|
loadComments(page + 1, true);
|
||||||
|
}
|
||||||
|
}, [loading, hasMore, page, loadComments, isAuthenticated]);
|
||||||
|
|
||||||
|
const refresh = useCallback(() => {
|
||||||
|
loadComments(1, false);
|
||||||
|
}, [loadComments]);
|
||||||
|
|
||||||
|
// Initial load
|
||||||
|
useEffect(() => {
|
||||||
|
loadComments(1, false);
|
||||||
|
}, [loadComments]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
comments,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
hasMore,
|
||||||
|
isAuthenticated,
|
||||||
|
loadMore,
|
||||||
|
refresh
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
@ -24,6 +24,7 @@ import { SeriesContent } from '../components/metadata/SeriesContent';
|
||||||
import { MovieContent } from '../components/metadata/MovieContent';
|
import { MovieContent } from '../components/metadata/MovieContent';
|
||||||
import { MoreLikeThisSection } from '../components/metadata/MoreLikeThisSection';
|
import { MoreLikeThisSection } from '../components/metadata/MoreLikeThisSection';
|
||||||
import { RatingsSection } from '../components/metadata/RatingsSection';
|
import { RatingsSection } from '../components/metadata/RatingsSection';
|
||||||
|
import { CommentsSection } from '../components/metadata/CommentsSection';
|
||||||
import { RouteParams, Episode } from '../types/metadata';
|
import { RouteParams, Episode } from '../types/metadata';
|
||||||
import Animated, {
|
import Animated, {
|
||||||
useAnimatedStyle,
|
useAnimatedStyle,
|
||||||
|
|
@ -63,6 +64,7 @@ const MemoizedSeriesContent = memo(SeriesContent);
|
||||||
const MemoizedMovieContent = memo(MovieContent);
|
const MemoizedMovieContent = memo(MovieContent);
|
||||||
const MemoizedMoreLikeThisSection = memo(MoreLikeThisSection);
|
const MemoizedMoreLikeThisSection = memo(MoreLikeThisSection);
|
||||||
const MemoizedRatingsSection = memo(RatingsSection);
|
const MemoizedRatingsSection = memo(RatingsSection);
|
||||||
|
const MemoizedCommentsSection = memo(CommentsSection);
|
||||||
const MemoizedCastDetailsModal = memo(CastDetailsModal);
|
const MemoizedCastDetailsModal = memo(CastDetailsModal);
|
||||||
|
|
||||||
const MetadataScreen: React.FC = () => {
|
const MetadataScreen: React.FC = () => {
|
||||||
|
|
@ -672,9 +674,18 @@ const MetadataScreen: React.FC = () => {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Comments Section - Lazy loaded */}
|
||||||
|
{shouldLoadSecondaryData && imdbId && (
|
||||||
|
<MemoizedCommentsSection
|
||||||
|
imdbId={imdbId}
|
||||||
|
tmdbId={tmdbId || undefined}
|
||||||
|
type={Object.keys(groupedEpisodes).length > 0 ? 'show' : 'movie'}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Recommendations Section with skeleton when loading - Lazy loaded */}
|
{/* Recommendations Section with skeleton when loading - Lazy loaded */}
|
||||||
{type === 'movie' && shouldLoadSecondaryData && (
|
{type === 'movie' && shouldLoadSecondaryData && (
|
||||||
<MemoizedMoreLikeThisSection
|
<MemoizedMoreLikeThisSection
|
||||||
recommendations={recommendations}
|
recommendations={recommendations}
|
||||||
loadingRecommendations={loadingRecommendations}
|
loadingRecommendations={loadingRecommendations}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -383,6 +383,177 @@ export interface TraktHistoryRemoveResponse {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Comment types
|
||||||
|
export interface TraktComment {
|
||||||
|
id: number;
|
||||||
|
comment: string;
|
||||||
|
spoiler: boolean;
|
||||||
|
review: boolean;
|
||||||
|
parent_id: number;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
replies: number;
|
||||||
|
likes: number;
|
||||||
|
user_stats?: {
|
||||||
|
rating?: number | null;
|
||||||
|
play_count?: number;
|
||||||
|
completed_count?: number;
|
||||||
|
};
|
||||||
|
user: {
|
||||||
|
username: string;
|
||||||
|
private: boolean;
|
||||||
|
name?: string;
|
||||||
|
vip: boolean;
|
||||||
|
vip_ep: boolean;
|
||||||
|
ids: {
|
||||||
|
slug: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TraktMovieComment {
|
||||||
|
type: 'movie';
|
||||||
|
movie: {
|
||||||
|
title: string;
|
||||||
|
year: number;
|
||||||
|
ids: {
|
||||||
|
trakt: number;
|
||||||
|
slug: string;
|
||||||
|
imdb: string;
|
||||||
|
tmdb: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
comment: TraktComment;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TraktShowComment {
|
||||||
|
type: 'show';
|
||||||
|
show: {
|
||||||
|
title: string;
|
||||||
|
year: number;
|
||||||
|
ids: {
|
||||||
|
trakt: number;
|
||||||
|
slug: string;
|
||||||
|
tvdb?: number;
|
||||||
|
imdb: string;
|
||||||
|
tmdb: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
comment: TraktComment;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TraktSeasonComment {
|
||||||
|
type: 'season';
|
||||||
|
season: {
|
||||||
|
number: number;
|
||||||
|
ids: {
|
||||||
|
trakt: number;
|
||||||
|
tvdb?: number;
|
||||||
|
tmdb?: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
show: {
|
||||||
|
title: string;
|
||||||
|
year: number;
|
||||||
|
ids: {
|
||||||
|
trakt: number;
|
||||||
|
slug: string;
|
||||||
|
tvdb?: number;
|
||||||
|
imdb: string;
|
||||||
|
tmdb: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
comment: TraktComment;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TraktEpisodeComment {
|
||||||
|
type: 'episode';
|
||||||
|
episode: {
|
||||||
|
season: number;
|
||||||
|
number: number;
|
||||||
|
title: string;
|
||||||
|
ids: {
|
||||||
|
trakt: number;
|
||||||
|
tvdb?: number;
|
||||||
|
imdb?: string;
|
||||||
|
tmdb?: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
show: {
|
||||||
|
title: string;
|
||||||
|
year: number;
|
||||||
|
ids: {
|
||||||
|
trakt: number;
|
||||||
|
slug: string;
|
||||||
|
tvdb?: number;
|
||||||
|
imdb: string;
|
||||||
|
tmdb: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
comment: TraktComment;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TraktListComment {
|
||||||
|
type: 'list';
|
||||||
|
list: {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
privacy: string;
|
||||||
|
share_link?: string;
|
||||||
|
display_numbers: boolean;
|
||||||
|
allow_comments: boolean;
|
||||||
|
updated_at: string;
|
||||||
|
item_count: number;
|
||||||
|
comment_count: number;
|
||||||
|
likes: number;
|
||||||
|
ids: {
|
||||||
|
trakt: number;
|
||||||
|
slug: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
comment: TraktComment;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simplified comment type based on actual API response
|
||||||
|
export interface TraktContentComment {
|
||||||
|
id: number;
|
||||||
|
comment: string;
|
||||||
|
spoiler: boolean;
|
||||||
|
review: boolean;
|
||||||
|
parent_id: number;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
replies: number;
|
||||||
|
likes: number;
|
||||||
|
language: string;
|
||||||
|
user_rating?: number;
|
||||||
|
user_stats?: {
|
||||||
|
rating?: number;
|
||||||
|
play_count?: number;
|
||||||
|
completed_count?: number;
|
||||||
|
};
|
||||||
|
user: {
|
||||||
|
username: string;
|
||||||
|
private: boolean;
|
||||||
|
deleted?: boolean;
|
||||||
|
name?: string;
|
||||||
|
vip: boolean;
|
||||||
|
vip_ep: boolean;
|
||||||
|
director?: boolean;
|
||||||
|
ids: {
|
||||||
|
slug: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep the old types for backward compatibility if needed
|
||||||
|
export type TraktContentCommentLegacy =
|
||||||
|
| TraktMovieComment
|
||||||
|
| TraktShowComment
|
||||||
|
| TraktSeasonComment
|
||||||
|
| TraktEpisodeComment
|
||||||
|
| TraktListComment;
|
||||||
|
|
||||||
export class TraktService {
|
export class TraktService {
|
||||||
private static instance: TraktService;
|
private static instance: TraktService;
|
||||||
private accessToken: string | null = null;
|
private accessToken: string | null = null;
|
||||||
|
|
@ -1062,24 +1233,56 @@ export class TraktService {
|
||||||
/**
|
/**
|
||||||
* Get trakt id from IMDb id
|
* Get trakt id from IMDb id
|
||||||
*/
|
*/
|
||||||
public async getTraktIdFromImdbId(imdbId: string, type: 'movies' | 'shows'): Promise<number | null> {
|
public async getTraktIdFromImdbId(imdbId: string, type: 'movie' | 'show'): Promise<number | null> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${TRAKT_API_URL}/search/${type}?id_type=imdb&id=${imdbId}`, {
|
// Clean IMDb ID - remove 'tt' prefix if present
|
||||||
headers: {
|
const cleanImdbId = imdbId.startsWith('tt') ? imdbId.substring(2) : imdbId;
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'trakt-api-version': '2',
|
logger.log(`[TraktService] Searching Trakt for ${type} with IMDb ID: ${cleanImdbId}`);
|
||||||
'trakt-api-key': TRAKT_CLIENT_ID
|
|
||||||
|
// Try multiple search approaches
|
||||||
|
const searchUrls = [
|
||||||
|
`${TRAKT_API_URL}/search/${type}?id_type=imdb&id=${cleanImdbId}`,
|
||||||
|
`${TRAKT_API_URL}/search/${type}?query=${encodeURIComponent(cleanImdbId)}&id_type=imdb`,
|
||||||
|
// Also try with the full tt-prefixed ID in case the API accepts it
|
||||||
|
`${TRAKT_API_URL}/search/${type}?id_type=imdb&id=tt${cleanImdbId}`
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const searchUrl of searchUrls) {
|
||||||
|
try {
|
||||||
|
logger.log(`[TraktService] Trying search URL: ${searchUrl}`);
|
||||||
|
|
||||||
|
const response = await fetch(searchUrl, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'trakt-api-version': '2',
|
||||||
|
'trakt-api-key': TRAKT_CLIENT_ID
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
logger.warn(`[TraktService] Search attempt failed (${response.status}): ${errorText}`);
|
||||||
|
continue; // Try next URL
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
logger.log(`[TraktService] Search response data:`, data);
|
||||||
|
|
||||||
|
if (data && data.length > 0) {
|
||||||
|
const traktId = data[0][type]?.ids?.trakt;
|
||||||
|
if (traktId) {
|
||||||
|
logger.log(`[TraktService] Found Trakt ID: ${traktId} for IMDb ID: ${cleanImdbId}`);
|
||||||
|
return traktId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (urlError) {
|
||||||
|
logger.warn(`[TraktService] URL attempt failed:`, urlError);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to get Trakt ID: ${response.status}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
logger.warn(`[TraktService] No results found for IMDb ID: ${cleanImdbId} after trying all search methods`);
|
||||||
if (data && data.length > 0) {
|
|
||||||
return data[0][type.slice(0, -1)].ids.trakt;
|
|
||||||
}
|
|
||||||
return null;
|
return null;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('[TraktService] Failed to get Trakt ID from IMDb ID:', error);
|
logger.error('[TraktService] Failed to get Trakt ID from IMDb ID:', error);
|
||||||
|
|
@ -1092,7 +1295,7 @@ export class TraktService {
|
||||||
*/
|
*/
|
||||||
public async addToWatchedMovies(imdbId: string, watchedAt: Date = new Date()): Promise<boolean> {
|
public async addToWatchedMovies(imdbId: string, watchedAt: Date = new Date()): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const traktId = await this.getTraktIdFromImdbId(imdbId, 'movies');
|
const traktId = await this.getTraktIdFromImdbId(imdbId, 'movie');
|
||||||
if (!traktId) {
|
if (!traktId) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
@ -1124,7 +1327,7 @@ export class TraktService {
|
||||||
watchedAt: Date = new Date()
|
watchedAt: Date = new Date()
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const traktId = await this.getTraktIdFromImdbId(imdbId, 'shows');
|
const traktId = await this.getTraktIdFromImdbId(imdbId, 'show');
|
||||||
if (!traktId) {
|
if (!traktId) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
@ -1165,7 +1368,7 @@ export class TraktService {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const traktId = await this.getTraktIdFromImdbId(imdbId, 'movies');
|
const traktId = await this.getTraktIdFromImdbId(imdbId, 'movie');
|
||||||
if (!traktId) {
|
if (!traktId) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
@ -1191,7 +1394,7 @@ export class TraktService {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const traktId = await this.getTraktIdFromImdbId(imdbId, 'shows');
|
const traktId = await this.getTraktIdFromImdbId(imdbId, 'show');
|
||||||
if (!traktId) {
|
if (!traktId) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
@ -2160,6 +2363,137 @@ export class TraktService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get trakt id from TMDB id (fallback method)
|
||||||
|
*/
|
||||||
|
public async getTraktIdFromTmdbId(tmdbId: number, type: 'movie' | 'show'): Promise<number | null> {
|
||||||
|
try {
|
||||||
|
logger.log(`[TraktService] Searching Trakt for ${type} with TMDB ID: ${tmdbId}`);
|
||||||
|
|
||||||
|
const response = await fetch(`${TRAKT_API_URL}/search/${type}?id_type=tmdb&id=${tmdbId}`, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'trakt-api-version': '2',
|
||||||
|
'trakt-api-key': TRAKT_CLIENT_ID
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
logger.warn(`[TraktService] TMDB search failed (${response.status}): ${errorText}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
logger.log(`[TraktService] TMDB search response:`, data);
|
||||||
|
if (data && data.length > 0) {
|
||||||
|
const traktId = data[0][type]?.ids?.trakt;
|
||||||
|
if (traktId) {
|
||||||
|
logger.log(`[TraktService] Found Trakt ID via TMDB: ${traktId} for TMDB ID: ${tmdbId}`);
|
||||||
|
return traktId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.warn(`[TraktService] No TMDB results found for TMDB ID: ${tmdbId}`);
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[TraktService] Failed to get Trakt ID from TMDB ID:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get comments for a movie
|
||||||
|
*/
|
||||||
|
public async getMovieComments(imdbId: string, tmdbId?: number, page: number = 1, limit: number = 10): Promise<TraktContentComment[]> {
|
||||||
|
try {
|
||||||
|
let traktId = await this.getTraktIdFromImdbId(imdbId, 'movie');
|
||||||
|
|
||||||
|
// Fallback to TMDB ID if IMDb search failed
|
||||||
|
if (!traktId && tmdbId) {
|
||||||
|
logger.log(`[TraktService] IMDb search failed, trying TMDB ID: ${tmdbId}`);
|
||||||
|
traktId = await this.getTraktIdFromTmdbId(tmdbId, 'movie');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!traktId) {
|
||||||
|
logger.warn(`[TraktService] Could not find Trakt ID for movie with IMDb: ${imdbId}, TMDB: ${tmdbId}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const endpoint = `/movies/${traktId}/comments?page=${page}&limit=${limit}`;
|
||||||
|
const result = await this.apiRequest<TraktContentComment[]>(endpoint, 'GET');
|
||||||
|
console.log(`[TraktService] Movie comments response:`, result);
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[TraktService] Failed to get movie comments:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get comments for a show
|
||||||
|
*/
|
||||||
|
public async getShowComments(imdbId: string, tmdbId?: number, page: number = 1, limit: number = 10): Promise<TraktContentComment[]> {
|
||||||
|
try {
|
||||||
|
let traktId = await this.getTraktIdFromImdbId(imdbId, 'show');
|
||||||
|
|
||||||
|
// Fallback to TMDB ID if IMDb search failed
|
||||||
|
if (!traktId && tmdbId) {
|
||||||
|
logger.log(`[TraktService] IMDb search failed, trying TMDB ID: ${tmdbId}`);
|
||||||
|
traktId = await this.getTraktIdFromTmdbId(tmdbId, 'show');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!traktId) {
|
||||||
|
logger.warn(`[TraktService] Could not find Trakt ID for show with IMDb: ${imdbId}, TMDB: ${tmdbId}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const endpoint = `/shows/${traktId}/comments?page=${page}&limit=${limit}`;
|
||||||
|
const result = await this.apiRequest<TraktContentComment[]>(endpoint, 'GET');
|
||||||
|
console.log(`[TraktService] Show comments response:`, result);
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[TraktService] Failed to get show comments:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get comments for a season
|
||||||
|
*/
|
||||||
|
public async getSeasonComments(imdbId: string, season: number, page: number = 1, limit: number = 10): Promise<TraktContentComment[]> {
|
||||||
|
try {
|
||||||
|
const traktId = await this.getTraktIdFromImdbId(imdbId, 'show');
|
||||||
|
if (!traktId) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const endpoint = `/shows/${traktId}/seasons/${season}/comments?page=${page}&limit=${limit}`;
|
||||||
|
return this.apiRequest<TraktContentComment[]>(endpoint, 'GET');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[TraktService] Failed to get season comments:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get comments for an episode
|
||||||
|
*/
|
||||||
|
public async getEpisodeComments(imdbId: string, season: number, episode: number, page: number = 1, limit: number = 10): Promise<TraktContentComment[]> {
|
||||||
|
try {
|
||||||
|
const traktId = await this.getTraktIdFromImdbId(imdbId, 'show');
|
||||||
|
if (!traktId) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const endpoint = `/shows/${traktId}/seasons/${season}/episodes/${episode}/comments?page=${page}&limit=${limit}`;
|
||||||
|
return this.apiRequest<TraktContentComment[]>(endpoint, 'GET');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[TraktService] Failed to get episode comments:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle app state changes to reduce memory pressure
|
* Handle app state changes to reduce memory pressure
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue