This commit is contained in:
2025-03-07 17:45:17 +08:00
commit 936af0c4ec
114 changed files with 37662 additions and 0 deletions

View File

@@ -0,0 +1,196 @@
import React, { useState, useEffect } from 'react';
import { MessageSquare, BarChart2, Send, RefreshCw, Settings } from 'lucide-react';
import CommentList from './components/CommentList';
import Analytics from './components/Analytics';
import ReplyGenerator from './components/ReplyGenerator';
import Settings from './components/Settings';
import { Comment } from '../types';
const Sidebar: React.FC = () => {
const [activeTab, setActiveTab] = useState<'comments' | 'analytics' | 'reply' | 'settings'>('comments');
const [comments, setComments] = useState<Comment[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [selectedComment, setSelectedComment] = useState<Comment | null>(null);
useEffect(() => {
// Setup message listener with error handling
const messageListener = (message: any) => {
try {
if (message.type === 'COMMENTS_CAPTURED') {
setComments(message.comments);
setIsLoading(false);
}
} catch (error) {
console.error('Error processing message:', error);
setIsLoading(false);
}
};
// Register listener if we're in a Chrome extension environment
if (typeof chrome !== 'undefined' && chrome.runtime && chrome.runtime.onMessage) {
chrome.runtime.onMessage.addListener(messageListener);
} else {
// We're in development mode - simulate comments loading
console.log('Development mode: simulating comment loading');
setTimeout(() => {
try {
// Import mock data dynamically to avoid issues
import('../mockData').then(module => {
setComments(module.default);
setIsLoading(false);
}).catch(error => {
console.error('Error loading mock data:', error);
setIsLoading(false);
});
} catch (error) {
console.error('Error in development mode comment simulation:', error);
setIsLoading(false);
}
}, 1000);
}
// Request comments from the current page if in extension environment
const requestComments = () => {
try {
if (typeof chrome !== 'undefined' && chrome.tabs && chrome.tabs.query) {
setIsLoading(true);
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
if (tabs[0]?.id) {
chrome.tabs.sendMessage(tabs[0].id, { type: 'GET_COMMENTS' });
}
});
}
} catch (error) {
console.error('Error requesting comments:', error);
setIsLoading(false);
}
};
if (typeof chrome !== 'undefined' && chrome.tabs) {
requestComments();
}
// Cleanup function
return () => {
if (typeof chrome !== 'undefined' && chrome.runtime && chrome.runtime.onMessage) {
chrome.runtime.onMessage.removeListener(messageListener);
}
};
}, []);
const refreshComments = () => {
try {
if (typeof chrome !== 'undefined' && chrome.tabs && chrome.tabs.query) {
setIsLoading(true);
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
if (tabs[0]?.id) {
chrome.tabs.sendMessage(tabs[0].id, { type: 'GET_COMMENTS' });
}
});
} else {
// Development mode - reload mock data
setIsLoading(true);
setTimeout(() => {
import('../mockData').then(module => {
setComments(module.default);
setIsLoading(false);
}).catch(error => {
console.error('Error reloading mock data:', error);
setIsLoading(false);
});
}, 1000);
}
} catch (error) {
console.error('Error refreshing comments:', error);
setIsLoading(false);
}
};
const handleSelectComment = (comment: Comment) => {
setSelectedComment(comment);
setActiveTab('reply');
};
return (
<div className="flex flex-col h-screen bg-gray-50">
{/* Header */}
<header className="bg-blue-600 text-white p-4 shadow-md">
<h1 className="text-xl font-bold flex items-center">
<MessageSquare className="mr-2" size={20} />
</h1>
<p className="text-sm opacity-80"></p>
</header>
{/* Main Content */}
<main className="flex-1 overflow-auto p-4">
{activeTab === 'comments' && (
<CommentList
comments={comments}
isLoading={isLoading}
onSelectComment={handleSelectComment}
/>
)}
{activeTab === 'analytics' && (
<Analytics comments={comments} />
)}
{activeTab === 'reply' && (
<ReplyGenerator
comment={selectedComment}
onBack={() => setActiveTab('comments')}
/>
)}
{activeTab === 'settings' && (
<Settings />
)}
</main>
{/* Refresh Button */}
<div className="absolute top-4 right-4">
<button
onClick={refreshComments}
className="p-2 bg-blue-700 rounded-full text-white hover:bg-blue-800 transition-colors"
title="重新捕獲留言"
>
<RefreshCw size={16} />
</button>
</div>
{/* Navigation */}
<nav className="bg-white border-t border-gray-200 p-2">
<div className="flex justify-around">
<button
onClick={() => setActiveTab('comments')}
className={`flex flex-col items-center p-2 rounded-md ${activeTab === 'comments' ? 'text-blue-600' : 'text-gray-600'}`}
>
<MessageSquare size={20} />
<span className="text-xs mt-1"></span>
</button>
<button
onClick={() => setActiveTab('analytics')}
className={`flex flex-col items-center p-2 rounded-md ${activeTab === 'analytics' ? 'text-blue-600' : 'text-gray-600'}`}
>
<BarChart2 size={20} />
<span className="text-xs mt-1"></span>
</button>
<button
onClick={() => setActiveTab('reply')}
className={`flex flex-col items-center p-2 rounded-md ${activeTab === 'reply' ? 'text-blue-600' : 'text-gray-600'}`}
>
<Send size={20} />
<span className="text-xs mt-1"></span>
</button>
<button
onClick={() => setActiveTab('settings')}
className={`flex flex-col items-center p-2 rounded-md ${activeTab === 'settings' ? 'text-blue-600' : 'text-gray-600'}`}
>
<Settings size={20} />
<span className="text-xs mt-1"></span>
</button>
</div>
</nav>
</div>
);
};
export default Sidebar;

View File

@@ -0,0 +1,371 @@
import React, { useMemo } from 'react';
import { BarChart2, TrendingUp, Clock, ThumbsUp, MessageSquare, Smile, Meh, Frown, Tag } from 'lucide-react';
import { Comment } from '../../types';
interface AnalyticsProps {
comments: Comment[];
}
const Analytics: React.FC<AnalyticsProps> = ({ comments }) => {
const stats = useMemo(() => {
// Total comments
const totalComments = comments.length;
// Comments by platform
const platformCounts: Record<string, number> = {};
comments.forEach(comment => {
platformCounts[comment.platform] = (platformCounts[comment.platform] || 0) + 1;
});
// Average likes
const totalLikes = comments.reduce((sum, comment) => sum + comment.likes, 0);
const avgLikes = totalComments > 0 ? (totalLikes / totalComments).toFixed(1) : '0';
// Comments with replies
const commentsWithReplies = comments.filter(comment =>
comment.replies && comment.replies.length > 0
).length;
// Total replies
const totalReplies = comments.reduce((sum, comment) =>
sum + (comment.replies?.length || 0), 0
);
// Sentiment counts
const sentimentCounts = {
positive: comments.filter(c => c.sentiment === 'positive').length,
neutral: comments.filter(c => c.sentiment === 'neutral').length,
negative: comments.filter(c => c.sentiment === 'negative').length
};
// Keywords analysis
const keywordCounts: Record<string, number> = {};
comments.forEach(comment => {
if (comment.keywords) {
comment.keywords.forEach(keyword => {
keywordCounts[keyword] = (keywordCounts[keyword] || 0) + 1;
});
}
});
const topKeywords = Object.entries(keywordCounts)
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
.map(([keyword, count]) => ({ keyword, count }));
// Categories analysis
const categoryCounts: Record<string, number> = {};
comments.forEach(comment => {
if (comment.category) {
categoryCounts[comment.category] = (categoryCounts[comment.category] || 0) + 1;
}
});
const categories = Object.entries(categoryCounts)
.sort((a, b) => b[1] - a[1])
.map(([category, count]) => ({ category, count }));
// Most active platforms (sorted)
const sortedPlatforms = Object.entries(platformCounts)
.sort((a, b) => b[1] - a[1])
.map(([platform, count]) => ({ platform, count }));
return {
totalComments,
platformCounts,
avgLikes,
commentsWithReplies,
totalReplies,
sortedPlatforms,
sentimentCounts,
topKeywords,
categories
};
}, [comments]);
if (comments.length === 0) {
return (
<div className="flex flex-col items-center justify-center h-64 text-center">
<BarChart2 size={48} className="text-gray-400 mb-4" />
<h3 className="text-lg font-medium text-gray-700"></h3>
<p className="text-gray-500 mt-2">
</p>
</div>
);
}
return (
<div>
<h2 className="text-lg font-semibold text-gray-800 mb-4"></h2>
{/* Summary Cards */}
<div className="grid grid-cols-2 gap-3 mb-6">
<div className="bg-white rounded-lg shadow p-3">
<div className="flex items-center text-blue-600 mb-1">
<MessageSquare size={16} className="mr-1" />
<span className="text-xs font-medium"></span>
</div>
<p className="text-2xl font-bold text-gray-800">{stats.totalComments}</p>
</div>
<div className="bg-white rounded-lg shadow p-3">
<div className="flex items-center text-green-600 mb-1">
<ThumbsUp size={16} className="mr-1" />
<span className="text-xs font-medium"></span>
</div>
<p className="text-2xl font-bold text-gray-800">{stats.avgLikes}</p>
</div>
<div className="bg-white rounded-lg shadow p-3">
<div className="flex items-center text-purple-600 mb-1">
<TrendingUp size={16} className="mr-1" />
<span className="text-xs font-medium"></span>
</div>
<p className="text-2xl font-bold text-gray-800">
{stats.totalComments > 0
? `${Math.round((stats.commentsWithReplies / stats.totalComments) * 100)}%`
: '0%'}
</p>
</div>
<div className="bg-white rounded-lg shadow p-3">
<div className="flex items-center text-orange-600 mb-1">
<MessageSquare size={16} className="mr-1" />
<span className="text-xs font-medium"></span>
</div>
<p className="text-2xl font-bold text-gray-800">{stats.totalReplies}</p>
</div>
</div>
{/* Sentiment Analysis */}
<div className="bg-white rounded-lg shadow p-4 mb-6">
<h3 className="text-sm font-medium text-gray-700 mb-3"></h3>
{/* Sentiment Bar */}
<div className="flex mb-2">
<div
className="bg-green-500 h-3 rounded-l-full"
style={{ width: `${(stats.sentimentCounts.positive / stats.totalComments) * 100}%` }}
></div>
<div
className="bg-gray-400 h-3"
style={{ width: `${(stats.sentimentCounts.neutral / stats.totalComments) * 100}%` }}
></div>
<div
className="bg-red-500 h-3 rounded-r-full"
style={{ width: `${(stats.sentimentCounts.negative / stats.totalComments) * 100}%` }}
></div>
</div>
<div className="grid grid-cols-3 gap-2 mt-3">
<div className="bg-green-50 p-2 rounded-md">
<div className="flex items-center justify-between mb-1">
<div className="flex items-center text-green-700">
<Smile size={14} className="mr-1" />
<span className="text-xs font-medium"></span>
</div>
<span className="text-xs text-gray-600">{Math.round((stats.sentimentCounts.positive / stats.totalComments) * 100)}%</span>
</div>
<p className="text-lg font-bold text-gray-800">{stats.sentimentCounts.positive}</p>
</div>
<div className="bg-gray-50 p-2 rounded-md">
<div className="flex items-center justify-between mb-1">
<div className="flex items-center text-gray-700">
<Meh size={14} className="mr-1" />
<span className="text-xs font-medium"></span>
</div>
<span className="text-xs text-gray-600">{Math.round((stats.sentimentCounts.neutral / stats.totalComments) * 100)}%</span>
</div>
<p className="text-lg font-bold text-gray-800">{stats.sentimentCounts.neutral}</p>
</div>
<div className="bg-red-50 p-2 rounded-md">
<div className="flex items-center justify-between mb-1">
<div className="flex items-center text-red-700">
<Frown size={14} className="mr-1" />
<span className="text-xs font-medium"></span>
</div>
<span className="text-xs text-gray-600">{Math.round((stats.sentimentCounts.negative / stats.totalComments) * 100)}%</span>
</div>
<p className="text-lg font-bold text-gray-800">{stats.sentimentCounts.negative}</p>
</div>
</div>
</div>
{/* Keywords Analysis */}
<div className="bg-white rounded-lg shadow p-4 mb-6">
<h3 className="text-sm font-medium text-gray-700 mb-3"></h3>
<div className="space-y-2">
{stats.topKeywords.slice(0, 5).map(({ keyword, count }) => (
<div key={keyword} className="flex items-center">
<div className="w-full bg-gray-200 rounded-full h-2 mr-2">
<div
className="bg-blue-600 h-2 rounded-full"
style={{ width: `${(count / stats.topKeywords[0].count) * 100}%` }}
></div>
</div>
<div className="flex justify-between items-center min-w-[100px]">
<span className="text-xs text-gray-700">{keyword}</span>
<span className="text-xs text-gray-500">{count}</span>
</div>
</div>
))}
</div>
<div className="mt-3 flex flex-wrap gap-1">
{stats.topKeywords.slice(5, 15).map(({ keyword, count }) => (
<span
key={keyword}
className="bg-blue-50 text-blue-700 text-xs px-2 py-0.5 rounded-full"
title={`出現 ${count}`}
>
{keyword}
</span>
))}
</div>
</div>
{/* Categories Analysis */}
<div className="bg-white rounded-lg shadow p-4 mb-6">
<h3 className="text-sm font-medium text-gray-700 mb-3"></h3>
<div className="space-y-2">
{stats.categories.slice(0, 5).map(({ category, count }) => (
<div key={category} className="flex items-center">
<div className="w-full bg-gray-200 rounded-full h-2 mr-2">
<div
className="bg-purple-600 h-2 rounded-full"
style={{ width: `${(count / stats.totalComments) * 100}%` }}
></div>
</div>
<div className="flex justify-between items-center min-w-[120px]">
<span className="text-xs text-gray-700">{category}</span>
<span className="text-xs text-gray-500">{count} ({Math.round((count / stats.totalComments) * 100)}%)</span>
</div>
</div>
))}
</div>
</div>
{/* Platform Distribution */}
<div className="bg-white rounded-lg shadow p-4 mb-6">
<h3 className="text-sm font-medium text-gray-700 mb-3"></h3>
<div className="space-y-3">
{Object.entries(stats.platformCounts).map(([platform, count]) => (
<div key={platform}>
<div className="flex justify-between text-xs text-gray-600 mb-1">
<span className="capitalize">{platform}</span>
<span>{count} ({Math.round((count / stats.totalComments) * 100)}%)</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className={`h-2 rounded-full ${getPlatformColor(platform)}`}
style={{ width: `${(count / stats.totalComments) * 100}%` }}
></div>
</div>
</div>
))}
</div>
</div>
{/* Top Comments */}
<div className="bg-white rounded-lg shadow p-4">
<h3 className="text-sm font-medium text-gray-700 mb-3"></h3>
{comments
.sort((a, b) => b.likes - a.likes)
.slice(0, 3)
.map(comment => (
<div key={comment.id} className="border-b border-gray-100 last:border-0 py-2">
<div className="flex justify-between items-start mb-1">
<span className="text-xs font-medium text-gray-800">{comment.author}</span>
<div className="flex items-center space-x-1">
<div className="flex items-center text-xs text-gray-500">
<ThumbsUp size={10} className="mr-1" />
{comment.likes}
</div>
{comment.sentiment && (
<div className={`flex items-center text-xs px-1 py-0.5 rounded-full ${getSentimentBadgeColor(comment.sentiment)}`}>
{comment.sentiment === 'positive' && <Smile size={8} className="mr-0.5" />}
{comment.sentiment === 'neutral' && <Meh size={8} className="mr-0.5" />}
{comment.sentiment === 'negative' && <Frown size={8} className="mr-0.5" />}
</div>
)}
</div>
</div>
<p className="text-xs text-gray-600 line-clamp-2">{comment.content}</p>
<div className="flex justify-between items-center mt-1">
<span className="text-xs text-gray-500">{comment.timestamp}</span>
<div className="flex items-center space-x-1">
{comment.category && (
<span className="text-xs px-1 py-0.5 rounded-full bg-purple-50 text-purple-700">
{comment.category}
</span>
)}
<span className={`text-xs px-1 py-0.5 rounded-full ${getPlatformBadgeColor(comment.platform)}`}>
{comment.platform}
</span>
</div>
</div>
</div>
))}
</div>
</div>
);
};
// Helper function to get platform-specific colors
function getPlatformColor(platform: string): string {
switch (platform) {
case 'facebook':
return 'bg-blue-600';
case 'instagram':
return 'bg-pink-600';
case 'twitter':
return 'bg-blue-400';
case 'youtube':
return 'bg-red-600';
case 'linkedin':
return 'bg-blue-800';
default:
return 'bg-gray-600';
}
}
// Helper function to get platform-specific badge colors
function getPlatformBadgeColor(platform: string): string {
switch (platform) {
case 'facebook':
return 'bg-blue-100 text-blue-800';
case 'instagram':
return 'bg-pink-100 text-pink-800';
case 'twitter':
return 'bg-blue-100 text-blue-600';
case 'youtube':
return 'bg-red-100 text-red-800';
case 'linkedin':
return 'bg-blue-100 text-blue-900';
default:
return 'bg-gray-100 text-gray-800';
}
}
// Helper function to get sentiment badge colors
function getSentimentBadgeColor(sentiment: string): string {
switch (sentiment) {
case 'positive':
return 'bg-green-100 text-green-800';
case 'neutral':
return 'bg-gray-100 text-gray-800';
case 'negative':
return 'bg-red-100 text-red-800';
default:
return 'bg-gray-100 text-gray-800';
}
}
export default Analytics;

View File

@@ -0,0 +1,453 @@
import React, { useState, useMemo } from 'react';
import { MessageSquare, ThumbsUp, Clock, Filter, SortDesc, Search, X, ChevronDown, Smile, Frown, Meh, Tag } from 'lucide-react';
import { Comment } from '../../types';
interface CommentListProps {
comments: Comment[];
isLoading: boolean;
onSelectComment: (comment: Comment) => void;
}
const CommentList: React.FC<CommentListProps> = ({ comments, isLoading, onSelectComment }) => {
const [searchTerm, setSearchTerm] = useState<string>('');
const [platformFilter, setPlatformFilter] = useState<string>('all');
const [sentimentFilter, setSentimentFilter] = useState<'all' | 'positive' | 'neutral' | 'negative'>('all');
const [sortBy, setSortBy] = useState<'newest' | 'oldest' | 'likes' | 'replies'>('newest');
const [showFilters, setShowFilters] = useState<boolean>(false);
const [showAnalytics, setShowAnalytics] = useState<boolean>(true);
// Get unique platforms from comments
const platforms = useMemo(() => {
const platformSet = new Set<string>();
comments.forEach(comment => platformSet.add(comment.platform));
return Array.from(platformSet);
}, [comments]);
// Calculate sentiment statistics
const sentimentStats = useMemo(() => {
const stats = {
positive: 0,
neutral: 0,
negative: 0,
total: comments.length
};
comments.forEach(comment => {
if (comment.sentiment === 'positive') stats.positive++;
else if (comment.sentiment === 'neutral') stats.neutral++;
else if (comment.sentiment === 'negative') stats.negative++;
});
return stats;
}, [comments]);
// Extract top keywords
const topKeywords = useMemo(() => {
const keywordCounts: Record<string, number> = {};
comments.forEach(comment => {
if (comment.keywords) {
comment.keywords.forEach(keyword => {
keywordCounts[keyword] = (keywordCounts[keyword] || 0) + 1;
});
}
});
return Object.entries(keywordCounts)
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
.map(([keyword, count]) => ({ keyword, count }));
}, [comments]);
// Extract categories
const categories = useMemo(() => {
const categoryCounts: Record<string, number> = {};
comments.forEach(comment => {
if (comment.category) {
categoryCounts[comment.category] = (categoryCounts[comment.category] || 0) + 1;
}
});
return Object.entries(categoryCounts)
.sort((a, b) => b[1] - a[1])
.map(([category, count]) => ({ category, count }));
}, [comments]);
// Filter and sort comments
const filteredAndSortedComments = useMemo(() => {
// First filter by search term, platform, and sentiment
let filtered = comments.filter(comment => {
const matchesSearch = searchTerm === '' ||
comment.content.toLowerCase().includes(searchTerm.toLowerCase()) ||
comment.author.toLowerCase().includes(searchTerm.toLowerCase());
const matchesPlatform = platformFilter === 'all' || comment.platform === platformFilter;
const matchesSentiment = sentimentFilter === 'all' || comment.sentiment === sentimentFilter;
return matchesSearch && matchesPlatform && matchesSentiment;
});
// Then sort
return filtered.sort((a, b) => {
switch (sortBy) {
case 'newest':
// Simple string comparison for timestamps (in a real app, parse dates properly)
return a.timestamp < b.timestamp ? 1 : -1;
case 'oldest':
return a.timestamp > b.timestamp ? 1 : -1;
case 'likes':
return b.likes - a.likes;
case 'replies':
return (b.replies?.length || 0) - (a.replies?.length || 0);
default:
return 0;
}
});
}, [comments, searchTerm, platformFilter, sentimentFilter, sortBy]);
if (isLoading) {
return (
<div className="flex flex-col items-center justify-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div>
<p className="mt-4 text-gray-600">...</p>
</div>
);
}
if (comments.length === 0) {
return (
<div className="flex flex-col items-center justify-center h-64 text-center">
<MessageSquare size={48} className="text-gray-400 mb-4" />
<h3 className="text-lg font-medium text-gray-700"></h3>
<p className="text-gray-500 mt-2">
</p>
</div>
);
}
return (
<div>
<div className="mb-4 flex justify-between items-center">
<h2 className="text-lg font-semibold text-gray-800"></h2>
<div className="flex items-center space-x-2">
<button
onClick={() => setShowAnalytics(!showAnalytics)}
className={`p-1.5 rounded-md ${showAnalytics ? 'bg-purple-100 text-purple-600' : 'bg-gray-100 text-gray-600'} hover:bg-purple-100 hover:text-purple-600 transition-colors`}
title="顯示/隱藏分析"
>
<Tag size={16} />
</button>
<button
onClick={() => setShowFilters(!showFilters)}
className={`p-1.5 rounded-md ${showFilters ? 'bg-blue-100 text-blue-600' : 'bg-gray-100 text-gray-600'} hover:bg-blue-100 hover:text-blue-600 transition-colors`}
title="篩選與排序"
>
<Filter size={16} />
</button>
<span className="bg-blue-100 text-blue-800 text-xs font-medium px-2.5 py-0.5 rounded">
{filteredAndSortedComments.length} / {comments.length}
</span>
</div>
</div>
{/* Quick Analytics */}
{showAnalytics && (
<div className="bg-white rounded-lg shadow p-3 mb-4">
<div className="flex justify-between items-center mb-2">
<h3 className="text-sm font-medium text-gray-700"></h3>
<div className="flex items-center space-x-1">
<span className="text-xs text-gray-500">: {sentimentStats.total}</span>
</div>
</div>
{/* Sentiment Distribution */}
<div className="flex mb-2">
<div
className="bg-green-500 h-2 rounded-l-full"
style={{ width: `${(sentimentStats.positive / sentimentStats.total) * 100}%` }}
title={`正面: ${sentimentStats.positive} (${Math.round((sentimentStats.positive / sentimentStats.total) * 100)}%)`}
></div>
<div
className="bg-gray-400 h-2"
style={{ width: `${(sentimentStats.neutral / sentimentStats.total) * 100}%` }}
title={`中性: ${sentimentStats.neutral} (${Math.round((sentimentStats.neutral / sentimentStats.total) * 100)}%)`}
></div>
<div
className="bg-red-500 h-2 rounded-r-full"
style={{ width: `${(sentimentStats.negative / sentimentStats.total) * 100}%` }}
title={`負面: ${sentimentStats.negative} (${Math.round((sentimentStats.negative / sentimentStats.total) * 100)}%)`}
></div>
</div>
<div className="flex justify-between text-xs text-gray-600 mb-3">
<div className="flex items-center">
<div className="w-2 h-2 rounded-full bg-green-500 mr-1"></div>
<span>: {sentimentStats.positive}</span>
</div>
<div className="flex items-center">
<div className="w-2 h-2 rounded-full bg-gray-400 mr-1"></div>
<span>: {sentimentStats.neutral}</span>
</div>
<div className="flex items-center">
<div className="w-2 h-2 rounded-full bg-red-500 mr-1"></div>
<span>: {sentimentStats.negative}</span>
</div>
</div>
{/* Top Keywords */}
<div className="mb-3">
<h3 className="text-xs font-medium text-gray-700 mb-1"></h3>
<div className="flex flex-wrap gap-1">
{topKeywords.slice(0, 8).map(({ keyword, count }) => (
<span
key={keyword}
className="bg-blue-50 text-blue-700 text-xs px-2 py-0.5 rounded-full"
title={`出現 ${count}`}
>
{keyword}
</span>
))}
</div>
</div>
{/* Top Categories */}
<div>
<h3 className="text-xs font-medium text-gray-700 mb-1"></h3>
<div className="flex flex-wrap gap-1">
{categories.slice(0, 5).map(({ category, count }) => (
<span
key={category}
className="bg-purple-50 text-purple-700 text-xs px-2 py-0.5 rounded-full"
title={`${count} 則留言`}
>
{category}
</span>
))}
</div>
</div>
</div>
)}
{/* Search and Filter Panel */}
{showFilters && (
<div className="bg-white rounded-lg shadow p-3 mb-4 space-y-3">
{/* Search */}
<div className="relative">
<div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
<Search size={14} className="text-gray-500" />
</div>
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="搜尋留言或作者..."
className="w-full pl-9 pr-9 py-2 text-sm border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500"
/>
{searchTerm && (
<button
onClick={() => setSearchTerm('')}
className="absolute inset-y-0 right-0 flex items-center pr-3"
>
<X size={14} className="text-gray-500 hover:text-gray-700" />
</button>
)}
</div>
<div className="grid grid-cols-2 gap-2">
{/* Platform Filter */}
<div className="relative">
<label className="block text-xs text-gray-700 mb-1"></label>
<div className="relative">
<select
value={platformFilter}
onChange={(e) => setPlatformFilter(e.target.value)}
className="w-full p-2 text-sm border border-gray-300 rounded-md appearance-none focus:ring-blue-500 focus:border-blue-500"
>
<option value="all"></option>
{platforms.map(platform => (
<option key={platform} value={platform}>{platform}</option>
))}
</select>
<div className="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
<ChevronDown size={14} className="text-gray-500" />
</div>
</div>
</div>
{/* Sentiment Filter */}
<div className="relative">
<label className="block text-xs text-gray-700 mb-1"></label>
<div className="relative">
<select
value={sentimentFilter}
onChange={(e) => setSentimentFilter(e.target.value as 'all' | 'positive' | 'neutral' | 'negative')}
className="w-full p-2 text-sm border border-gray-300 rounded-md appearance-none focus:ring-blue-500 focus:border-blue-500"
>
<option value="all"></option>
<option value="positive"></option>
<option value="neutral"></option>
<option value="negative"></option>
</select>
<div className="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
<ChevronDown size={14} className="text-gray-500" />
</div>
</div>
</div>
{/* Sort By */}
<div className="relative col-span-2">
<label className="block text-xs text-gray-700 mb-1"></label>
<div className="relative">
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as 'newest' | 'oldest' | 'likes' | 'replies')}
className="w-full p-2 text-sm border border-gray-300 rounded-md appearance-none focus:ring-blue-500 focus:border-blue-500"
>
<option value="newest"></option>
<option value="oldest"></option>
<option value="likes"></option>
<option value="replies"></option>
</select>
<div className="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
<SortDesc size={14} className="text-gray-500" />
</div>
</div>
</div>
</div>
{/* Filter Stats */}
{(searchTerm || platformFilter !== 'all' || sentimentFilter !== 'all') && (
<div className="flex justify-between items-center pt-1 text-xs text-gray-500">
<span>
{filteredAndSortedComments.length === comments.length
? '顯示全部留言'
: `顯示 ${filteredAndSortedComments.length} 個符合條件的留言`}
</span>
<button
onClick={() => {
setSearchTerm('');
setPlatformFilter('all');
setSentimentFilter('all');
}}
className="text-blue-600 hover:text-blue-800"
>
</button>
</div>
)}
</div>
)}
{/* Comments List */}
{filteredAndSortedComments.length === 0 ? (
<div className="bg-gray-50 rounded-lg p-4 text-center">
<p className="text-gray-600"></p>
<button
onClick={() => {
setSearchTerm('');
setPlatformFilter('all');
setSentimentFilter('all');
}}
className="mt-2 text-sm text-blue-600 hover:text-blue-800"
>
</button>
</div>
) : (
<div className="space-y-3">
{filteredAndSortedComments.map((comment) => (
<div
key={comment.id}
className="bg-white rounded-lg shadow p-4 hover:shadow-md transition-shadow cursor-pointer"
onClick={() => onSelectComment(comment)}
>
<div className="flex justify-between items-start mb-2">
<div className="font-medium text-gray-900">{comment.author}</div>
<div className="flex items-center space-x-2">
<div className="flex items-center text-gray-500 text-xs">
<Clock size={12} className="mr-1" />
{comment.timestamp}
</div>
{comment.sentiment && (
<div className={`flex items-center text-xs px-1.5 py-0.5 rounded-full ${getSentimentBadgeColor(comment.sentiment)}`}>
{comment.sentiment === 'positive' && <Smile size={10} className="mr-1" />}
{comment.sentiment === 'neutral' && <Meh size={10} className="mr-1" />}
{comment.sentiment === 'negative' && <Frown size={10} className="mr-1" />}
{getSentimentLabel(comment.sentiment)}
</div>
)}
</div>
</div>
<p className="text-gray-700 mb-3 line-clamp-2">{comment.content}</p>
<div className="flex flex-wrap gap-2 mb-3">
{comment.keywords?.map(keyword => (
<span key={keyword} className="bg-blue-50 text-blue-700 text-xs px-1.5 py-0.5 rounded">
{keyword}
</span>
))}
</div>
<div className="flex items-center justify-between text-xs text-gray-500">
<div className="flex items-center">
<ThumbsUp size={12} className="mr-1" />
{comment.likes}
</div>
<div className="flex items-center">
<MessageSquare size={12} className="mr-1" />
{comment.replies?.length || 0}
</div>
<div className="flex items-center space-x-2">
{comment.category && (
<span className="px-1.5 py-0.5 rounded bg-purple-50 text-purple-700">
{comment.category}
</span>
)}
<span className="px-1.5 py-0.5 rounded bg-gray-100 text-gray-600">
{comment.platform}
</span>
</div>
</div>
</div>
))}
</div>
)}
</div>
);
};
// Helper function to get sentiment badge colors
function getSentimentBadgeColor(sentiment: string): string {
switch (sentiment) {
case 'positive':
return 'bg-green-100 text-green-800';
case 'neutral':
return 'bg-gray-100 text-gray-800';
case 'negative':
return 'bg-red-100 text-red-800';
default:
return 'bg-gray-100 text-gray-800';
}
}
// Helper function to get sentiment labels
function getSentimentLabel(sentiment: string): string {
switch (sentiment) {
case 'positive':
return '正面';
case 'neutral':
return '中性';
case 'negative':
return '負面';
default:
return '未知';
}
}
export default CommentList;

View File

@@ -0,0 +1,363 @@
import React, { useState, useEffect } from 'react';
import { ArrowLeft, Send, Copy, Check, Zap, User, Smile, Meh, Frown, Tag, ThumbsUp } from 'lucide-react';
import { Comment, ReplyTone, ReplyPersona } from '../../types';
interface ReplyGeneratorProps {
comment: Comment | null;
onBack: () => void;
}
const ReplyGenerator: React.FC<ReplyGeneratorProps> = ({ comment, onBack }) => {
const [selectedTone, setSelectedTone] = useState<string>('friendly');
const [selectedPersona, setSelectedPersona] = useState<string>('brand');
const [generatedReplies, setGeneratedReplies] = useState<string[]>([]);
const [isGenerating, setIsGenerating] = useState<boolean>(false);
const [copied, setCopied] = useState<boolean>(false);
const tones: ReplyTone[] = [
{ id: 'friendly', name: '友善', description: '溫暖親切的語氣' },
{ id: 'professional', name: '專業', description: '正式且專業的語氣' },
{ id: 'casual', name: '輕鬆', description: '隨意輕鬆的對話風格' },
{ id: 'enthusiastic', name: '熱情', description: '充滿活力與熱情' },
{ id: 'empathetic', name: '同理心', description: '表達理解與關懷' }
];
const personas: ReplyPersona[] = [
{ id: 'brand', name: '品牌代表', description: '以品牌官方身份回覆' },
{ id: 'support', name: '客服人員', description: '以客服專員身份回覆' },
{ id: 'expert', name: '領域專家', description: '以專業人士身份回覆' },
{ id: 'friend', name: '朋友', description: '以朋友身份回覆' }
];
useEffect(() => {
// In development mode, we don't have access to chrome.storage
// So we'll use a mock implementation
const loadDefaultSettings = () => {
try {
// Check if we're in a Chrome extension environment
if (typeof chrome !== 'undefined' && chrome.storage && chrome.storage.sync) {
chrome.storage.sync.get(['defaultTone', 'defaultPersona'], (result) => {
if (result.defaultTone) setSelectedTone(result.defaultTone);
if (result.defaultPersona) setSelectedPersona(result.defaultPersona);
});
} else {
// Mock storage for development environment
console.log('Using mock storage for development');
// Use default values or load from localStorage if needed
const savedTone = localStorage.getItem('defaultTone');
const savedPersona = localStorage.getItem('defaultPersona');
if (savedTone) setSelectedTone(savedTone);
if (savedPersona) setSelectedPersona(savedPersona);
}
} catch (error) {
console.error('Error loading settings:', error);
// Continue with default values
}
};
loadDefaultSettings();
}, []);
const generateReplies = () => {
if (!comment) return;
setIsGenerating(true);
// Simulate API call or processing delay
setTimeout(() => {
// Generate replies based on comment sentiment, category, and selected tone/persona
let mockReplies: string[] = [];
// Base reply templates for different sentiments
if (comment.sentiment === 'positive') {
mockReplies = [
`感謝您的正面評價!我們很高興您喜歡我們的產品/服務。您的支持是我們前進的動力。`,
`非常感謝您的讚美!我們團隊一直致力於提供最好的體驗,很開心能得到您的認可。`,
`謝謝您的美好評價!您的滿意是我們最大的成就,我們會繼續努力維持這樣的水準。`
];
} else if (comment.sentiment === 'negative') {
mockReplies = [
`非常抱歉給您帶來不便。我們非常重視您的反饋,並會立即處理您提到的問題。請問可以提供更多細節,以便我們更好地解決?`,
`感謝您的坦誠反饋。我們對您的體驗感到遺憾,並承諾會改進。我們的團隊已經注意到這個問題,正在積極解決中。`,
`您的意見對我們非常寶貴。對於您遇到的困難,我們深表歉意。請放心,我們會認真對待每一條反饋,並努力改進我們的產品和服務。`
];
} else {
mockReplies = [
`感謝您的留言!我們很樂意回答您的問題。請問還有什麼我們可以幫助您的嗎?`,
`謝謝您的關注!您提出的問題很有價值,我們會盡快為您提供所需的信息。`,
`感謝您的互動!我們非常重視您的每一個問題,並致力於提供最準確的回答。`
];
}
// Customize based on category if available
if (comment.category) {
// Add category-specific content to the replies
mockReplies = mockReplies.map(reply => {
switch (comment.category) {
case '產品讚美':
return reply + ` 我們不斷努力改進產品,您的鼓勵給了我們很大的動力。`;
case '產品詢問':
return reply + ` 關於產品的具體信息,我們建議您查看官網的產品說明頁面,或直接聯繫我們的客服團隊。`;
case '產品問題':
return reply + ` 我們的售後團隊將會與您聯繫,協助解決產品問題。您也可以撥打客服熱線獲取即時幫助。`;
case '物流問題':
return reply + ` 我們會立即與物流部門核實您的訂單狀態,並盡快給您回覆。`;
case '價格問題':
return reply + ` 關於價格的疑問,我們的銷售團隊將為您提供最詳細的解答和最優惠的方案。`;
default:
return reply;
}
});
}
// Adjust tone based on selection
mockReplies = mockReplies.map(reply => {
switch (selectedTone) {
case 'professional':
return reply.replace(/感謝|謝謝/g, '非常感謝').replace(//g, '。');
case 'casual':
return reply.replace(/我們/g, '我們團隊').replace(/。/g, '~');
case 'enthusiastic':
return reply.replace(//g, '').replace(/謝謝/g, '非常感謝');
case 'empathetic':
return reply.replace(/感謝/g, '真誠感謝').replace(/我們理解/g, '我們完全理解');
default:
return reply;
}
});
// Adjust persona based on selection
mockReplies = mockReplies.map(reply => {
switch (selectedPersona) {
case 'support':
return `作為客服代表,${reply}`;
case 'expert':
return `以專業角度來看,${reply}`;
case 'friend':
return reply.replace(/我們/g, '我們').replace(/非常感謝/g, '超級感謝');
default:
return reply;
}
});
setGeneratedReplies(mockReplies);
setIsGenerating(false);
}, 1500);
};
const copyToClipboard = (text: string) => {
try {
navigator.clipboard.writeText(text).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}).catch(err => {
console.error('Failed to copy text: ', err);
// Fallback method
const textArea = document.createElement('textarea');
textArea.value = text;
document.body.appendChild(textArea);
textArea.select();
document.execCommand('copy');
document.body.removeChild(textArea);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
} catch (error) {
console.error('Copy to clipboard failed:', error);
}
};
if (!comment) {
return (
<div className="flex flex-col items-center justify-center h-64 text-center">
<Send size={48} className="text-gray-400 mb-4" />
<h3 className="text-lg font-medium text-gray-700"></h3>
<p className="text-gray-500 mt-2">
</p>
<button
onClick={onBack}
className="mt-4 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
>
</button>
</div>
);
}
return (
<div>
<div className="flex items-center mb-4">
<button
onClick={onBack}
className="mr-2 p-1 rounded-full hover:bg-gray-200 transition-colors"
>
<ArrowLeft size={18} />
</button>
<h2 className="text-lg font-semibold text-gray-800"></h2>
</div>
{/* Original Comment */}
<div className="bg-white rounded-lg shadow p-4 mb-4">
<div className="flex items-start mb-2">
<div className="bg-gray-200 rounded-full w-8 h-8 flex items-center justify-center mr-2">
<User size={16} />
</div>
<div className="flex-1">
<div className="flex justify-between items-start">
<div>
<div className="font-medium text-gray-900">{comment.author}</div>
<div className="text-xs text-gray-500">{comment.platform} · {comment.timestamp}</div>
</div>
<div className="flex items-center space-x-1">
{comment.sentiment && (
<div className={`flex items-center text-xs px-1.5 py-0.5 rounded-full ${getSentimentBadgeColor(comment.sentiment)}`}>
{comment.sentiment === 'positive' && <Smile size={10} className="mr-1" />}
{comment.sentiment === 'neutral' && <Meh size={10} className="mr-1" />}
{comment.sentiment === 'negative' && <Frown size={10} className="mr-1" />}
{getSentimentLabel(comment.sentiment)}
</div>
)}
</div>
</div>
</div>
</div>
<p className="text-gray-700 mb-2">{comment.content}</p>
<div className="flex flex-wrap gap-1 mb-2">
{comment.keywords?.map(keyword => (
<span key={keyword} className="bg-blue-50 text-blue-700 text-xs px-1.5 py-0.5 rounded">
{keyword}
</span>
))}
</div>
<div className="flex justify-between items-center text-xs text-gray-500">
<div className="flex items-center">
<ThumbsUp size={12} className="mr-1" />
{comment.likes}
</div>
{comment.category && (
<span className="px-1.5 py-0.5 rounded bg-purple-50 text-purple-700">
{comment.category}
</span>
)}
</div>
</div>
{/* Tone Selection */}
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-2"></label>
<div className="grid grid-cols-3 gap-2">
{tones.map(tone => (
<button
key={tone.id}
onClick={() => setSelectedTone(tone.id)}
className={`p-2 text-xs rounded-md text-center transition-colors ${
selectedTone === tone.id
? 'bg-blue-100 text-blue-700 border border-blue-300'
: 'bg-white text-gray-700 border border-gray-200 hover:bg-gray-50'
}`}
title={tone.description}
>
{tone.name}
</button>
))}
</div>
</div>
{/* Persona Selection */}
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-2"></label>
<div className="grid grid-cols-2 gap-2">
{personas.map(persona => (
<button
key={persona.id}
onClick={() => setSelectedPersona(persona.id)}
className={`p-2 text-xs rounded-md text-center transition-colors ${
selectedPersona === persona.id
? 'bg-blue-100 text-blue-700 border border-blue-300'
: 'bg-white text-gray-700 border border-gray-200 hover:bg-gray-50'
}`}
title={persona.description}
>
{persona.name}
</button>
))}
</div>
</div>
{/* Generate Button */}
<button
onClick={generateReplies}
disabled={isGenerating}
className="w-full py-2 px-4 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors disabled:bg-blue-300 flex items-center justify-center mb-4"
>
{isGenerating ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
...
</>
) : (
<>
<Zap size={16} className="mr-2" />
</>
)}
</button>
{/* Generated Replies */}
{generatedReplies.length > 0 && (
<div>
<h3 className="text-sm font-medium text-gray-700 mb-2"></h3>
<div className="space-y-3">
{generatedReplies.map((reply, index) => (
<div key={index} className="bg-white rounded-lg shadow p-4 relative">
<p className="text-gray-700 pr-8">{reply}</p>
<button
onClick={() => copyToClipboard(reply)}
className="absolute top-3 right-3 p-1 rounded-full hover:bg-gray-100 transition-colors"
title="複製到剪貼板"
>
{copied ? <Check size={16} className="text-green-600" /> : <Copy size={16} className="text-gray-500" />}
</button>
</div>
))}
</div>
</div>
)}
</div>
);
};
// Helper function to get sentiment badge colors
function getSentimentBadgeColor(sentiment: string): string {
switch (sentiment) {
case 'positive':
return 'bg-green-100 text-green-800';
case 'neutral':
return 'bg-gray-100 text-gray-800';
case 'negative':
return 'bg-red-100 text-red-800';
default:
return 'bg-gray-100 text-gray-800';
}
}
// Helper function to get sentiment labels
function getSentimentLabel(sentiment: string): string {
switch (sentiment) {
case 'positive':
return '正面';
case 'neutral':
return '中性';
case 'negative':
return '負面';
default:
return '未知';
}
}
export default ReplyGenerator;

View File

@@ -0,0 +1,239 @@
import React, { useState, useEffect } from 'react';
import { Save, Settings as SettingsIcon } from 'lucide-react';
import { ReplyTone, ReplyPersona, SettingsData } from '../../types';
const Settings: React.FC = () => {
const [settings, setSettings] = useState<SettingsData>({
defaultTone: 'friendly',
defaultPersona: 'brand',
autoDetectPlatform: true,
language: 'zh-TW',
maxComments: 50
});
const [isSaving, setIsSaving] = useState<boolean>(false);
const [saveSuccess, setSaveSuccess] = useState<boolean>(false);
const tones: ReplyTone[] = [
{ id: 'friendly', name: '友善', description: '溫暖親切的語氣' },
{ id: 'professional', name: '專業', description: '正式且專業的語氣' },
{ id: 'casual', name: '輕鬆', description: '隨意輕鬆的對話風格' },
{ id: 'enthusiastic', name: '熱情', description: '充滿活力與熱情' },
{ id: 'empathetic', name: '同理心', description: '表達理解與關懷' }
];
const personas: ReplyPersona[] = [
{ id: 'brand', name: '品牌代表', description: '以品牌官方身份回覆' },
{ id: 'support', name: '客服人員', description: '以客服專員身份回覆' },
{ id: 'expert', name: '領域專家', description: '以專業人士身份回覆' },
{ id: 'friend', name: '朋友', description: '以朋友身份回覆' }
];
useEffect(() => {
// Load settings - with fallback for development environment
const loadSettings = () => {
try {
if (typeof chrome !== 'undefined' && chrome.storage && chrome.storage.sync) {
// We're in a Chrome extension environment
chrome.storage.sync.get(['defaultTone', 'defaultPersona', 'autoDetectPlatform', 'language', 'maxComments'], (result) => {
setSettings(prev => ({
...prev,
...result
}));
});
} else {
// We're in development mode - use localStorage
console.log('Using localStorage for settings in development mode');
const savedSettings = localStorage.getItem('commentAssistantSettings');
if (savedSettings) {
try {
const parsedSettings = JSON.parse(savedSettings);
setSettings(prev => ({
...prev,
...parsedSettings
}));
} catch (e) {
console.error('Error parsing saved settings:', e);
}
}
}
} catch (error) {
console.error('Error loading settings:', error);
}
};
loadSettings();
}, []);
const handleChange = (e: React.ChangeEvent<HTMLSelectElement | HTMLInputElement>) => {
const { name, value, type } = e.target;
if (type === 'checkbox') {
const checked = (e.target as HTMLInputElement).checked;
setSettings(prev => ({
...prev,
[name]: checked
}));
} else {
setSettings(prev => ({
...prev,
[name]: value
}));
}
};
const saveSettings = () => {
setIsSaving(true);
try {
if (typeof chrome !== 'undefined' && chrome.storage && chrome.storage.sync) {
// We're in a Chrome extension environment
chrome.storage.sync.set(settings, () => {
setIsSaving(false);
setSaveSuccess(true);
setTimeout(() => {
setSaveSuccess(false);
}, 2000);
});
} else {
// We're in development mode - use localStorage
localStorage.setItem('commentAssistantSettings', JSON.stringify(settings));
// Simulate async operation
setTimeout(() => {
setIsSaving(false);
setSaveSuccess(true);
setTimeout(() => {
setSaveSuccess(false);
}, 2000);
}, 500);
}
} catch (error) {
console.error('Error saving settings:', error);
setIsSaving(false);
}
};
return (
<div>
<div className="flex items-center mb-4">
<SettingsIcon size={20} className="mr-2 text-gray-700" />
<h2 className="text-lg font-semibold text-gray-800"></h2>
</div>
<div className="bg-white rounded-lg shadow p-4 mb-4">
<h3 className="text-sm font-medium text-gray-700 mb-3"></h3>
<div className="mb-4">
<label className="block text-sm text-gray-700 mb-1"></label>
<select
name="defaultTone"
value={settings.defaultTone}
onChange={handleChange}
className="w-full p-2 border border-gray-300 rounded-md text-sm"
>
{tones.map(tone => (
<option key={tone.id} value={tone.id}>
{tone.name} - {tone.description}
</option>
))}
</select>
</div>
<div className="mb-4">
<label className="block text-sm text-gray-700 mb-1"></label>
<select
name="defaultPersona"
value={settings.defaultPersona}
onChange={handleChange}
className="w-full p-2 border border-gray-300 rounded-md text-sm"
>
{personas.map(persona => (
<option key={persona.id} value={persona.id}>
{persona.name} - {persona.description}
</option>
))}
</select>
</div>
</div>
<div className="bg-white rounded-lg shadow p-4 mb-4">
<h3 className="text-sm font-medium text-gray-700 mb-3"></h3>
<div className="mb-4">
<label className="flex items-center">
<input
type="checkbox"
name="autoDetectPlatform"
checked={settings.autoDetectPlatform}
onChange={handleChange}
className="h-4 w-4 text-blue-600 rounded border-gray-300 focus:ring-blue-500"
/>
<span className="ml-2 text-sm text-gray-700"></span>
</label>
</div>
<div className="mb-4">
<label className="block text-sm text-gray-700 mb-1"></label>
<select
name="language"
value={settings.language}
onChange={handleChange}
className="w-full p-2 border border-gray-300 rounded-md text-sm"
>
<option value="zh-TW"></option>
<option value="en-US">English</option>
</select>
</div>
<div className="mb-4">
<label className="block text-sm text-gray-700 mb-1">
({settings.maxComments})
</label>
<input
type="range"
name="maxComments"
min="10"
max="100"
step="10"
value={settings.maxComments}
onChange={handleChange}
className="w-full"
/>
<div className="flex justify-between text-xs text-gray-500">
<span>10</span>
<span>50</span>
<span>100</span>
</div>
</div>
</div>
<button
onClick={saveSettings}
disabled={isSaving}
className="w-full py-2 px-4 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors disabled:bg-blue-300 flex items-center justify-center"
>
{isSaving ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
...
</>
) : saveSuccess ? (
<>
<Save size={16} className="mr-2" />
</>
) : (
<>
<Save size={16} className="mr-2" />
</>
)}
</button>
</div>
);
};
export default Settings;

View File

@@ -0,0 +1,55 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import Sidebar from './Sidebar';
import '../index.css';
import ErrorBoundary from '../ErrorBoundary';
// Error boundary for the sidebar
const renderSidebar = () => {
try {
const rootElement = document.getElementById('root');
if (!rootElement) {
console.error('Root element not found');
return;
}
createRoot(rootElement).render(
<StrictMode>
<ErrorBoundary>
<Sidebar />
</ErrorBoundary>
</StrictMode>
);
} catch (error) {
console.error('Error rendering sidebar:', error);
// Render a fallback UI in case of error
const rootElement = document.getElementById('root');
if (rootElement) {
rootElement.innerHTML = `
<div style="padding: 20px; color: #721c24; background-color: #f8d7da; border: 1px solid #f5c6cb; border-radius: 5px;">
<h2>Sidebar Error</h2>
<p>Sorry, something went wrong while loading the sidebar.</p>
<p>Error details: ${error instanceof Error ? error.message : String(error)}</p>
<button onclick="window.location.reload()" style="background: #dc3545; color: white; border: none; padding: 8px 16px; border-radius: 4px; cursor: pointer;">
Reload Sidebar
</button>
</div>
`;
}
}
};
// Disable Vite's error overlay to prevent WebSocket connection attempts
window.addEventListener('error', (event) => {
event.preventDefault();
console.error('Caught error:', event.error);
return true;
});
// Disable Vite's HMR client
if (import.meta.hot) {
import.meta.hot.decline();
}
renderSidebar();