191 lines
7.3 KiB
Vue
191 lines
7.3 KiB
Vue
<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>
|