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,3 @@
{
"template": "bolt-vite-react-ts"
}

8
extension/.bolt/prompt Normal file
View File

@@ -0,0 +1,8 @@
For all designs I ask you to make, have them be beautiful, not cookie cutter. Make webpages that are fully featured and worthy for production.
By default, this template supports JSX syntax with Tailwind CSS classes, React hooks, and Lucide React for icons. Do not install other packages for UI themes, icons, etc unless absolutely necessary or I request them.
Use icons from lucide-react for logos.
Use stock photos from unsplash where appropriate, only valid URLs you know exist. Do not download the images, only link to them in image tags.

24
extension/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

7
extension/background.js Normal file
View File

@@ -0,0 +1,7 @@
// Open the side panel when the extension icon is clicked
chrome.action.onClicked.addListener((tab) => {
chrome.sidePanel.open({ tabId: tab.id });
});
// Set the side panel as open by default for all pages
chrome.sidePanel.setOptions({ enabled: true });

243
extension/content.js Normal file
View File

@@ -0,0 +1,243 @@
// Function to extract comments from the page
function extractComments() {
const comments = [];
let platform = detectPlatform();
// Different extraction strategies based on the platform
if (platform === 'facebook') {
extractFacebookComments(comments);
} else if (platform === 'youtube') {
extractYoutubeComments(comments);
} else if (platform === 'twitter') {
extractTwitterComments(comments);
} else if (platform === 'instagram') {
extractInstagramComments(comments);
} else if (platform === 'linkedin') {
extractLinkedinComments(comments);
} else {
// Generic extraction for other platforms
extractGenericComments(comments);
}
return comments;
}
// Detect the current platform
function detectPlatform() {
const url = window.location.hostname;
if (url.includes('facebook.com')) return 'facebook';
if (url.includes('youtube.com')) return 'youtube';
if (url.includes('twitter.com') || url.includes('x.com')) return 'twitter';
if (url.includes('instagram.com')) return 'instagram';
if (url.includes('linkedin.com')) return 'linkedin';
return 'other';
}
// Platform-specific extraction functions
function extractFacebookComments(comments) {
// Facebook comment selectors
const commentElements = document.querySelectorAll('[aria-label="Comment"]');
commentElements.forEach((element, index) => {
try {
const authorElement = element.querySelector('a');
const contentElement = element.querySelector('[data-ad-comet-preview="message"]');
const timestampElement = element.querySelector('a[href*="comment_id"]');
const likesElement = element.querySelector('[aria-label*="reactions"]');
if (contentElement) {
comments.push({
id: `fb-comment-${index}`,
author: authorElement ? authorElement.textContent : 'Facebook User',
content: contentElement.textContent,
timestamp: timestampElement ? timestampElement.textContent : 'Recently',
likes: likesElement ? parseInt(likesElement.textContent) || 0 : 0,
platform: 'facebook'
});
}
} catch (error) {
console.error('Error extracting Facebook comment:', error);
}
});
}
function extractYoutubeComments(comments) {
// YouTube comment selectors
const commentElements = document.querySelectorAll('ytd-comment-thread-renderer');
commentElements.forEach((element, index) => {
try {
const authorElement = element.querySelector('#author-text');
const contentElement = element.querySelector('#content-text');
const timestampElement = element.querySelector('.published-time-text');
const likesElement = element.querySelector('#vote-count-middle');
if (contentElement) {
comments.push({
id: `yt-comment-${index}`,
author: authorElement ? authorElement.textContent.trim() : 'YouTube User',
content: contentElement.textContent.trim(),
timestamp: timestampElement ? timestampElement.textContent.trim() : 'Recently',
likes: likesElement ? parseInt(likesElement.textContent) || 0 : 0,
platform: 'youtube'
});
}
} catch (error) {
console.error('Error extracting YouTube comment:', error);
}
});
}
function extractTwitterComments(comments) {
// Twitter/X comment selectors
const commentElements = document.querySelectorAll('[data-testid="tweet"]');
commentElements.forEach((element, index) => {
try {
const authorElement = element.querySelector('[data-testid="User-Name"]');
const contentElement = element.querySelector('[data-testid="tweetText"]');
const timestampElement = element.querySelector('time');
const likesElement = element.querySelector('[data-testid="like"]');
if (contentElement) {
comments.push({
id: `twitter-comment-${index}`,
author: authorElement ? authorElement.textContent.split('·')[0].trim() : 'Twitter User',
content: contentElement.textContent.trim(),
timestamp: timestampElement ? timestampElement.getAttribute('datetime') : 'Recently',
likes: likesElement ? parseInt(likesElement.textContent) || 0 : 0,
platform: 'twitter'
});
}
} catch (error) {
console.error('Error extracting Twitter comment:', error);
}
});
}
function extractInstagramComments(comments) {
// Instagram comment selectors
const commentElements = document.querySelectorAll('ul > li > div > div > div:nth-child(2)');
commentElements.forEach((element, index) => {
try {
const authorElement = element.querySelector('h3');
const contentElement = element.querySelector('span');
if (contentElement && authorElement) {
comments.push({
id: `ig-comment-${index}`,
author: authorElement.textContent.trim(),
content: contentElement.textContent.trim(),
timestamp: 'Recently', // Instagram doesn't easily show timestamps
likes: 0, // Instagram doesn't easily show like counts
platform: 'instagram'
});
}
} catch (error) {
console.error('Error extracting Instagram comment:', error);
}
});
}
function extractLinkedinComments(comments) {
// LinkedIn comment selectors
const commentElements = document.querySelectorAll('.comments-comment-item');
commentElements.forEach((element, index) => {
try {
const authorElement = element.querySelector('.comments-post-meta__name-text');
const contentElement = element.querySelector('.comments-comment-item__main-content');
const timestampElement = element.querySelector('.comments-comment-item__timestamp');
if (contentElement) {
comments.push({
id: `linkedin-comment-${index}`,
author: authorElement ? authorElement.textContent.trim() : 'LinkedIn User',
content: contentElement.textContent.trim(),
timestamp: timestampElement ? timestampElement.textContent.trim() : 'Recently',
likes: 0, // LinkedIn doesn't easily show like counts
platform: 'linkedin'
});
}
} catch (error) {
console.error('Error extracting LinkedIn comment:', error);
}
});
}
function extractGenericComments(comments) {
// Generic comment selectors that might work across different platforms
const possibleCommentSelectors = [
'.comment',
'[class*="comment"]',
'[id*="comment"]',
'.review',
'[class*="review"]',
'[class*="post"]',
'[class*="message"]'
];
for (const selector of possibleCommentSelectors) {
const elements = document.querySelectorAll(selector);
if (elements.length > 0) {
elements.forEach((element, index) => {
// Try to find text content that looks like a comment
const textContent = element.textContent.trim();
if (textContent.length > 10 && textContent.length < 1000) {
comments.push({
id: `generic-comment-${index}`,
author: 'User',
content: textContent,
timestamp: 'Recently',
likes: 0,
platform: 'other'
});
}
});
// If we found comments with this selector, no need to try others
if (comments.length > 0) break;
}
}
}
// Listen for messages from the sidebar
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'GET_COMMENTS') {
const comments = extractComments();
// Limit the number of comments based on settings
chrome.storage.sync.get(['maxComments'], (result) => {
const maxComments = result.maxComments || 50;
const limitedComments = comments.slice(0, maxComments);
// Send the comments back to the sidebar
chrome.runtime.sendMessage({
type: 'COMMENTS_CAPTURED',
comments: limitedComments
});
});
}
return true;
});
// Initial extraction when the content script loads
setTimeout(() => {
const comments = extractComments();
chrome.storage.sync.get(['maxComments'], (result) => {
const maxComments = result.maxComments || 50;
const limitedComments = comments.slice(0, maxComments);
chrome.runtime.sendMessage({
type: 'COMMENTS_CAPTURED',
comments: limitedComments
});
});
}, 1000);

View File

@@ -0,0 +1,28 @@
import js from '@eslint/js';
import globals from 'globals';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
}
);

View File

@@ -0,0 +1 @@
<!-- This is a placeholder. You'll need to create actual icon files -->

View File

@@ -0,0 +1 @@
<!-- This is a placeholder. You'll need to create actual icon files -->

View File

@@ -0,0 +1 @@
<!-- This is a placeholder. You'll need to create actual icon files -->

13
extension/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

41
extension/manifest.json Normal file
View File

@@ -0,0 +1,41 @@
{
"manifest_version": 3,
"name": "Social Media Comment Assistant",
"version": "1.0.0",
"description": "A sidebar extension that captures comments, analyzes them, and suggests replies",
"action": {
"default_title": "Comment Assistant",
"default_icon": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
},
"icons": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
},
"permissions": [
"activeTab",
"scripting",
"storage",
"sidePanel"
],
"host_permissions": [
"<all_urls>"
],
"side_panel": {
"default_path": "sidebar.html"
},
"background": {
"service_worker": "background.js",
"type": "module"
},
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["content.js"]
}
]
}

4517
extension/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

36
extension/package.json Normal file
View File

@@ -0,0 +1,36 @@
{
"name": "social-media-comment-assistant",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"lucide-react": "^0.344.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@crxjs/vite-plugin": "^2.0.0-beta.23",
"@eslint/js": "^9.9.1",
"@types/chrome": "^0.0.260",
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.18",
"eslint": "^9.9.1",
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
"eslint-plugin-react-refresh": "^0.4.11",
"globals": "^15.9.0",
"postcss": "^8.4.35",
"tailwindcss": "^3.4.1",
"typescript": "^5.5.3",
"typescript-eslint": "^8.3.0",
"vite": "^5.4.2"
},
"packageManager": "pnpm@9.15.4+sha512.b2dc20e2fc72b3e18848459b37359a32064663e5627a51e4c74b2c29dd8e8e0491483c3abb40789cfd578bf362fb6ba8261b05f0387d76792ed6e23ea3b1b6a0"
}

2887
extension/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

12
extension/sidebar.html Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Comment Assistant</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/sidebar/main.tsx"></script>
</body>
</html>

239
extension/src/App.tsx Normal file
View File

@@ -0,0 +1,239 @@
import React, { useState, useEffect } from 'react';
import { MessageSquare, BarChart2, Send, RefreshCw, Settings as SettingsIcon, AlertCircle } from 'lucide-react';
import CommentList from './sidebar/components/CommentList';
import Analytics from './sidebar/components/Analytics';
import ReplyGenerator from './sidebar/components/ReplyGenerator';
import Settings from './sidebar/components/Settings';
import { Comment } from './types';
import mockComments from './mockData';
function App() {
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);
const [mockDelay, setMockDelay] = useState<number>(1000);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
// Simulate loading comments with a delay
setError(null);
const timer = setTimeout(() => {
try {
setComments(mockComments);
setIsLoading(false);
} catch (err) {
setError('Error loading comments: ' + (err instanceof Error ? err.message : String(err)));
setIsLoading(false);
}
}, mockDelay);
return () => clearTimeout(timer);
}, [mockDelay]);
const refreshComments = () => {
setIsLoading(true);
setError(null);
setTimeout(() => {
try {
setComments(mockComments);
setIsLoading(false);
} catch (err) {
setError('Error refreshing comments: ' + (err instanceof Error ? err.message : String(err)));
setIsLoading(false);
}
}, mockDelay);
};
const handleSelectComment = (comment: Comment) => {
setSelectedComment(comment);
setActiveTab('reply');
};
return (
<div className="flex flex-col min-h-screen bg-gray-100">
<header className="bg-blue-600 text-white p-4">
<div className="container mx-auto flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<h1 className="text-2xl font-bold"> - </h1>
<div className="flex items-center space-x-4">
<div className="flex items-center">
<span className="text-sm mr-2">:</span>
<select
value={mockDelay}
onChange={(e) => setMockDelay(Number(e.target.value))}
className="bg-blue-700 text-white rounded px-2 py-1 text-sm"
>
<option value="0"></option>
<option value="500">0.5 </option>
<option value="1000">1 </option>
<option value="2000">2 </option>
</select>
</div>
<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>
</div>
</header>
{error && (
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative mx-auto mt-4 container" role="alert">
<div className="flex items-center">
<AlertCircle className="mr-2" size={20} />
<span className="block sm:inline">{error}</span>
</div>
</div>
)}
<main className="flex-1 container mx-auto p-4 flex flex-col md:flex-row gap-4">
{/* Sidebar Preview */}
<div className="w-full md:w-80 bg-white rounded-lg shadow-lg overflow-hidden flex flex-col h-[600px] border border-gray-200">
{/* Header */}
<div className="bg-blue-600 text-white p-4 shadow-md">
<h2 className="text-xl font-bold flex items-center">
<MessageSquare className="mr-2" size={20} />
</h2>
<p className="text-sm opacity-80"></p>
</div>
{/* Content */}
<div 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 />
)}
</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'}`}
>
<SettingsIcon size={20} />
<span className="text-xs mt-1"></span>
</button>
</div>
</nav>
</div>
{/* Development Info */}
<div className="flex-1">
<div className="bg-white rounded-lg shadow-lg p-6">
<h2 className="text-xl font-bold mb-4"></h2>
<div className="mb-6">
<h3 className="text-lg font-semibold mb-2"></h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="bg-gray-50 p-3 rounded border border-gray-200">
<p className="text-sm font-medium text-gray-700"></p>
<p className="text-lg">{activeTab}</p>
</div>
<div className="bg-gray-50 p-3 rounded border border-gray-200">
<p className="text-sm font-medium text-gray-700"></p>
<p className="text-lg">{comments.length}</p>
</div>
<div className="bg-gray-50 p-3 rounded border border-gray-200">
<p className="text-sm font-medium text-gray-700"></p>
<p className="text-lg">{isLoading ? '載入中' : '已載入'}</p>
</div>
<div className="bg-gray-50 p-3 rounded border border-gray-200">
<p className="text-sm font-medium text-gray-700"></p>
<p className="text-lg">{selectedComment ? `ID: ${selectedComment.id}` : '無'}</p>
</div>
</div>
</div>
<div className="mb-6">
<h3 className="text-lg font-semibold mb-2"></h3>
<div className="bg-blue-50 p-4 rounded border border-blue-200">
<p className="mb-2"> Chrome </p>
<ul className="list-disc pl-5 space-y-1 text-sm">
<li></li>
<li>調</li>
<li></li>
<li>使</li>
</ul>
</div>
</div>
<div>
<h3 className="text-lg font-semibold mb-2"></h3>
<div className="bg-gray-50 p-4 rounded border border-gray-200 space-y-3">
<div>
<p className="font-medium"></p>
<code className="bg-gray-100 px-2 py-1 rounded text-sm">npm run build</code>
</div>
<div>
<p className="font-medium"></p>
<ol className="list-decimal pl-5 text-sm space-y-1">
<li> Chrome (chrome://extensions/)</li>
<li></li>
<li></li>
<li> dist </li>
</ol>
</div>
<div>
<p className="font-medium"></p>
<ol className="list-decimal pl-5 text-sm space-y-1">
<li></li>
<li></li>
<li></li>
</ol>
</div>
</div>
</div>
</div>
</div>
</main>
<footer className="bg-gray-800 text-white p-4 text-center">
<p> - © 2025</p>
</footer>
</div>
);
}
export default App;

View File

@@ -0,0 +1,59 @@
import React, { Component, ErrorInfo, ReactNode } from 'react';
import { AlertCircle } from 'lucide-react';
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
console.error('Error caught by ErrorBoundary:', error, errorInfo);
}
render(): ReactNode {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback;
}
return (
<div className="p-4 bg-red-100 border border-red-400 text-red-700 rounded-md">
<div className="flex items-start">
<AlertCircle className="mr-2 mt-0.5" size={20} />
<div>
<h3 className="font-bold mb-1">Something went wrong</h3>
<p className="text-sm mb-2">
{this.state.error?.message || 'An unexpected error occurred'}
</p>
<button
onClick={() => window.location.reload()}
className="bg-red-600 text-white px-3 py-1 rounded text-sm hover:bg-red-700 transition-colors"
>
Reload Page
</button>
</div>
</div>
</div>
);
}
return this.props.children;
}
}
export default ErrorBoundary;

3
extension/src/index.css Normal file
View File

@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

55
extension/src/main.tsx Normal file
View File

@@ -0,0 +1,55 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App.tsx';
import './index.css';
import ErrorBoundary from './ErrorBoundary';
// Error boundary for the entire application
const renderApp = () => {
try {
const rootElement = document.getElementById('root');
if (!rootElement) {
console.error('Root element not found');
return;
}
createRoot(rootElement).render(
<StrictMode>
<ErrorBoundary>
<App />
</ErrorBoundary>
</StrictMode>
);
} catch (error) {
console.error('Error rendering application:', 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>Application Error</h2>
<p>Sorry, something went wrong while loading the application.</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 Page
</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();
}
renderApp();

224
extension/src/mockData.ts Normal file
View File

@@ -0,0 +1,224 @@
import { Comment } from './types';
const mockComments: Comment[] = [
{
id: 'comment-1',
author: '王小明',
content: '這個產品真的很好用!我已經推薦給我的朋友們了。希望未來能有更多顏色選擇。',
timestamp: '2小時前',
likes: 24,
platform: 'facebook',
sentiment: 'positive',
keywords: ['好用', '推薦', '顏色'],
category: '產品讚美',
replies: [
{
id: 'reply-1-1',
author: '品牌官方',
content: '謝謝您的支持!我們正在開發更多顏色,敬請期待!',
timestamp: '1小時前',
likes: 5,
platform: 'facebook',
sentiment: 'positive'
}
]
},
{
id: 'comment-2',
author: '林美玲',
content: '請問這個產品適合敏感肌膚使用嗎?我之前用類似的產品會過敏。',
timestamp: '3小時前',
likes: 7,
platform: 'facebook',
sentiment: 'neutral',
keywords: ['敏感肌膚', '過敏', '適合'],
category: '產品詢問'
},
{
id: 'comment-3',
author: 'Jason Chen',
content: 'The quality is amazing! Worth every penny. Will definitely buy again.',
timestamp: '5小時前',
likes: 18,
platform: 'instagram',
sentiment: 'positive',
keywords: ['quality', 'worth', 'buy again'],
category: '產品讚美'
},
{
id: 'comment-4',
author: '陳大華',
content: '收到產品了,包裝很精美,但是尺寸比我想像中小一點。總體來說還是很滿意的。',
timestamp: '昨天',
likes: 12,
platform: 'facebook',
sentiment: 'neutral',
keywords: ['包裝', '尺寸', '滿意'],
category: '產品評價'
},
{
id: 'comment-5',
author: 'Sarah Wong',
content: '我有個問題,請問這個產品可以國際運送嗎?我現在在美國。',
timestamp: '昨天',
likes: 3,
platform: 'instagram',
sentiment: 'neutral',
keywords: ['國際運送', '美國'],
category: '物流詢問'
},
{
id: 'comment-6',
author: '黃小琳',
content: '價格有點貴,但品質確實不錯。希望能有折扣活動。',
timestamp: '2天前',
likes: 9,
platform: 'facebook',
sentiment: 'neutral',
keywords: ['價格', '品質', '折扣'],
category: '價格評論'
},
{
id: 'comment-7',
author: 'Mike Li',
content: 'Just received my order. The shipping was super fast! Great service.',
timestamp: '2天前',
likes: 15,
platform: 'twitter',
sentiment: 'positive',
keywords: ['shipping', 'fast', 'service'],
category: '物流評價'
},
{
id: 'comment-8',
author: '張小菲',
content: '我的訂單顯示已發貨但追蹤號碼似乎不正確。能幫我確認一下嗎訂單號TW20250615001',
timestamp: '3天前',
likes: 0,
platform: 'facebook',
sentiment: 'negative',
keywords: ['訂單', '追蹤號碼', '不正確'],
category: '物流問題'
},
{
id: 'comment-9',
author: 'David Wang',
content: '這是我第三次購買了,每次都很滿意。客服也很棒!',
timestamp: '4天前',
likes: 27,
platform: 'youtube',
sentiment: 'positive',
keywords: ['購買', '滿意', '客服'],
category: '客戶體驗',
replies: [
{
id: 'reply-9-1',
author: '品牌官方',
content: '感謝您的持續支持!我們非常重視每一位顧客的體驗。',
timestamp: '4天前',
likes: 8,
platform: 'youtube',
sentiment: 'positive'
},
{
id: 'reply-9-2',
author: 'Lisa Chen',
content: '我也很喜歡他們的客服,總是很有耐心解答問題。',
timestamp: '3天前',
likes: 5,
platform: 'youtube',
sentiment: 'positive'
}
]
},
{
id: 'comment-10',
author: '李小明',
content: '產品收到了,但有一個小零件好像壞了。請問如何申請售後服務?',
timestamp: '5天前',
likes: 2,
platform: 'facebook',
sentiment: 'negative',
keywords: ['零件', '壞了', '售後服務'],
category: '產品問題'
},
{
id: 'comment-11',
author: 'Emma Chang',
content: '我很喜歡你們的環保包裝!希望更多品牌能這樣做。',
timestamp: '1週前',
likes: 42,
platform: 'instagram',
sentiment: 'positive',
keywords: ['環保包裝', '喜歡'],
category: '包裝評價',
replies: [
{
id: 'reply-11-1',
author: '品牌官方',
content: '謝謝您的支持!環保是我們的核心價值之一,我們會繼續努力做得更好。',
timestamp: '1週前',
likes: 12,
platform: 'instagram',
sentiment: 'positive'
}
]
},
{
id: 'comment-12',
author: '陳小華',
content: '請問有沒有實體店面可以試用產品?',
timestamp: '1週前',
likes: 5,
platform: 'facebook',
sentiment: 'neutral',
keywords: ['實體店面', '試用'],
category: '銷售詢問'
},
{
id: 'comment-13',
author: 'Kevin Wu',
content: 'Great product but the app needs improvement. Sometimes it crashes when I try to connect to the device.',
timestamp: '1週前',
likes: 8,
platform: 'twitter',
sentiment: 'neutral',
keywords: ['product', 'app', 'crashes'],
category: '應用問題'
},
{
id: 'comment-14',
author: '林小芳',
content: '我在官網看到的價格和這裡不一樣,為什麼?',
timestamp: '2週前',
likes: 3,
platform: 'youtube',
sentiment: 'negative',
keywords: ['價格', '官網', '不一樣'],
category: '價格問題'
},
{
id: 'comment-15',
author: 'Sophia Lin',
content: '剛剛在朋友家看到這個產品,效果真的很驚人!請問現在有什麼促銷活動嗎?',
timestamp: '2週前',
likes: 19,
platform: 'facebook',
sentiment: 'positive',
keywords: ['效果', '驚人', '促銷活動'],
category: '產品讚美',
replies: [
{
id: 'reply-15-1',
author: '品牌官方',
content: '您好我們目前有限時折扣活動購買任兩件產品即可享85折優惠。詳情請查看我們的官網。',
timestamp: '2週前',
likes: 4,
platform: 'facebook',
sentiment: 'positive'
}
]
}
];
export default mockComments;

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();

49
extension/src/types.ts Normal file
View File

@@ -0,0 +1,49 @@
export interface Comment {
id: string;
author: string;
content: string;
timestamp: string;
likes: number;
replies?: Comment[];
platform: 'facebook' | 'instagram' | 'twitter' | 'youtube' | 'linkedin' | 'other';
sentiment?: 'positive' | 'neutral' | 'negative';
keywords?: string[];
category?: string;
}
export interface ReplyTone {
id: string;
name: string;
description: string;
}
export interface ReplyPersona {
id: string;
name: string;
description: string;
}
export interface SettingsData {
defaultTone: string;
defaultPersona: string;
autoDetectPlatform: boolean;
language: 'zh-TW' | 'en-US';
maxComments: number;
}
export interface CommentFilter {
searchTerm: string;
platform: string;
sortBy: 'newest' | 'oldest' | 'likes' | 'replies';
sentiment?: 'positive' | 'neutral' | 'negative' | 'all';
}
export interface CommentAnalytics {
sentimentCounts: {
positive: number;
neutral: number;
negative: number;
};
topKeywords: Array<{keyword: string, count: number}>;
categories: Record<string, number>;
}

1
extension/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,8 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {},
},
plugins: [],
};

View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

7
extension/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

25
extension/vite.config.ts Normal file
View File

@@ -0,0 +1,25 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { crx } from '@crxjs/vite-plugin';
import manifest from './manifest.json';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
react(),
crx({ manifest }),
],
optimizeDeps: {
exclude: ['lucide-react'],
},
build: {
rollupOptions: {
input: {
sidebar: 'sidebar.html',
},
},
},
server: {
hmr: false, // Completely disable HMR to prevent WebSocket connection attempts
},
});