feat: 部署初版测试
This commit is contained in:
@@ -1,59 +1,167 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { cn } from './lib/utils'
|
||||
import { ref, onMounted } from 'vue'
|
||||
|
||||
const isLoading = ref(false)
|
||||
const activeTab = ref<'settings' | 'history'>('settings')
|
||||
|
||||
const triggerMockLoading = () => {
|
||||
isLoading.value = true
|
||||
setTimeout(() => {
|
||||
isLoading.value = false
|
||||
}, 1000)
|
||||
// Settings State
|
||||
const identity = ref('Independent Developer / Founder')
|
||||
const language = ref('auto')
|
||||
const isSaving = ref(false)
|
||||
|
||||
// History State
|
||||
const historyList = ref<Array<any>>([])
|
||||
|
||||
onMounted(() => {
|
||||
// Load settings
|
||||
chrome.storage.sync.get(['identity', 'language'], (res) => {
|
||||
if (res.identity) identity.value = String(res.identity)
|
||||
if (res.language) language.value = String(res.language)
|
||||
})
|
||||
|
||||
// Load history
|
||||
chrome.storage.local.get(['history'], (res) => {
|
||||
if (Array.isArray(res.history)) {
|
||||
historyList.value = res.history
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const saveSettings = () => {
|
||||
isSaving.value = true
|
||||
chrome.storage.sync.set({
|
||||
identity: identity.value,
|
||||
language: language.value
|
||||
}, () => {
|
||||
setTimeout(() => {
|
||||
isSaving.value = false
|
||||
}, 600)
|
||||
})
|
||||
}
|
||||
|
||||
const copyToClipboard = (text: string) => {
|
||||
navigator.clipboard.writeText(text)
|
||||
}
|
||||
|
||||
const clearHistory = () => {
|
||||
chrome.storage.local.set({ history: [] }, () => {
|
||||
historyList.value = []
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-[400px] h-[600px] bg-[#0A0A0A]/90 backdrop-blur-xl border border-white/10 text-[#E5E5E5] p-6 flex flex-col font-sans">
|
||||
<div class="w-[400px] h-[600px] bg-[#0A0A0A] text-[#E5E5E5] flex flex-col font-sans overflow-hidden">
|
||||
|
||||
<!-- Title Area -->
|
||||
<div class="mb-6">
|
||||
<h1 class="text-xl font-medium tracking-tight bg-gradient-to-r from-violet-500 to-blue-500 bg-clip-text text-transparent inline-block">
|
||||
InsightReply
|
||||
</h1>
|
||||
<p class="text-xs text-zinc-400 mt-1">Social Insight Copilot</p>
|
||||
<!-- Header -->
|
||||
<div class="p-5 border-b border-white/10 bg-white/5 flex justify-between items-center">
|
||||
<div>
|
||||
<h1 class="text-xl font-semibold tracking-tight bg-gradient-to-r from-violet-500 to-blue-500 bg-clip-text text-transparent">
|
||||
InsightReply
|
||||
</h1>
|
||||
<p class="text-[11px] text-zinc-500 mt-1 uppercase tracking-widest font-semibold">Social Copilot</p>
|
||||
</div>
|
||||
|
||||
<!-- Tab Switcher -->
|
||||
<div class="flex gap-1 bg-black/50 p-1 rounded-lg border border-white/5">
|
||||
<button
|
||||
@click="activeTab = 'settings'"
|
||||
:class="['px-3 py-1 text-xs font-medium rounded-md transition-all', activeTab === 'settings' ? 'bg-zinc-800 text-white shadow-md' : 'text-zinc-500 hover:text-zinc-300']"
|
||||
>
|
||||
Settings
|
||||
</button>
|
||||
<button
|
||||
@click="activeTab = 'history'"
|
||||
:class="['px-3 py-1 text-xs font-medium rounded-md transition-all', activeTab === 'history' ? 'bg-zinc-800 text-white shadow-md' : 'text-zinc-500 hover:text-zinc-300']"
|
||||
>
|
||||
History
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content Area -->
|
||||
<div class="flex-1 overflow-y-auto pr-2 space-y-4">
|
||||
<div class="flex-1 overflow-y-auto p-5">
|
||||
|
||||
<!-- Example Heat Score Card -->
|
||||
<div class="bg-[#171717] rounded-xl p-4 border border-white/5 shadow-lg shadow-black/50">
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<span class="text-sm font-medium text-zinc-300">Current Tweet Heat</span>
|
||||
<span class="text-xs font-bold px-2 py-0.5 rounded-full bg-orange-500/20 text-orange-400 border border-orange-500/20">Hot</span>
|
||||
</div>
|
||||
<div class="flex items-end gap-2">
|
||||
<span class="text-3xl font-semibold tracking-tighter">87.5</span>
|
||||
<span class="text-xs text-zinc-500 mb-1">/ 100</span>
|
||||
<!-- Settings Tab -->
|
||||
<div v-if="activeTab === 'settings'" class="animate-in fade-in slide-in-from-right-4 duration-300 space-y-6">
|
||||
<div>
|
||||
<h2 class="text-base font-medium text-white mb-1">Your Identity Profile</h2>
|
||||
<p class="text-xs text-zinc-400 mb-4">Set your background so AI can generate relevant, authentic replies matching your persona.</p>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-xs font-medium text-zinc-300">Identity Label</label>
|
||||
<input
|
||||
v-model="identity"
|
||||
type="text"
|
||||
class="w-full bg-[#171717] border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-brand-primary/50 transition-colors"
|
||||
placeholder="e.g. AI Founder, Indie Hacker, Marketer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-xs font-medium text-zinc-300">Reply Language</label>
|
||||
<select
|
||||
v-model="language"
|
||||
class="w-full bg-[#171717] border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-brand-primary/50 transition-colors appearance-none"
|
||||
>
|
||||
<option value="auto">Auto (Match Tweet)</option>
|
||||
<option value="en">English (en)</option>
|
||||
<option value="zh">Chinese (zh)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="saveSettings"
|
||||
:disabled="isSaving"
|
||||
class="w-full py-2.5 bg-brand-primary hover:bg-brand-primary/90 text-white rounded-lg text-sm font-medium transition-all shadow-lg flex items-center justify-center gap-2 mt-4 disabled:opacity-70 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span v-if="isSaving" class="w-4 h-4 border-2 border-white/20 border-t-white rounded-full animate-spin"></span>
|
||||
{{ isSaving ? 'Saving...' : 'Save Preferences' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Action Button -->
|
||||
<button
|
||||
@click="triggerMockLoading"
|
||||
:disabled="isLoading"
|
||||
:class="cn(
|
||||
'w-full py-2.5 rounded-lg text-sm font-medium transition-all duration-200 ease-in-out',
|
||||
'flex items-center justify-center gap-2',
|
||||
isLoading
|
||||
? 'bg-[#171717] text-zinc-500 border border-white/5 cursor-not-allowed'
|
||||
: 'bg-brand-primary hover:bg-brand-primary/90 text-white shadow-lg shadow-brand-primary/20 hover:scale-[0.98]'
|
||||
)"
|
||||
>
|
||||
<span v-if="isLoading" class="animate-spin inline-block w-4 h-4 border-2 border-white/20 border-t-white rounded-full"></span>
|
||||
{{ isLoading ? 'Generating Insights...' : 'Generate Replies' }}
|
||||
</button>
|
||||
<!-- History Tab -->
|
||||
<div v-if="activeTab === 'history'" class="animate-in fade-in slide-in-from-left-4 duration-300 flex flex-col h-full">
|
||||
<div class="flex justify-between items-center mb-4 pb-2 border-b border-white/10">
|
||||
<h2 class="text-xs font-semibold text-zinc-400 uppercase tracking-widest">Generation History</h2>
|
||||
<button @click="clearHistory" class="text-xs text-red-400/80 hover:text-red-400 transition-colors">Clear All</button>
|
||||
</div>
|
||||
|
||||
<div v-if="historyList.length === 0" class="flex-1 flex flex-col items-center justify-center text-zinc-500 space-y-3 pb-10">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" class="opacity-20"><path d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
<p class="text-sm">No history yet</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-6">
|
||||
<div v-for="(item, idx) in historyList" :key="idx" class="space-y-3 bg-white/5 rounded-xl p-4 border border-white/5">
|
||||
<div class="text-[10px] text-zinc-500 font-mono">{{ new Date(item.timestamp).toLocaleString() }}</div>
|
||||
<div class="text-xs italic text-zinc-400 border-l-2 border-zinc-700 pl-2">"{{ item.tweetContent?.substring(0, 100) }}{{ item.tweetContent?.length > 100 ? '...' : '' }}"</div>
|
||||
|
||||
<div class="space-y-2 mt-3 pt-3 border-t border-white/5">
|
||||
<div v-for="(reply, rIdx) in item.replies" :key="rIdx" class="bg-[#171717] rounded-lg p-3 group relative">
|
||||
<div class="flex justify-between items-center mb-1">
|
||||
<span class="text-[10px] font-medium text-brand-primary bg-brand-primary/10 px-1.5 py-0.5 rounded">{{ reply.strategy }}</span>
|
||||
<button @click="copyToClipboard(reply.content)" class="opacity-0 group-hover:opacity-100 transition-opacity text-[10px] text-zinc-400 hover:text-white">Copy</button>
|
||||
</div>
|
||||
<div class="text-xs text-zinc-300 leading-relaxed">{{ reply.content }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* Scoped overrides */
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 400px;
|
||||
height: 600px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,33 +5,87 @@
|
||||
|
||||
console.log('InsightReply Background Script Loaded');
|
||||
|
||||
const API_BASE = 'http://localhost:8080/api/v1';
|
||||
const API_BASE = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080/api/v1';
|
||||
|
||||
chrome.runtime.onMessage.addListener((message: { type: string; payload?: any }, _sender: chrome.runtime.MessageSender, sendResponse: (response?: any) => void) => {
|
||||
if (message.type === 'SHOW_INSIGHT') {
|
||||
console.log('Received tweet data in background:', message.payload);
|
||||
}
|
||||
|
||||
if (message.type === 'FETCH_CUSTOM_STRATEGIES') {
|
||||
chrome.storage.local.get(['jwt_token'], (res) => {
|
||||
const token = res.jwt_token;
|
||||
if (!token) {
|
||||
sendResponse({ success: false, data: [] });
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(`${API_BASE}/users/me/strategies`, {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
})
|
||||
.then(resp => resp.json())
|
||||
.then(data => {
|
||||
if (data.code === 200 && Array.isArray(data.data)) {
|
||||
sendResponse({ success: true, data: data.data });
|
||||
} else {
|
||||
sendResponse({ success: false, data: [] });
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Fetch strategies error:', err);
|
||||
sendResponse({ success: false, data: [] });
|
||||
});
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (message.type === 'GENERATE_REPLY') {
|
||||
const { tweetContent, strategy, identity } = message.payload;
|
||||
|
||||
fetch(`${API_BASE}/ai/generate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
tweet_content: tweetContent,
|
||||
strategy: strategy,
|
||||
identity: identity || 'Independent Developer / Founder'
|
||||
chrome.storage.local.get(['jwt_token'], (res) => {
|
||||
const token = res.jwt_token;
|
||||
if (!token) {
|
||||
sendResponse({ success: false, error: 'unauthorized', message: 'Please log in via the Extension Options page.' });
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(`${API_BASE}/ai/generate`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
tweet_content: tweetContent,
|
||||
strategy: strategy,
|
||||
identity: identity || 'Independent Developer / Founder'
|
||||
})
|
||||
})
|
||||
})
|
||||
.then(resp => resp.json())
|
||||
.then(data => {
|
||||
sendResponse({ success: true, data: data.data });
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('API Error:', err);
|
||||
sendResponse({ success: false, error: err.message });
|
||||
});
|
||||
.then(resp => resp.json())
|
||||
.then(data => {
|
||||
const resultData = data.data;
|
||||
sendResponse({ success: true, data: resultData });
|
||||
|
||||
// Save to History Tab
|
||||
if (resultData && resultData.replies) {
|
||||
chrome.storage.local.get(['history'], (res) => {
|
||||
const history = Array.isArray(res.history) ? res.history : [];
|
||||
const newEntry = {
|
||||
timestamp: Date.now(),
|
||||
tweetContent,
|
||||
replies: resultData.replies
|
||||
};
|
||||
const updatedHistory = [newEntry, ...history].slice(0, 50); // Keep last 50
|
||||
chrome.storage.local.set({ history: updatedHistory });
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('API Error:', err);
|
||||
sendResponse({ success: false, error: err.message });
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
return true; // Keep channel open for async response
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ref, onMounted } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
tweetData?: {
|
||||
id: string;
|
||||
author: string;
|
||||
content: string;
|
||||
stats: {
|
||||
@@ -15,40 +16,101 @@ const props = defineProps<{
|
||||
|
||||
const isVisible = ref(true)
|
||||
const selectedStrategy = ref('Insightful')
|
||||
const generatedReply = ref('')
|
||||
const generatedReplies = ref<Array<{strategy: string, content: string}>>([])
|
||||
const isGenerating = ref(false)
|
||||
|
||||
const strategies = [
|
||||
const defaultStrategies = [
|
||||
{ id: 'Insightful', label: '认知升级型', icon: '🧠' },
|
||||
{ id: 'Humorous', label: '幽默风趣型', icon: '😄' },
|
||||
{ id: 'Professional', label: '专业严谨型', icon: '⚖️' },
|
||||
{ id: 'Supportive', label: '共鸣支持型', icon: '❤️' },
|
||||
{ id: 'Critical', label: '锐评批判型', icon: '🔥' }
|
||||
{ id: 'Critical', label: '锐评批判型', icon: '🔥' },
|
||||
{ id: 'Quote', label: '引用转发型', icon: '💬' }
|
||||
]
|
||||
|
||||
const strategies = ref([...defaultStrategies])
|
||||
|
||||
onMounted(() => {
|
||||
chrome.runtime.sendMessage({ type: 'FETCH_CUSTOM_STRATEGIES' }, (response) => {
|
||||
if (response && response.success && response.data) {
|
||||
const customStrategies = response.data.map((s: any) => ({
|
||||
id: s.strategy_key,
|
||||
label: s.label,
|
||||
icon: '✨'
|
||||
}))
|
||||
strategies.value = [...defaultStrategies, ...customStrategies]
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const generate = () => {
|
||||
if (!props.tweetData) return
|
||||
|
||||
isGenerating.value = true
|
||||
chrome.runtime.sendMessage({
|
||||
type: 'GENERATE_REPLY',
|
||||
payload: {
|
||||
tweetContent: props.tweetData.content,
|
||||
strategy: selectedStrategy.value,
|
||||
identity: 'Independent Developer / Founder' // Could be dynamic later
|
||||
}
|
||||
}, (response) => {
|
||||
isGenerating.value = false
|
||||
if (response && response.success) {
|
||||
generatedReply.value = response.data.reply
|
||||
} else {
|
||||
generatedReply.value = 'Failed to generate reply. Please check your connection or API key.'
|
||||
generatedReplies.value = []
|
||||
|
||||
chrome.storage.sync.get(['identity', 'language'], (res) => {
|
||||
let finalIdentity = res.identity ? String(res.identity) : 'Independent Developer / Founder'
|
||||
if (res.language && res.language !== 'auto') {
|
||||
const langStr = String(res.language)
|
||||
const langMap: Record<string, string> = { 'zh': 'Chinese (Simplified)', 'en': 'English' }
|
||||
finalIdentity += ` (CRITICAL: You MUST reply in ${langMap[langStr] || langStr})`
|
||||
}
|
||||
|
||||
chrome.runtime.sendMessage({
|
||||
type: 'GENERATE_REPLY',
|
||||
payload: {
|
||||
tweetContent: props.tweetData!.content,
|
||||
strategy: selectedStrategy.value,
|
||||
identity: finalIdentity
|
||||
}
|
||||
}, (response) => {
|
||||
isGenerating.value = false
|
||||
if (response && response.success) {
|
||||
generatedReplies.value = response.data.replies || []
|
||||
} else if (response && response.error === 'unauthorized') {
|
||||
generatedReplies.value = [{ strategy: 'Auth Required', content: 'Connection required. Please log in first.' }]
|
||||
chrome.runtime.openOptionsPage()
|
||||
} else {
|
||||
generatedReplies.value = [{ strategy: 'Error', content: response?.error || 'Failed to generate reply. Please check your connection or API key.' }]
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const copyToClipboard = () => {
|
||||
navigator.clipboard.writeText(generatedReply.value)
|
||||
const showProfileTip = ref(false)
|
||||
|
||||
const copyToClipboard = async (reply: any) => {
|
||||
navigator.clipboard.writeText(reply.content)
|
||||
showProfileTip.value = true
|
||||
setTimeout(() => { showProfileTip.value = false }, 7000)
|
||||
|
||||
// Epic 13: Record generated reply for performance tracking telemetry
|
||||
if (!props.tweetData || !props.tweetData.id) return;
|
||||
|
||||
chrome.storage.local.get(['jwt_token'], async (result) => {
|
||||
if (result.jwt_token) {
|
||||
try {
|
||||
const apiBase = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080/api/v1'
|
||||
const token = result.jwt_token;
|
||||
await fetch(`${apiBase}/replies/record`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
tweet_id: props.tweetData?.id,
|
||||
strategy_type: reply.strategy || 'General',
|
||||
content: reply.content,
|
||||
language: 'en'
|
||||
})
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to log telemetry:', err) // Non blocking telemetry
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -98,20 +160,40 @@ const copyToClipboard = () => {
|
||||
</div>
|
||||
|
||||
<!-- Result Area -->
|
||||
<div v-if="generatedReply" class="space-y-3 animate-in fade-in slide-in-from-bottom-2 duration-500">
|
||||
<div v-if="generatedReplies.length > 0" class="space-y-4 animate-in fade-in slide-in-from-bottom-2 duration-500">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-xs font-semibold text-zinc-500 uppercase tracking-widest">Assistant Suggestion</span>
|
||||
<button @click="copyToClipboard" class="text-[10px] text-brand-primary hover:underline">Copy Result</button>
|
||||
<span class="text-xs font-semibold text-zinc-500 uppercase tracking-widest">Assistant Suggestions</span>
|
||||
</div>
|
||||
<div class="bg-[#171717] rounded-xl p-4 border border-white/10 text-sm leading-relaxed whitespace-pre-wrap">
|
||||
{{ generatedReply }}
|
||||
|
||||
<div v-for="(reply, idx) in generatedReplies" :key="idx" class="bg-[#171717] rounded-xl p-4 border border-white/10 space-y-3 relative group transition-all hover:bg-[#202020] hover:border-white/20">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-xs font-medium text-brand-primary bg-brand-primary/10 px-2 py-0.5 rounded-full">
|
||||
{{ reply.strategy || 'Suggestion' }}
|
||||
</span>
|
||||
<button @click="copyToClipboard(reply)" class="opacity-0 group-hover:opacity-100 transition-opacity text-[10px] text-zinc-400 hover:text-white flex items-center gap-1 cursor-pointer">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
<div class="text-sm leading-relaxed whitespace-pre-wrap text-[#E5E5E5]">
|
||||
{{ reply.content }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Footer Action -->
|
||||
<div class="p-4 bg-white/5 border-t border-white/5">
|
||||
<div class="p-4 bg-white/5 border-t border-white/5 flex flex-col gap-3">
|
||||
<transition enter-active-class="transition ease-out duration-300 transform" enter-from-class="opacity-0 translate-y-2" enter-to-class="opacity-100 translate-y-0" leave-active-class="transition ease-in duration-200 transform" leave-from-class="opacity-100 translate-y-0" leave-to-class="opacity-0 translate-y-2">
|
||||
<div v-if="showProfileTip" class="bg-blue-500/10 border border-blue-500/20 text-blue-400 text-[11px] p-2.5 rounded-lg flex gap-2 items-start relative pr-6">
|
||||
<span class="text-sm leading-none">💡</span>
|
||||
<p class="leading-relaxed"><strong>Optimze your conversion:</strong> Ensure your X Bio and Pinned Tweet clearly mention your product/link!</p>
|
||||
<button @click="showProfileTip = false" class="absolute top-2 right-2 text-blue-400/50 hover:text-blue-400">✕</button>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<button
|
||||
@click="generate"
|
||||
:disabled="isGenerating"
|
||||
|
||||
@@ -17,22 +17,48 @@ interface TweetData {
|
||||
};
|
||||
}
|
||||
|
||||
let relevanceKeywords: string[] = [];
|
||||
|
||||
chrome.storage.local.get(['relevance_keywords'], (res) => {
|
||||
if (res.relevance_keywords) {
|
||||
relevanceKeywords = String(res.relevance_keywords).split(',').map((k: string) => k.trim().toLowerCase()).filter(Boolean);
|
||||
}
|
||||
});
|
||||
|
||||
chrome.storage.onChanged.addListener((changes, namespace) => {
|
||||
if (namespace === 'local' && changes.relevance_keywords) {
|
||||
const val = String(changes.relevance_keywords.newValue || '');
|
||||
relevanceKeywords = val.split(',').map((k: string) => k.trim().toLowerCase()).filter(Boolean);
|
||||
}
|
||||
});
|
||||
|
||||
// 2. 提取推文内容的逻辑
|
||||
const parseStat = (statString: string): number => {
|
||||
if (!statString) return 0;
|
||||
const match = statString.match(/([\d,\.]+)\s*([KkMmB])?/i);
|
||||
if (!match) return 0;
|
||||
const baseStr = match[1] || '0';
|
||||
let num = parseFloat(baseStr.replace(/,/g, ''));
|
||||
const multiplier = match[2] ? match[2].toUpperCase() : '';
|
||||
if (multiplier === 'K') num *= 1000;
|
||||
if (multiplier === 'M') num *= 1000000;
|
||||
if (multiplier === 'B') num *= 1000000000;
|
||||
return num;
|
||||
};
|
||||
|
||||
const extractTweetData = (tweetElement: HTMLElement): TweetData | null => {
|
||||
try {
|
||||
// 根据 X 的 DOM 结构提取 (可能会随 Twitter 更新而变化)
|
||||
const textElement = tweetElement.querySelector('[data-testid="tweetText"]');
|
||||
const authorElement = tweetElement.querySelector('[data-testid="User-Name"]');
|
||||
const linkElement = tweetElement.querySelector('time')?.parentElement as HTMLAnchorElement;
|
||||
|
||||
// 互动数据提取
|
||||
if (!textElement || !authorElement || !linkElement) return null;
|
||||
|
||||
const getStat = (testid: string) => {
|
||||
const el = tweetElement.querySelector(`[data-testid="${testid}"]`);
|
||||
return el?.getAttribute('aria-label') || '0';
|
||||
};
|
||||
|
||||
if (!textElement || !authorElement || !linkElement) return null;
|
||||
|
||||
const tweetId = linkElement.href.split('/').pop() || '';
|
||||
|
||||
return {
|
||||
@@ -51,13 +77,68 @@ const extractTweetData = (tweetElement: HTMLElement): TweetData | null => {
|
||||
}
|
||||
};
|
||||
|
||||
const injectBadges = (tweetElement: HTMLElement, data: TweetData) => {
|
||||
if (tweetElement.dataset.insightBadged === 'true') return;
|
||||
tweetElement.dataset.insightBadged = 'true';
|
||||
|
||||
const likes = parseStat(data.stats.likes);
|
||||
const retweets = parseStat(data.stats.retweets);
|
||||
const replies = parseStat(data.stats.replies);
|
||||
const heatScore = likes * 1 + retweets * 2 + replies * 3;
|
||||
|
||||
let badgeText = '';
|
||||
let badgeStyle = '';
|
||||
|
||||
if (heatScore > 50000) {
|
||||
badgeText = '🔥 Trending';
|
||||
badgeStyle = 'color: #f97316; background: rgba(249, 115, 22, 0.1); border: 1px solid rgba(249, 115, 22, 0.2);';
|
||||
} else if (heatScore > 5000) {
|
||||
badgeText = '⚡ Rising';
|
||||
badgeStyle = 'color: #3b82f6; background: rgba(59, 130, 246, 0.1); border: 1px solid rgba(59, 130, 246, 0.2);';
|
||||
}
|
||||
|
||||
let isRelevant = false;
|
||||
if (relevanceKeywords.length > 0 && data.content) {
|
||||
const contentLower = data.content.toLowerCase();
|
||||
isRelevant = relevanceKeywords.some(kw => contentLower.includes(kw));
|
||||
}
|
||||
|
||||
if (!badgeText && !isRelevant) return;
|
||||
|
||||
const authorSection = tweetElement.querySelector('[data-testid="User-Name"]');
|
||||
if (!authorSection) return;
|
||||
|
||||
const container = document.createElement('div');
|
||||
container.className = 'insight-badges-container';
|
||||
container.style.cssText = `display: inline-flex; align-items: center; gap: 4px; margin-left: 8px;`;
|
||||
|
||||
if (badgeText) {
|
||||
const heatBadge = document.createElement('span');
|
||||
heatBadge.style.cssText = `padding: 2px 6px; border-radius: 4px; font-size: 10px; font-weight: bold; ${badgeStyle}`;
|
||||
heatBadge.innerText = badgeText;
|
||||
container.appendChild(heatBadge);
|
||||
}
|
||||
|
||||
if (isRelevant) {
|
||||
const relBadge = document.createElement('span');
|
||||
relBadge.style.cssText = `padding: 2px 6px; border-radius: 4px; font-size: 10px; font-weight: bold; color: #10b981; background: rgba(16, 185, 129, 0.1); border: 1px solid rgba(16, 185, 129, 0.2);`;
|
||||
relBadge.innerText = '🎯 Relevant';
|
||||
container.appendChild(relBadge);
|
||||
}
|
||||
|
||||
const firstLine = authorSection.firstElementChild;
|
||||
if (firstLine) {
|
||||
firstLine.appendChild(container);
|
||||
} else {
|
||||
authorSection.appendChild(container);
|
||||
}
|
||||
};
|
||||
|
||||
// 3. 注入“Insight”按钮
|
||||
const injectInsightButton = (tweetElement: HTMLElement) => {
|
||||
// 查找操作栏 (Actions bar)
|
||||
const actionBar = tweetElement.querySelector('[role="group"]');
|
||||
if (!actionBar || actionBar.querySelector('.insight-reply-btn')) return;
|
||||
|
||||
// 创建按钮
|
||||
const btnContainer = document.createElement('div');
|
||||
btnContainer.className = 'insight-reply-btn';
|
||||
btnContainer.style.display = 'flex';
|
||||
@@ -65,7 +146,6 @@ const injectInsightButton = (tweetElement: HTMLElement) => {
|
||||
btnContainer.style.marginLeft = '12px';
|
||||
btnContainer.style.cursor = 'pointer';
|
||||
|
||||
// 按钮内部图标 (简易版)
|
||||
btnContainer.innerHTML = `
|
||||
<div style="padding: 4px; border-radius: 9999px; transition: background 0.2s;" onmouseover="this.style.background='rgba(139, 92, 246, 0.1)'" onmouseout="this.style.background='transparent'">
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor" style="color: #8B5CF6;">
|
||||
@@ -77,9 +157,6 @@ const injectInsightButton = (tweetElement: HTMLElement) => {
|
||||
btnContainer.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
const data = extractTweetData(tweetElement);
|
||||
console.log('Target Tweet Data:', data);
|
||||
|
||||
// 发送消息给插件侧边栏/Popup (后续实现)
|
||||
if (data) {
|
||||
chrome.runtime.sendMessage({ type: 'SHOW_INSIGHT', payload: data });
|
||||
}
|
||||
@@ -92,7 +169,10 @@ const injectInsightButton = (tweetElement: HTMLElement) => {
|
||||
const scanTweets = () => {
|
||||
const tweets = document.querySelectorAll('article[data-testid="tweet"]');
|
||||
tweets.forEach((tweet) => {
|
||||
injectInsightButton(tweet as HTMLElement);
|
||||
const el = tweet as HTMLElement;
|
||||
injectInsightButton(el);
|
||||
const data = extractTweetData(el);
|
||||
if (data) injectBadges(el, data);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
109
extension/src/options/Auth.vue
Normal file
109
extension/src/options/Auth.vue
Normal file
@@ -0,0 +1,109 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
const emit = defineEmits(['authenticated'])
|
||||
|
||||
const isLogin = ref(true)
|
||||
const isLoading = ref(false)
|
||||
const errorMsg = ref('')
|
||||
|
||||
const form = ref({
|
||||
email: '',
|
||||
password: ''
|
||||
})
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080/api/v1'
|
||||
|
||||
const submitAuth = async () => {
|
||||
if (!form.value.email || !form.value.password) {
|
||||
errorMsg.value = 'Email and password are required'
|
||||
return
|
||||
}
|
||||
|
||||
errorMsg.value = ''
|
||||
isLoading.value = true
|
||||
|
||||
const endpoint = isLogin.value ? '/auth/login' : '/users/register'
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
email: form.value.email,
|
||||
password: form.value.password
|
||||
})
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
|
||||
if (!res.ok || data.code !== 200) {
|
||||
throw new Error(data.message || 'Authentication failed')
|
||||
}
|
||||
|
||||
// Auth Success
|
||||
const token = data.data.token
|
||||
const userId = data.data.user_id || data.data.user?.id
|
||||
|
||||
chrome.storage.local.set({ jwt_token: token, user_id: userId }, () => {
|
||||
emit('authenticated', token)
|
||||
})
|
||||
|
||||
} catch (err: any) {
|
||||
errorMsg.value = err.message
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div v-if="errorMsg" class="p-3 bg-red-500/10 border border-red-500/20 text-red-400 text-sm rounded-lg">
|
||||
{{ errorMsg }}
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="submitAuth" class="space-y-4 text-left">
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-xs font-medium text-zinc-300">Email Address</label>
|
||||
<input
|
||||
v-model="form.email"
|
||||
type="email"
|
||||
required
|
||||
class="w-full bg-[#0A0A0A] border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-brand-primary/50 transition-colors"
|
||||
placeholder="your@email.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-xs font-medium text-zinc-300">Password</label>
|
||||
<input
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
required
|
||||
class="w-full bg-[#0A0A0A] border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-brand-primary/50 transition-colors"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="isLoading"
|
||||
class="w-full py-2.5 bg-brand-primary hover:bg-brand-primary/90 text-white rounded-lg text-sm font-medium transition-all shadow-lg flex items-center justify-center gap-2 mt-2 disabled:opacity-70 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span v-if="isLoading" class="w-4 h-4 border-2 border-white/20 border-t-white rounded-full animate-spin"></span>
|
||||
{{ isLoading ? 'Processing...' : (isLogin ? 'Sign In' : 'Create Account') }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="text-center mt-4 border-t border-white/10 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
@click="isLogin = !isLogin"
|
||||
class="text-xs text-zinc-400 hover:text-white transition-colors"
|
||||
>
|
||||
{{ isLogin ? "Don't have an account? Sign up" : "Already have an account? Log in" }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
192
extension/src/options/Competitors.vue
Normal file
192
extension/src/options/Competitors.vue
Normal file
@@ -0,0 +1,192 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
|
||||
const props = defineProps<{ token: string }>()
|
||||
const API_BASE = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080/api/v1'
|
||||
|
||||
const competitors = ref<Array<any>>([])
|
||||
const isLoading = ref(true)
|
||||
const isSubmitting = ref(false)
|
||||
const errorMsg = ref('')
|
||||
|
||||
const showForm = ref(false)
|
||||
const form = ref({
|
||||
competitor_name: '',
|
||||
platform: 'twitter',
|
||||
target_handle: '',
|
||||
keywords: ''
|
||||
})
|
||||
|
||||
const fetchCompetitors = async () => {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/monitors/competitors`, {
|
||||
headers: { 'Authorization': `Bearer ${props.token}` }
|
||||
})
|
||||
const data = await res.json()
|
||||
if (res.ok && data.code === 200) {
|
||||
competitors.value = data.data || []
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const createCompetitor = async () => {
|
||||
isSubmitting.value = true
|
||||
errorMsg.value = ''
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/monitors/competitors`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${props.token}`
|
||||
},
|
||||
body: JSON.stringify(form.value)
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok || data.code !== 200) {
|
||||
throw new Error(data.message || 'Failed to add competitor')
|
||||
}
|
||||
|
||||
// Reset form & reload
|
||||
form.value = { competitor_name: '', platform: 'twitter', target_handle: '', keywords: '' }
|
||||
showForm.value = false
|
||||
await fetchCompetitors()
|
||||
|
||||
} catch (err: any) {
|
||||
errorMsg.value = err.message
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const deleteCompetitor = async (id: number) => {
|
||||
if (!confirm('Are you sure you want to stop tracking this competitor?')) return
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/monitors/competitors/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Authorization': `Bearer ${props.token}` }
|
||||
})
|
||||
if (res.ok) {
|
||||
competitors.value = competitors.value.filter(c => c.id !== id)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (props.token) fetchCompetitors()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
|
||||
<div v-if="isLoading" class="p-10 flex justify-center">
|
||||
<div class="w-6 h-6 border-2 border-white/20 border-t-white rounded-full animate-spin"></div>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<p class="text-sm text-zinc-400">Track competitors tweets & mentions to find opportunistic conversations.</p>
|
||||
<button
|
||||
v-if="!showForm"
|
||||
@click="showForm = true"
|
||||
class="px-4 py-2 bg-brand-primary/20 text-brand-primary border border-brand-primary/30 rounded-lg text-xs font-semibold hover:bg-brand-primary hover:text-white transition-colors flex items-center gap-1"
|
||||
>
|
||||
+ Add Competitor
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Create Form -->
|
||||
<form v-if="showForm" @submit.prevent="createCompetitor" class="bg-[#171717] border border-white/10 rounded-xl p-6 space-y-4 mb-8">
|
||||
<div class="flex justify-between items-center mb-2 border-b border-white/10 pb-2">
|
||||
<h3 class="text-sm font-semibold text-white">Add Target Radar</h3>
|
||||
<button type="button" @click="showForm = false" class="text-zinc-500 hover:text-white">✕</button>
|
||||
</div>
|
||||
|
||||
<div v-if="errorMsg" class="p-3 bg-red-500/10 border border-red-500/20 text-red-400 text-xs rounded-lg">
|
||||
{{ errorMsg }}
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-xs font-medium text-zinc-300">Competitor Product / Name</label>
|
||||
<input v-model="form.competitor_name" required class="w-full bg-[#0A0A0A] border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-brand-primary/50" placeholder="e.g. Acme Corp" />
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-xs font-medium text-zinc-300">Platform</label>
|
||||
<select v-model="form.platform" class="w-full bg-[#0A0A0A] border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-brand-primary/50 appearance-none">
|
||||
<option value="twitter">Twitter / X</option>
|
||||
<option value="reddit">Reddit (Coming Soon)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-xs font-medium text-zinc-300">Target Handle</label>
|
||||
<div class="relative">
|
||||
<span class="absolute inset-y-0 left-0 pl-3 flex items-center text-zinc-500 pointer-events-none text-sm">@</span>
|
||||
<input v-model="form.target_handle" class="w-full bg-[#0A0A0A] border border-white/10 rounded-lg pl-8 pr-3 py-2 text-sm text-white focus:outline-none focus:border-brand-primary/50" placeholder="acmecorp" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-xs font-medium text-zinc-300">Track Keywords (Comma separated)</label>
|
||||
<input v-model="form.keywords" required class="w-full bg-[#0A0A0A] border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-brand-primary/50" placeholder="e.g. acme sucks, alternative to acme" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end pt-4">
|
||||
<button type="submit" :disabled="isSubmitting" class="px-5 py-2.5 bg-brand-primary hover:bg-brand-primary/90 text-white rounded-lg text-sm font-medium transition-all shadow flex items-center justify-center gap-2 disabled:opacity-70 disabled:cursor-not-allowed">
|
||||
<span v-if="isSubmitting" class="w-4 h-4 border-2 border-white/20 border-t-white rounded-full animate-spin"></span>
|
||||
{{ isSubmitting ? 'Saving...' : 'Start Tracking' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- List -->
|
||||
<div v-if="competitors.length === 0 && !showForm" class="text-center p-12 border border-dashed border-white/10 rounded-xl text-zinc-500">
|
||||
You are not tracking any competitors.
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div v-for="c in competitors" :key="c.id" class="bg-[#0A0A0A] border border-white/10 rounded-xl p-5 relative group hover:border-white/20 transition-colors">
|
||||
|
||||
<div class="flex justify-between items-start mb-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 rounded-full bg-white/5 flex items-center justify-center text-brand-primary">
|
||||
<svg v-if="c.platform === 'twitter'" width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M23.953 4.57a10 10 0 01-2.825.775 4.958 4.958 0 002.163-2.723c-.951.555-2.005.959-3.127 1.184a4.92 4.92 0 00-8.384 4.482C7.69 8.095 4.067 6.13 1.64 3.162a4.822 4.822 0 00-.666 2.475c0 1.71.87 3.213 2.188 4.096a4.904 4.904 0 01-2.228-.616v.06a4.923 4.923 0 003.946 4.827 4.996 4.996 0 01-2.212.085 4.936 4.936 0 004.604 3.417 9.867 9.867 0 01-6.102 2.105c-.39 0-.779-.023-1.17-.067a13.995 13.995 0 007.557 2.209c9.053 0 13.998-7.496 13.998-13.985 0-.21 0-.42-.015-.63A9.935 9.935 0 0024 4.59z"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="text-sm font-semibold text-white">{{ c.competitor_name }}</h4>
|
||||
<p v-if="c.target_handle" class="text-xs text-zinc-500">@{{ c.target_handle }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span class="px-2.5 py-1 bg-green-500/10 text-green-400 text-[10px] font-bold rounded-full border border-green-500/20 uppercase tracking-wide">Active</span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1">
|
||||
<p class="text-[10px] text-zinc-500 uppercase tracking-wider font-semibold">Keywords Targeted</p>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<span v-for="kw in c.keywords.split(',')" :key="kw" class="px-2 py-0.5 bg-zinc-800 text-zinc-300 text-[11px] rounded flex border border-zinc-700">
|
||||
{{ kw.trim() }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button @click="deleteCompetitor(c.id)" class="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity text-red-500 hover:text-red-400 p-2 rounded-lg hover:bg-red-500/10">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>
|
||||
</button>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
141
extension/src/options/HotTweets.vue
Normal file
141
extension/src/options/HotTweets.vue
Normal file
@@ -0,0 +1,141 @@
|
||||
<template>
|
||||
<div class="h-full flex flex-col p-6 space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-white tracking-tight flex items-center gap-2">
|
||||
🔥 Hot Opportunities
|
||||
</h2>
|
||||
<p class="text-zinc-400 mt-1">High-potential threads bubbling up from your monitoring radar</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="loadHotTweets"
|
||||
class="p-2 bg-white/5 border border-white/10 rounded-lg hover:bg-white/10 transition-colors"
|
||||
title="Refresh data"
|
||||
>
|
||||
<svg v-if="loading" class="w-5 h-5 text-white animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>
|
||||
<span v-else>↻</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div v-if="error" class="bg-red-500/10 border border-red-500/20 text-red-400 p-4 rounded-xl flex items-start gap-3">
|
||||
<span>⚠️</span>
|
||||
<p class="text-sm font-medium">{{ error }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else-if="!loading && tweets.length === 0" class="flex-1 flex flex-col items-center justify-center text-center p-8 bg-white/5 border border-white/10 rounded-2xl border-dashed">
|
||||
<div class="w-16 h-16 bg-white/5 rounded-full flex items-center justify-center mb-4">
|
||||
<span class="text-2xl opacity-50">🔭</span>
|
||||
</div>
|
||||
<h3 class="text-white font-medium mb-2">No hot tweets found</h3>
|
||||
<p class="text-sm text-zinc-400 max-w-sm">
|
||||
The scraper is running in the background. It takes some time and activity to build momentum heat scores. Check back later!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- List -->
|
||||
<div v-else class="grid gap-4 md:grid-cols-2">
|
||||
<div v-for="tweet in tweets" :key="tweet.id" class="p-5 bg-white/5 border border-white/10 hover:border-white/20 hover:bg-white/[0.07] transition-all rounded-xl relative group flex flex-col justify-between">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="flex justify-between items-start mb-3">
|
||||
<div class="flex flex-col">
|
||||
<span class="font-bold text-white tracking-wide">@{{ tweet.author_handle }}</span>
|
||||
<span class="text-[11px] text-zinc-500 font-mono">{{ new Date(tweet.posted_at).toLocaleString() }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 bg-rose-500/10 text-rose-400 border border-rose-500/20 px-2 py-1 rounded text-xs font-bold font-mono">
|
||||
<span>🔥</span> {{ tweet.heat_score }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<p class="text-zinc-300 text-sm leading-relaxed mb-4 line-clamp-4">
|
||||
{{ tweet.content }}
|
||||
</p>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="mt-auto flex items-center justify-between border-t border-white/5 pt-3">
|
||||
<div class="flex gap-4 text-xs text-zinc-400 font-mono">
|
||||
<span title="Replies">💬 {{ tweet.reply_count }}</span>
|
||||
<span title="Retweets">🔁 {{ tweet.retweet_count }}</span>
|
||||
<span title="Likes">❤️ {{ tweet.like_count }}</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="openTweet(tweet.author_handle, tweet.x_tweet_id)"
|
||||
class="px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-white text-xs font-medium rounded-md transition-colors"
|
||||
>
|
||||
Reply on X ↗
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
token: string
|
||||
}>()
|
||||
|
||||
interface Tweet {
|
||||
id: string
|
||||
x_tweet_id: string
|
||||
author_handle: string
|
||||
content: string
|
||||
posted_at: string
|
||||
like_count: number
|
||||
retweet_count: number
|
||||
reply_count: number
|
||||
heat_score: number
|
||||
}
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080/api/v1'
|
||||
|
||||
const tweets = ref<Tweet[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const loadHotTweets = async () => {
|
||||
if (!props.token) {
|
||||
error.value = 'Unauthenticated. Please login.'
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/tweets/hot`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${props.token}`
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load hot opportunities (${response.status})`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
tweets.value = data || []
|
||||
} catch (err: any) {
|
||||
error.value = err.message || 'Network error while fetching tweets.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const openTweet = (handle: string, tweetId: string) => {
|
||||
// Construct the active twitter URL to trigger the context script sidebar there
|
||||
const url = `https://twitter.com/${handle}/status/${tweetId}`
|
||||
chrome.tabs.create({ url })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadHotTweets()
|
||||
})
|
||||
</script>
|
||||
123
extension/src/options/Options.vue
Normal file
123
extension/src/options/Options.vue
Normal file
@@ -0,0 +1,123 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import Auth from './Auth.vue'
|
||||
import Profile from './Profile.vue'
|
||||
import Strategies from './Strategies.vue'
|
||||
import Competitors from './Competitors.vue'
|
||||
import HotTweets from './HotTweets.vue'
|
||||
|
||||
const token = ref('')
|
||||
const isLoading = ref(true)
|
||||
const activeTab = ref('profile') // 'profile', 'strategies', 'competitors'
|
||||
|
||||
onMounted(() => {
|
||||
chrome.storage.local.get(['jwt_token'], (res) => {
|
||||
if (res.jwt_token) {
|
||||
token.value = String(res.jwt_token)
|
||||
}
|
||||
isLoading.value = false
|
||||
})
|
||||
})
|
||||
|
||||
const onAuthenticated = (newToken: string) => {
|
||||
token.value = newToken
|
||||
}
|
||||
|
||||
const logout = () => {
|
||||
chrome.storage.local.remove(['jwt_token', 'user_id'], () => {
|
||||
token.value = ''
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen flex flex-col items-center pt-20 pb-10 font-sans">
|
||||
|
||||
<div class="w-full max-w-4xl px-6">
|
||||
<div class="mb-10 flex justify-between items-end border-b border-white/10 pb-4">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold tracking-tight bg-gradient-to-r from-violet-500 to-blue-500 bg-clip-text text-transparent">
|
||||
InsightReply Dashboard
|
||||
</h1>
|
||||
<p class="text-zinc-400 mt-2">Configure your custom AI strategies, product context, and monitor radar.</p>
|
||||
</div>
|
||||
<div v-if="token" class="flex items-center gap-4">
|
||||
<span class="text-sm text-green-400 flex items-center gap-2">
|
||||
<div class="w-2 h-2 rounded-full bg-green-400"></div> Connected
|
||||
</span>
|
||||
<button @click="logout" class="text-sm text-zinc-500 hover:text-white transition-colors">Sign out</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isLoading" class="flex justify-center p-20">
|
||||
<div class="w-8 h-8 border-4 border-white/20 border-t-brand-primary rounded-full animate-spin"></div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!token" class="bg-[#171717] border border-white/10 rounded-2xl p-8 max-w-md mx-auto shadow-2xl mt-10">
|
||||
<h2 class="text-xl font-semibold mb-2 text-center">Authentication Required</h2>
|
||||
<p class="text-zinc-400 text-sm mb-6 text-center">Log in to link your InsightReply account.</p>
|
||||
|
||||
<Auth @authenticated="onAuthenticated" />
|
||||
</div>
|
||||
|
||||
<div v-else class="flex gap-8 items-start">
|
||||
|
||||
<!-- Sidebar Menu -->
|
||||
<div class="w-64 flex flex-col gap-2 shrink-0">
|
||||
<button
|
||||
@click="activeTab = 'profile'"
|
||||
:class="['text-left px-4 py-3 rounded-lg text-sm font-medium transition-colors', activeTab === 'profile' ? 'bg-white/10 text-white' : 'text-zinc-400 hover:bg-white/5 hover:text-zinc-300']"
|
||||
>
|
||||
Product Profile
|
||||
</button>
|
||||
<button
|
||||
@click="activeTab = 'strategies'"
|
||||
:class="['text-left px-4 py-3 rounded-lg text-sm font-medium transition-colors', activeTab === 'strategies' ? 'bg-white/10 text-white' : 'text-zinc-400 hover:bg-white/5 hover:text-zinc-300']"
|
||||
>
|
||||
Custom Strategies
|
||||
</button>
|
||||
<button
|
||||
@click="activeTab = 'competitors'"
|
||||
:class="['text-left px-4 py-3 rounded-lg text-sm font-medium transition-colors', activeTab === 'competitors' ? 'bg-white/10 text-white' : 'text-zinc-400 hover:bg-white/5 hover:text-zinc-300']"
|
||||
>
|
||||
Competitor Radar
|
||||
</button>
|
||||
|
||||
<div class="h-px bg-white/10 my-2"></div>
|
||||
|
||||
<button
|
||||
@click="activeTab = 'hottweets'"
|
||||
:class="['text-left px-4 py-3 rounded-lg text-sm font-medium transition-colors flex items-center justify-between', activeTab === 'hottweets' ? 'bg-orange-500/20 text-orange-400 border border-orange-500/30' : 'text-zinc-400 hover:bg-white/5 hover:text-zinc-300']"
|
||||
>
|
||||
<span>🔥 Opportunities</span>
|
||||
<span v-if="activeTab !== 'hottweets'" class="w-2 h-2 rounded-full bg-orange-500 animate-pulse"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Main Content Area -->
|
||||
<div class="flex-1 bg-[#171717] border border-white/10 rounded-2xl p-8 shadow-xl min-h-[500px]">
|
||||
<div v-show="activeTab === 'profile'" class="animate-in fade-in duration-300">
|
||||
<h2 class="text-lg font-semibold mb-6 border-b border-white/10 pb-4">Product Profile Configuration</h2>
|
||||
<Profile :token="token" />
|
||||
</div>
|
||||
|
||||
<div v-show="activeTab === 'strategies'" class="animate-in fade-in duration-300">
|
||||
<h2 class="text-lg font-semibold mb-6 border-b border-white/10 pb-4">Custom Generation Strategies</h2>
|
||||
<Strategies :token="token" />
|
||||
</div>
|
||||
|
||||
<div v-show="activeTab === 'competitors'" class="animate-in fade-in duration-300">
|
||||
<h2 class="text-lg font-semibold mb-6 border-b border-white/10 pb-4">Competitor Monitoring</h2>
|
||||
<Competitors :token="token" />
|
||||
</div>
|
||||
|
||||
<div v-show="activeTab === 'hottweets'" class="animate-in fade-in duration-300 h-full">
|
||||
<HotTweets :token="token" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
215
extension/src/options/Profile.vue
Normal file
215
extension/src/options/Profile.vue
Normal file
@@ -0,0 +1,215 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
|
||||
const props = defineProps<{ token: string }>()
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080/api/v1'
|
||||
|
||||
const isLoading = ref(true)
|
||||
const isSaving = ref(false)
|
||||
const savedMessage = ref('')
|
||||
const errorMsg = ref('')
|
||||
|
||||
// Default LLM Options based on the API docs GET /sys/config/llms (mocking for now as we didn't build that API fully)
|
||||
const providers = ['openai', 'anthropic', 'gemini', 'deepseek']
|
||||
|
||||
const form = ref({
|
||||
product_name: '',
|
||||
industry: '',
|
||||
target_audience: '',
|
||||
core_features: '',
|
||||
user_intent: '',
|
||||
relevance_keywords: '',
|
||||
default_llm_provider: '',
|
||||
default_llm_model: ''
|
||||
})
|
||||
|
||||
const fetchProfile = async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/users/me/product_profiles`, {
|
||||
headers: { 'Authorization': `Bearer ${props.token}` }
|
||||
})
|
||||
const data = await res.json()
|
||||
if (res.ok && data.code === 200 && data.data) {
|
||||
const p = data.data
|
||||
form.value = {
|
||||
product_name: p.product_name || '',
|
||||
industry: p.industry || '',
|
||||
target_audience: p.target_audience || '',
|
||||
core_features: p.core_features || '',
|
||||
user_intent: p.user_intent || '',
|
||||
relevance_keywords: p.relevance_keywords || '',
|
||||
default_llm_provider: p.default_llm_provider || '',
|
||||
default_llm_model: p.default_llm_model || ''
|
||||
}
|
||||
|
||||
// Sync relevance keywords to local storage for Content Script
|
||||
chrome.storage.local.set({ relevance_keywords: form.value.relevance_keywords })
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Failed to load profile:', err)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const saveProfile = async () => {
|
||||
isSaving.value = true
|
||||
savedMessage.value = ''
|
||||
errorMsg.value = ''
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/users/me/product_profiles`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${props.token}`
|
||||
},
|
||||
body: JSON.stringify(form.value)
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
|
||||
if (!res.ok || data.code !== 200) {
|
||||
throw new Error(data.message || 'Failed to save profile')
|
||||
}
|
||||
|
||||
// Sync relevance keywords to local storage for Content Script
|
||||
chrome.storage.local.set({ relevance_keywords: form.value.relevance_keywords })
|
||||
|
||||
savedMessage.value = 'Profile saved successfully!'
|
||||
setTimeout(() => { savedMessage.value = '' }, 3000)
|
||||
|
||||
} catch (err: any) {
|
||||
errorMsg.value = err.message
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (props.token) {
|
||||
fetchProfile()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="isLoading" class="p-10 flex justify-center">
|
||||
<div class="w-6 h-6 border-2 border-white/20 border-t-white rounded-full animate-spin"></div>
|
||||
</div>
|
||||
|
||||
<form v-else @submit.prevent="saveProfile" class="space-y-6">
|
||||
<div v-if="errorMsg" class="p-3 bg-red-500/10 border border-red-500/20 text-red-400 text-sm rounded-lg">
|
||||
{{ errorMsg }}
|
||||
</div>
|
||||
<div v-if="savedMessage" class="p-3 bg-green-500/10 border border-green-500/20 text-green-400 text-sm rounded-lg">
|
||||
{{ savedMessage }}
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-6">
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-xs font-medium text-zinc-300">Product Name</label>
|
||||
<input
|
||||
v-model="form.product_name"
|
||||
type="text"
|
||||
required
|
||||
class="w-full bg-[#0A0A0A] border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-brand-primary/50 transition-colors"
|
||||
placeholder="e.g. SwiftBiu"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-xs font-medium text-zinc-300">Industry / Niche</label>
|
||||
<input
|
||||
v-model="form.industry"
|
||||
type="text"
|
||||
class="w-full bg-[#0A0A0A] border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-brand-primary/50 transition-colors"
|
||||
placeholder="e.g. AI Tools, Developer Productivity"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-xs font-medium text-zinc-300">Target Audience</label>
|
||||
<input
|
||||
v-model="form.target_audience"
|
||||
type="text"
|
||||
class="w-full bg-[#0A0A0A] border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-brand-primary/50 transition-colors"
|
||||
placeholder="e.g. Indie hackers building on MacOS"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-xs font-medium text-zinc-300 flex items-center gap-2">
|
||||
Core Features
|
||||
<span class="text-[10px] text-zinc-500 font-normal">What does your product do?</span>
|
||||
</label>
|
||||
<textarea
|
||||
v-model="form.core_features"
|
||||
rows="3"
|
||||
class="w-full bg-[#0A0A0A] border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-brand-primary/50 transition-colors resize-none"
|
||||
placeholder="e.g. One-click screenshot translation, local OCR, native macOS support..."
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-xs font-medium text-zinc-300 flex items-center gap-2">
|
||||
User Intent / Pitch Angle
|
||||
<span class="text-[10px] text-zinc-500 font-normal">How do you usually reply to people?</span>
|
||||
</label>
|
||||
<textarea
|
||||
v-model="form.user_intent"
|
||||
rows="2"
|
||||
class="w-full bg-[#0A0A0A] border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-brand-primary/50 transition-colors resize-none"
|
||||
placeholder="e.g. I generally want to help people solve translation problems and mention that SwiftBiu is a fast native solution."
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-xs font-medium text-zinc-300">Relevance Keywords (Comma separated)</label>
|
||||
<input
|
||||
v-model="form.relevance_keywords"
|
||||
type="text"
|
||||
class="w-full bg-[#0A0A0A] border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-brand-primary/50 transition-colors"
|
||||
placeholder="e.g. translation, OCR, macos app, bob alternative"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="pt-4 border-t border-white/10 pb-2">
|
||||
<h3 class="text-sm font-medium text-zinc-300 mb-4">Advanced: Preferred LLM Override</h3>
|
||||
<div class="grid grid-cols-2 gap-6">
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-xs font-medium text-zinc-400">Provider</label>
|
||||
<select
|
||||
v-model="form.default_llm_provider"
|
||||
class="w-full bg-[#0A0A0A] border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-brand-primary/50 appearance-none"
|
||||
>
|
||||
<option value="">System Default (Recommended)</option>
|
||||
<option v-for="p in providers" :key="p" :value="p">{{ p.charAt(0).toUpperCase() + p.slice(1) }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-xs font-medium text-zinc-400">Model Name</label>
|
||||
<input
|
||||
v-model="form.default_llm_model"
|
||||
type="text"
|
||||
class="w-full bg-[#0A0A0A] border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-brand-primary/50 transition-colors"
|
||||
placeholder="e.g. gpt-4o, claude-3-5-sonnet"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pt-4 flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="isSaving"
|
||||
class="px-6 py-2.5 bg-brand-primary hover:bg-brand-primary/90 text-white rounded-lg text-sm font-medium transition-all shadow-lg flex items-center justify-center gap-2 disabled:opacity-70 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span v-if="isSaving" class="w-4 h-4 border-2 border-white/20 border-t-white rounded-full animate-spin"></span>
|
||||
{{ isSaving ? 'Saving...' : 'Save Profile' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
162
extension/src/options/Strategies.vue
Normal file
162
extension/src/options/Strategies.vue
Normal file
@@ -0,0 +1,162 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
|
||||
const props = defineProps<{ token: string }>()
|
||||
const API_BASE = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080/api/v1'
|
||||
|
||||
const strategies = ref<Array<any>>([])
|
||||
const isLoading = ref(true)
|
||||
const isSubmitting = ref(false)
|
||||
const errorMsg = ref('')
|
||||
|
||||
const showForm = ref(false)
|
||||
const form = ref({
|
||||
strategy_key: '',
|
||||
label: '',
|
||||
description: ''
|
||||
})
|
||||
|
||||
const fetchStrategies = async () => {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/users/me/strategies`, {
|
||||
headers: { 'Authorization': `Bearer ${props.token}` }
|
||||
})
|
||||
const data = await res.json()
|
||||
if (res.ok && data.code === 200) {
|
||||
strategies.value = data.data || []
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const createStrategy = async () => {
|
||||
isSubmitting.value = true
|
||||
errorMsg.value = ''
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/users/me/strategies`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${props.token}`
|
||||
},
|
||||
body: JSON.stringify(form.value)
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok || data.code !== 200) {
|
||||
throw new Error(data.message || 'Failed to create strategy')
|
||||
}
|
||||
|
||||
// Reset form & reload
|
||||
form.value = { strategy_key: '', label: '', description: '' }
|
||||
showForm.value = false
|
||||
await fetchStrategies()
|
||||
|
||||
} catch (err: any) {
|
||||
errorMsg.value = err.message
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const deleteStrategy = async (id: number) => {
|
||||
if (!confirm('Are you sure you want to delete this custom strategy?')) return
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/users/me/strategies/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Authorization': `Bearer ${props.token}` }
|
||||
})
|
||||
if (res.ok) {
|
||||
strategies.value = strategies.value.filter(s => s.id !== id)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (props.token) fetchStrategies()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
|
||||
<div v-if="isLoading" class="p-10 flex justify-center">
|
||||
<div class="w-6 h-6 border-2 border-white/20 border-t-white rounded-full animate-spin"></div>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<p class="text-sm text-zinc-400">These custom prompt angles will appear dynamically in your Twitter sidebar.</p>
|
||||
<button
|
||||
v-if="!showForm"
|
||||
@click="showForm = true"
|
||||
class="px-4 py-2 bg-brand-primary/20 text-brand-primary border border-brand-primary/30 rounded-lg text-xs font-semibold hover:bg-brand-primary hover:text-white transition-colors flex items-center gap-1"
|
||||
>
|
||||
+ Create Custom Strategy
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Create Form -->
|
||||
<form v-if="showForm" @submit.prevent="createStrategy" class="bg-white/5 border border-white/10 rounded-xl p-6 space-y-4 mb-8">
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<h3 class="text-sm font-semibold text-white">New Strategy</h3>
|
||||
<button type="button" @click="showForm = false" class="text-zinc-500 hover:text-white">✕</button>
|
||||
</div>
|
||||
|
||||
<div v-if="errorMsg" class="p-3 bg-red-500/10 border border-red-500/20 text-red-400 text-xs rounded-lg">
|
||||
{{ errorMsg }}
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-[11px] font-medium text-zinc-400 uppercase tracking-widest">Internal ID</label>
|
||||
<input v-model="form.strategy_key" required class="w-full bg-[#0A0A0A] border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-brand-primary/50" placeholder="e.g. startup_pitch" />
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-[11px] font-medium text-zinc-400 uppercase tracking-widest">Display Label (Sidebar)</label>
|
||||
<input v-model="form.label" required class="w-full bg-[#0A0A0A] border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-brand-primary/50" placeholder="e.g. Startup Pitch" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-[11px] font-medium text-zinc-400 uppercase tracking-widest">Prompt Instructions</label>
|
||||
<textarea v-model="form.description" required rows="3" class="w-full bg-[#0A0A0A] border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-brand-primary/50 resize-none" placeholder="Instruct the AI exactly how to respond using this strategy. e.g. Be concise, act like a VC, ask challenging questions."></textarea>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end pt-2">
|
||||
<button type="submit" :disabled="isSubmitting" class="px-5 py-2 bg-brand-primary hover:bg-brand-primary/90 text-white rounded-lg text-sm font-medium transition-all shadow-lg flex items-center justify-center gap-2 disabled:opacity-70 disabled:cursor-not-allowed">
|
||||
<span v-if="isSubmitting" class="w-3 h-3 border-2 border-white/20 border-t-white rounded-full animate-spin"></span>
|
||||
{{ isSubmitting ? 'Saving...' : 'Add Strategy' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- List -->
|
||||
<div v-if="strategies.length === 0 && !showForm" class="text-center p-12 border border-dashed border-white/10 rounded-xl text-zinc-500">
|
||||
You haven't created any custom strategies yet.
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div v-for="s in strategies" :key="s.id" class="bg-white/5 border border-white/10 rounded-xl p-5 flex justify-between items-start group hover:border-white/20 transition-colors">
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-sm font-semibold text-white">{{ s.label }}</span>
|
||||
<span class="text-[10px] font-mono text-brand-primary bg-brand-primary/10 px-2 py-0.5 rounded-full">{{ s.strategy_key }}</span>
|
||||
</div>
|
||||
<p class="text-xs text-zinc-400 leading-relaxed max-w-2xl">{{ s.description }}</p>
|
||||
</div>
|
||||
<button @click="deleteStrategy(s.id)" class="opacity-0 group-hover:opacity-100 transition-opacity text-red-500 hover:text-red-400 p-2 rounded-lg hover:bg-red-500/10">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
5
extension/src/options/main.ts
Normal file
5
extension/src/options/main.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { createApp } from 'vue'
|
||||
import '../style.css' // Reuse Tailwind CSS
|
||||
import Options from './Options.vue'
|
||||
|
||||
createApp(Options).mount('#app')
|
||||
Reference in New Issue
Block a user