feat: 部署初版测试
This commit is contained in:
149
web/src/views/Dashboard.vue
Normal file
149
web/src/views/Dashboard.vue
Normal file
@@ -0,0 +1,149 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
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(true)
|
||||
const error = ref('')
|
||||
|
||||
const loadHotTweets = async () => {
|
||||
const token = localStorage.getItem('jwt_token')
|
||||
if (!token) {
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/tweets/hot`, {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
})
|
||||
|
||||
if (response.status === 401) {
|
||||
localStorage.removeItem('jwt_token')
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
|
||||
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) => {
|
||||
window.open(`https://twitter.com/${handle}/status/${tweetId}`, '_blank')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadHotTweets()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="max-w-[1400px] mx-auto space-y-8 animate-fade-in-up">
|
||||
<!-- Header -->
|
||||
<header class="flex justify-between items-end pb-4 border-b border-white/5">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold tracking-tight text-white flex items-center gap-3">
|
||||
🔥 Pipeline
|
||||
<span class="text-sm font-medium px-2.5 py-1 bg-brand-primary/20 text-brand-primary rounded-full">{{ tweets.length }} leads</span>
|
||||
</h1>
|
||||
<p class="text-zinc-500 mt-2">Prioritized X threads bubbling up from your monitoring radar.</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="loadHotTweets"
|
||||
class="px-4 py-2 bg-white/5 border border-white/10 text-white rounded-lg hover:bg-white/10 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<span v-if="loading" class="animate-spin inline-block">↻</span>
|
||||
<span v-else>↻ Refresh</span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<!-- 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-12 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-xl text-white font-medium mb-2">No hot tweets found</h3>
|
||||
<p class="text-sm text-zinc-400 max-w-sm">
|
||||
The scraper is building radar momentum. Check back later to see rising opportunities.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Pinterest Style Grid -->
|
||||
<div v-else class="columns-1 md:columns-2 lg:columns-3 xl:columns-4 gap-6 space-y-6">
|
||||
<div
|
||||
v-for="tweet in tweets"
|
||||
:key="tweet.id"
|
||||
class="break-inside-avoid glass-panel p-5 relative group flex flex-col transition-all duration-300 hover:border-brand-primary/50 hover:shadow-[0_10px_40px_rgba(59,130,246,0.1)] hover:-translate-y-1"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex justify-between items-start mb-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-full bg-gradient-to-br from-zinc-700 to-zinc-800 flex items-center justify-center font-bold text-white shadow-inner">
|
||||
{{ tweet.author_handle.charAt(0).toUpperCase() }}
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<span class="font-bold text-white tracking-wide text-sm">@{{ tweet.author_handle }}</span>
|
||||
<span class="text-[11px] text-zinc-500 font-mono">{{ new Date(tweet.posted_at).toLocaleDateString() }}</span>
|
||||
</div>
|
||||
</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-full text-xs font-bold font-mono shadow-sm">
|
||||
<span>🔥</span> {{ tweet.heat_score }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<p class="text-zinc-300 text-sm leading-relaxed mb-6">
|
||||
{{ tweet.content }}
|
||||
</p>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="mt-auto flex items-center justify-between border-t border-white/5 pt-4">
|
||||
<div class="flex gap-4 text-xs text-zinc-400 font-mono">
|
||||
<span title="Replies" class="flex items-center gap-1"><span class="opacity-50">💬</span> {{ tweet.reply_count }}</span>
|
||||
<span title="Retweets" class="flex items-center gap-1"><span class="opacity-50">🔁</span> {{ tweet.retweet_count }}</span>
|
||||
<span title="Likes" class="flex items-center gap-1"><span class="opacity-50">❤️</span> {{ tweet.like_count }}</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="openTweet(tweet.author_handle, tweet.x_tweet_id)"
|
||||
class="px-4 py-1.5 bg-white/5 hover:bg-brand-primary text-white text-xs font-medium rounded-lg transition-all duration-300 border border-white/10 hover:border-transparent hover:shadow-lg hover:shadow-brand-primary/20"
|
||||
>
|
||||
Reply ↗
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
116
web/src/views/History.vue
Normal file
116
web/src/views/History.vue
Normal file
@@ -0,0 +1,116 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
|
||||
interface GeneratedReply {
|
||||
id: string
|
||||
tweet_id: string
|
||||
strategy_type: string
|
||||
content: string
|
||||
status: string
|
||||
created_at: string
|
||||
// Assuming a join or performance data is available, for now, we'll mock metrics or show pending status
|
||||
}
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080/api/v1'
|
||||
const replies = ref<GeneratedReply[]>([])
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
|
||||
const loadHistory = async () => {
|
||||
const token = localStorage.getItem('jwt_token')
|
||||
if (!token) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/replies`, {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
})
|
||||
if (!res.ok) throw new Error('Failed to fetch history')
|
||||
replies.value = await res.json()
|
||||
} catch (err: any) {
|
||||
error.value = err.message
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'copied': return 'bg-yellow-500/20 text-yellow-400 border-yellow-500/20'
|
||||
case 'posted': return 'bg-green-500/20 text-green-400 border-green-500/20'
|
||||
default: return 'bg-zinc-500/20 text-zinc-400 border-zinc-500/20'
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadHistory()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="max-w-[1000px] mx-auto space-y-8 animate-fade-in-up">
|
||||
<!-- Header -->
|
||||
<header class="flex justify-between items-end pb-4 border-b border-white/5">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold tracking-tight text-white flex items-center gap-3">
|
||||
⏱️ AI Generation History
|
||||
<span class="text-sm font-medium px-2.5 py-1 bg-brand-primary/20 text-brand-primary rounded-full">{{ replies.length }} Entries</span>
|
||||
</h1>
|
||||
<p class="text-zinc-500 mt-2">Track the live performance of your automated X replies.</p>
|
||||
</div>
|
||||
<button
|
||||
@click="loadHistory"
|
||||
class="px-4 py-2 bg-white/5 border border-white/10 text-white rounded-lg hover:bg-white/10 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<span v-if="loading" class="animate-spin inline-block">↻</span>
|
||||
<span v-else>↻ Refresh</span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div v-if="loading" class="text-zinc-500 py-12 text-center">Reconstructing timeline...</div>
|
||||
|
||||
<!-- Timeline List -->
|
||||
<div v-else-if="replies.length > 0" class="space-y-4">
|
||||
<div
|
||||
v-for="r in replies"
|
||||
:key="r.id"
|
||||
class="glass-panel p-5 border border-white/10 hover:border-brand-primary/30 transition-all flex flex-col gap-3"
|
||||
>
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-xs font-bold font-mono tracking-wider bg-brand-primary/10 text-brand-primary px-2 py-1 rounded">
|
||||
{{ r.strategy_type }}
|
||||
</span>
|
||||
<span class="text-xs text-zinc-500 border border-white/10 px-2 py-1 rounded-full border-dashed">
|
||||
Target ID: <span class="font-mono text-zinc-400">{{ r.tweet_id.substring(0,8) }}...</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-[11px] text-zinc-500 font-mono">{{ new Date(r.created_at).toLocaleString() }}</span>
|
||||
<span :class="`border px-2 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wider ${getStatusBadge(r.status)}`">
|
||||
{{ r.status }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-zinc-300 text-sm leading-relaxed border-l-2 border-brand-primary/20 pl-4 py-1 italic bg-white/[0.02] rounded-r-lg">
|
||||
"{{ r.content }}"
|
||||
</p>
|
||||
|
||||
<div class="flex items-center justify-between border-t border-white/5 pt-3 mt-1">
|
||||
<div class="flex gap-6 text-xs text-zinc-500 font-mono">
|
||||
<span class="flex items-center gap-1">♥️ <span class="text-zinc-300">Evaluating in 24h</span></span>
|
||||
<span class="flex items-center gap-1">💬 <span class="text-zinc-300">Pending Worker</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else class="flex flex-col items-center justify-center py-20 bg-white/5 border border-white/10 rounded-2xl border-dashed">
|
||||
<span class="text-4xl mb-4 opacity-50">📝</span>
|
||||
<h3 class="text-lg font-medium text-white mb-2">No generations yet</h3>
|
||||
<p class="text-zinc-400 text-sm max-w-sm text-center">Copies made from the browser extension will be logged here autonomously for performance tracking.</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
66
web/src/views/Login.vue
Normal file
66
web/src/views/Login.vue
Normal file
@@ -0,0 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Activity } from 'lucide-vue-next'
|
||||
|
||||
const router = useRouter()
|
||||
const email = ref('')
|
||||
const password = ref('')
|
||||
const errorMsg = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
const handleLogin = async () => {
|
||||
errorMsg.value = ''
|
||||
loading.value = true
|
||||
|
||||
const apiBase = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080/api/v1'
|
||||
|
||||
try {
|
||||
const res = await fetch(`${apiBase}/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: email.value, password: password.value })
|
||||
})
|
||||
|
||||
if (!res.ok) throw new Error('Invalid credentials')
|
||||
|
||||
const data = await res.json()
|
||||
if (data.token) {
|
||||
localStorage.setItem('jwt_token', data.token)
|
||||
router.push('/dashboard')
|
||||
}
|
||||
} catch (err: any) {
|
||||
errorMsg.value = err.message || 'Login failed'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div class="h-screen w-full flex items-center justify-center relative bg-[#0B1120]">
|
||||
<div class="glass-panel w-full max-w-sm p-8 z-10 flex flex-col items-center">
|
||||
<div class="w-12 h-12 rounded-xl bg-gradient-to-br from-brand-primary to-blue-600 flex items-center justify-center mb-6">
|
||||
<Activity color="white" :size="24" />
|
||||
</div>
|
||||
<h2 class="text-2xl font-bold text-white mb-2">Welcome Back</h2>
|
||||
<p class="text-zinc-400 text-sm text-center mb-8">Sign in to orchestrate your AI reply strategy.</p>
|
||||
|
||||
<form @submit.prevent="handleLogin" class="w-full space-y-4">
|
||||
<div v-if="errorMsg" class="bg-red-500/10 text-red-400 p-3 rounded-lg text-sm text-center">
|
||||
{{ errorMsg }}
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-zinc-500 uppercase tracking-widest block mb-2">Email</label>
|
||||
<input v-model="email" type="email" required placeholder="founder@startup.com" class="w-full bg-black/20 border border-white/10 rounded-lg px-4 py-3 text-white placeholder-zinc-600 focus:outline-none focus:border-brand-primary/50 focus:ring-1 focus:ring-brand-primary/50 transition-all cursor-text pointer-events-auto" style="pointer-events: auto;" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-zinc-500 uppercase tracking-widest block mb-2">Password</label>
|
||||
<input v-model="password" type="password" required placeholder="••••••••" class="w-full bg-black/20 border border-white/10 rounded-lg px-4 py-3 text-white placeholder-zinc-600 focus:outline-none focus:border-brand-primary/50 focus:ring-1 focus:ring-brand-primary/50 transition-all cursor-text pointer-events-auto" style="pointer-events: auto;" />
|
||||
</div>
|
||||
<button type="submit" :disabled="loading" class="w-full bg-brand-primary hover:bg-blue-500 disabled:opacity-50 text-white font-medium rounded-lg px-4 py-3 mt-4 transition-all hover:shadow-[0_0_20px_rgba(59,130,246,0.3)] duration-300">
|
||||
{{ loading ? 'Signing In...' : 'Sign In' }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
190
web/src/views/Radar.vue
Normal file
190
web/src/views/Radar.vue
Normal file
@@ -0,0 +1,190 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
|
||||
interface Monitor {
|
||||
id: string
|
||||
brand_name: string
|
||||
x_handle: string
|
||||
is_active: boolean
|
||||
created_at: string
|
||||
}
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080/api/v1'
|
||||
const monitors = ref<Monitor[]>([])
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
|
||||
const newKeyword = ref('')
|
||||
const newHandle = ref('')
|
||||
const adding = ref(false)
|
||||
|
||||
const loadMonitors = async () => {
|
||||
const token = localStorage.getItem('jwt_token')
|
||||
if (!token) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/monitors`, {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
})
|
||||
if (!res.ok) throw new Error('Failed to fetch monitors')
|
||||
monitors.value = await res.json()
|
||||
} catch (err: any) {
|
||||
error.value = err.message
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const addMonitor = async () => {
|
||||
if (!newKeyword.value && !newHandle.value) return
|
||||
|
||||
const token = localStorage.getItem('jwt_token')
|
||||
adding.value = true
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/monitors`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
brand_name: newKeyword.value,
|
||||
x_handle: newHandle.value.replace('@', '')
|
||||
})
|
||||
})
|
||||
|
||||
if (!res.ok) throw new Error('Failed to add target')
|
||||
|
||||
newKeyword.value = ''
|
||||
newHandle.value = ''
|
||||
await loadMonitors()
|
||||
} catch (err: any) {
|
||||
alert(err.message)
|
||||
} finally {
|
||||
adding.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const toggleMonitor = async (m: Monitor) => {
|
||||
const token = localStorage.getItem('jwt_token')
|
||||
try {
|
||||
await fetch(`${API_BASE}/monitors/${m.id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({ is_active: !m.is_active })
|
||||
})
|
||||
m.is_active = !m.is_active
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
|
||||
const deleteMonitor = async (id: string) => {
|
||||
if (!confirm('Remove this radar target?')) return
|
||||
const token = localStorage.getItem('jwt_token')
|
||||
try {
|
||||
await fetch(`${API_BASE}/monitors/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
})
|
||||
monitors.value = monitors.value.filter(m => m.id !== id)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadMonitors()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="max-w-[1400px] mx-auto space-y-8 animate-fade-in-up">
|
||||
<!-- Header & Add Form -->
|
||||
<header class="flex flex-col md:flex-row md:justify-between items-start md:items-end gap-6 pb-6 border-b border-white/5">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold tracking-tight text-white flex items-center gap-3">
|
||||
📡 X Radar Targets
|
||||
<span class="text-sm font-medium px-2.5 py-1 bg-indigo-500/20 text-indigo-400 rounded-full">{{ monitors.length }} Active</span>
|
||||
</h1>
|
||||
<p class="text-zinc-500 mt-2 text-sm max-w-lg">
|
||||
Configure Nitter scraper targets. The engine will autonomously monitor these accounts and keywords for high-potential engagement opportunities.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="addMonitor" class="flex items-center gap-3 bg-white/5 p-2 rounded-xl border border-white/10 w-full md:w-auto">
|
||||
<input
|
||||
v-model="newHandle"
|
||||
placeholder="X Handle (e.g. elonmusk)"
|
||||
class="bg-black/20 text-white text-sm rounded-lg px-3 py-2 border border-white/10 w-40 focus:outline-none focus:border-brand-primary/50 pointer-events-auto" style="pointer-events: auto;"
|
||||
/>
|
||||
<span class="text-zinc-600 text-sm font-bold">+</span>
|
||||
<input
|
||||
v-model="newKeyword"
|
||||
placeholder="Keyword Combo (Optional)"
|
||||
class="bg-black/20 text-white text-sm rounded-lg px-3 py-2 border border-white/10 w-48 focus:outline-none focus:border-brand-primary/50 pointer-events-auto" style="pointer-events: auto;"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="adding || (!newKeyword && !newHandle)"
|
||||
class="bg-brand-primary hover:bg-blue-500 disabled:opacity-50 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors whitespace-nowrap"
|
||||
>
|
||||
Add Target
|
||||
</button>
|
||||
</form>
|
||||
</header>
|
||||
|
||||
<div v-if="loading" class="text-zinc-500 py-12 text-center">Loading radar grid...</div>
|
||||
|
||||
<!-- Active Grid -->
|
||||
<div v-else-if="monitors.length > 0" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
<div
|
||||
v-for="m in monitors"
|
||||
:key="m.id"
|
||||
class="glass-panel p-5 border border-white/10 relative group transition-all"
|
||||
:class="m.is_active ? 'hover:border-indigo-500/30' : 'opacity-60 grayscale'"
|
||||
>
|
||||
<div class="flex justify-between items-start mb-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-full bg-gradient-to-br from-indigo-500/20 to-purple-600/20 border border-indigo-500/20 flex items-center justify-center font-bold text-indigo-400">
|
||||
{{ m.x_handle ? m.x_handle.charAt(0).toUpperCase() : '#' }}
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<span class="font-bold text-white tracking-wide text-sm truncate max-w-[120px]" :title="m.x_handle || 'Global Search'">
|
||||
{{ m.x_handle ? '@' + m.x_handle : 'Global Search' }}
|
||||
</span>
|
||||
<span class="text-xs text-zinc-500">Target</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toggle -->
|
||||
<button @click="toggleMonitor(m)" class="relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none" :class="m.is_active ? 'bg-indigo-500' : 'bg-white/10'">
|
||||
<span aria-hidden="true" class="pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out" :class="m.is_active ? 'translate-x-4' : 'translate-x-0'"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="bg-black/20 rounded-lg p-3 border border-white/5 mb-4">
|
||||
<span class="text-xs font-semibold text-zinc-500 uppercase tracking-widest block mb-1">Intersection Keyword</span>
|
||||
<span class="text-sm text-zinc-300 font-mono">{{ m.brand_name || '— (All Posts)' }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between items-center border-t border-white/5 pt-4 mt-auto">
|
||||
<span class="text-[10px] text-zinc-500 font-mono">{{ new Date(m.created_at).toLocaleDateString() }}</span>
|
||||
<button @click="deleteMonitor(m.id)" class="text-xs text-red-400/50 hover:text-red-400 transition-colors pointer-events-auto" style="pointer-events: auto;">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else class="flex flex-col items-center justify-center py-20 bg-white/5 border border-white/10 rounded-2xl border-dashed">
|
||||
<span class="text-4xl mb-4 opacity-50">🛰️</span>
|
||||
<h3 class="text-lg font-medium text-white mb-2">Radar is offline</h3>
|
||||
<p class="text-zinc-400 text-sm max-w-sm text-center">Add a competitor's X handle or an intersection keyword to initialize the autonomous scraping engine.</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user