init
This commit is contained in:
3
extension/.bolt/config.json
Normal file
3
extension/.bolt/config.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"template": "bolt-vite-react-ts"
|
||||
}
|
||||
8
extension/.bolt/prompt
Normal file
8
extension/.bolt/prompt
Normal 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
24
extension/.gitignore
vendored
Normal 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
7
extension/background.js
Normal 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
243
extension/content.js
Normal 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);
|
||||
28
extension/eslint.config.js
Normal file
28
extension/eslint.config.js
Normal 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 },
|
||||
],
|
||||
},
|
||||
}
|
||||
);
|
||||
1
extension/icons/icon128.png
Normal file
1
extension/icons/icon128.png
Normal file
@@ -0,0 +1 @@
|
||||
<!-- This is a placeholder. You'll need to create actual icon files -->
|
||||
1
extension/icons/icon16.png
Normal file
1
extension/icons/icon16.png
Normal file
@@ -0,0 +1 @@
|
||||
<!-- This is a placeholder. You'll need to create actual icon files -->
|
||||
1
extension/icons/icon48.png
Normal file
1
extension/icons/icon48.png
Normal file
@@ -0,0 +1 @@
|
||||
<!-- This is a placeholder. You'll need to create actual icon files -->
|
||||
13
extension/index.html
Normal file
13
extension/index.html
Normal 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
41
extension/manifest.json
Normal 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
4517
extension/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
36
extension/package.json
Normal file
36
extension/package.json
Normal 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
2887
extension/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
6
extension/postcss.config.js
Normal file
6
extension/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
12
extension/sidebar.html
Normal file
12
extension/sidebar.html
Normal 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
239
extension/src/App.tsx
Normal 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;
|
||||
59
extension/src/ErrorBoundary.tsx
Normal file
59
extension/src/ErrorBoundary.tsx
Normal 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
3
extension/src/index.css
Normal file
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
55
extension/src/main.tsx
Normal file
55
extension/src/main.tsx
Normal 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
224
extension/src/mockData.ts
Normal 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;
|
||||
196
extension/src/sidebar/Sidebar.tsx
Normal file
196
extension/src/sidebar/Sidebar.tsx
Normal 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;
|
||||
371
extension/src/sidebar/components/Analytics.tsx
Normal file
371
extension/src/sidebar/components/Analytics.tsx
Normal 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;
|
||||
453
extension/src/sidebar/components/CommentList.tsx
Normal file
453
extension/src/sidebar/components/CommentList.tsx
Normal 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;
|
||||
363
extension/src/sidebar/components/ReplyGenerator.tsx
Normal file
363
extension/src/sidebar/components/ReplyGenerator.tsx
Normal 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;
|
||||
239
extension/src/sidebar/components/Settings.tsx
Normal file
239
extension/src/sidebar/components/Settings.tsx
Normal 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;
|
||||
55
extension/src/sidebar/main.tsx
Normal file
55
extension/src/sidebar/main.tsx
Normal 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
49
extension/src/types.ts
Normal 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
1
extension/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
8
extension/tailwind.config.js
Normal file
8
extension/tailwind.config.js
Normal file
@@ -0,0 +1,8 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
24
extension/tsconfig.app.json
Normal file
24
extension/tsconfig.app.json
Normal 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
7
extension/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
22
extension/tsconfig.node.json
Normal file
22
extension/tsconfig.node.json
Normal 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
25
extension/vite.config.ts
Normal 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
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user