feat: 部署初版测试
Some checks failed
Extension Build & Release / build (push) Failing after 1m5s
Backend Deploy (Go + Docker) / deploy (push) Failing after 1m40s
Web Console Deploy (Vue 3 + Vite) / deploy (push) Has been cancelled

This commit is contained in:
zs
2026-03-02 21:25:21 +08:00
parent db3abb3174
commit 8cf6cb944b
97 changed files with 10250 additions and 209 deletions

1
web/.env Normal file
View File

@@ -0,0 +1 @@
VITE_API_BASE_URL=http://insight.buildapp.eu.org/api/v1

2
web/.env.production Normal file
View File

@@ -0,0 +1,2 @@
# Handled by Nginx reverse proxy in production
VITE_API_BASE_URL=/api/v1

24
web/.gitignore vendored Normal file
View File

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

3
web/.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

5
web/README.md Normal file
View File

@@ -0,0 +1,5 @@
# Vue 3 + TypeScript + Vite
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).

13
web/index.html Normal file
View File

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

2973
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

29
web/package.json Normal file
View File

@@ -0,0 +1,29 @@
{
"name": "web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.13.6",
"lucide-vue-next": "^0.575.0",
"pinia": "^3.0.4",
"vue": "^3.5.25",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@types/node": "^24.10.1",
"@vitejs/plugin-vue": "^6.0.2",
"@vue/tsconfig": "^0.8.1",
"autoprefixer": "^10.4.27",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.19",
"typescript": "~5.9.3",
"vite": "^7.3.1",
"vue-tsc": "^3.1.5"
}
}

6
web/postcss.config.js Normal file
View File

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

1
web/public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

78
web/src/App.vue Normal file
View File

@@ -0,0 +1,78 @@
<script setup lang="ts">
import { useRoute } from 'vue-router'
import { Activity, RadioReceiver, Clock, Settings, LogOut } from 'lucide-vue-next'
const route = useRoute()
const navItems = [
{ name: 'Dashboard', path: '/dashboard', icon: Activity },
{ name: 'Radar', path: '/radar', icon: RadioReceiver },
{ name: 'History', path: '/history', icon: Clock }
]
</script>
<template>
<div class="flex h-screen w-full bg-[#0B1120] text-zinc-300 font-sans overflow-hidden">
<!-- Sidebar -->
<aside v-if="route.path !== '/login'" class="w-64 border-r border-white/10 bg-[#0F172A] flex flex-col shrink-0">
<div class="p-6 flex items-center gap-3">
<div class="w-8 h-8 rounded-lg bg-gradient-to-br from-brand-primary to-blue-600 flex items-center justify-center shrink-0">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2"><path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/></svg>
</div>
<span class="font-bold text-lg text-white tracking-wide">InsightReply</span>
</div>
<nav class="flex-1 px-4 py-6 space-y-2">
<router-link
v-for="item in navItems"
:key="item.path"
:to="item.path"
class="flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-200"
:class="[
route.path === item.path
? 'bg-brand-primary/10 text-brand-primary font-medium'
: 'text-zinc-400 hover:text-white hover:bg-white/5'
]"
>
<component :is="item.icon" :size="18" :stroke-width="route.path === item.path ? 2.5 : 2" />
{{ item.name }}
</router-link>
</nav>
<div class="p-4 border-t border-white/10">
<button class="flex items-center gap-3 px-4 py-3 w-full rounded-xl text-zinc-400 hover:text-white hover:bg-white/5 transition-all">
<Settings :size="18" />
<span>Settings</span>
</button>
<button class="flex items-center gap-3 px-4 py-3 w-full rounded-xl text-red-400/80 hover:text-red-400 hover:bg-red-500/10 transition-all mt-1">
<LogOut :size="18" />
<span>Logout</span>
</button>
</div>
</aside>
<!-- Main Content Area -->
<main class="flex-1 flex flex-col h-full overflow-hidden relative">
<div class="absolute inset-0 bg-[url('https://grainy-gradients.vercel.app/noise.svg')] opacity-[0.03] mix-blend-overlay pointer-events-none"></div>
<!-- Topbar (Optional, currently just acts as spacing/header area handled inside views) -->
<!-- Page Content -->
<div class="flex-1 overflow-y-auto px-8 py-8 relative z-10">
<router-view v-slot="{ Component }">
<transition
enter-active-class="transition duration-300 ease-out"
enter-from-class="transform translate-y-4 opacity-0"
enter-to-class="transform translate-y-0 opacity-100"
leave-active-class="transition duration-200 ease-in"
leave-from-class="transform translate-y-0 opacity-100"
leave-to-class="transform translate-y-4 opacity-0"
mode="out-in"
>
<component :is="Component" />
</transition>
</router-view>
</div>
</main>
</div>
</template>

1
web/src/assets/vue.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

View File

@@ -0,0 +1,41 @@
<script setup lang="ts">
import { ref } from 'vue'
defineProps<{ msg: string }>()
const count = ref(0)
</script>
<template>
<h1>{{ msg }}</h1>
<div class="card">
<button type="button" @click="count++">count is {{ count }}</button>
<p>
Edit
<code>components/HelloWorld.vue</code> to test HMR
</p>
</div>
<p>
Check out
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
>create-vue</a
>, the official Vue + Vite starter
</p>
<p>
Learn more about IDE Support for Vue in the
<a
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
target="_blank"
>Vue Docs Scaling up Guide</a
>.
</p>
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
</template>
<style scoped>
.read-the-docs {
color: #888;
}
</style>

10
web/src/main.ts Normal file
View File

@@ -0,0 +1,10 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import router from './router'
import './style.css'
import App from './App.vue'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')

33
web/src/router/index.ts Normal file
View File

@@ -0,0 +1,33 @@
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/login',
name: 'Login',
component: () => import('../views/Login.vue')
},
{
path: '/',
redirect: '/dashboard',
},
{
path: '/dashboard',
name: 'Dashboard',
component: () => import('../views/Dashboard.vue')
},
{
path: '/radar',
name: 'Radar',
component: () => import('../views/Radar.vue')
},
{
path: '/history',
name: 'History',
component: () => import('../views/History.vue')
}
]
})
export default router

24
web/src/style.css Normal file
View File

@@ -0,0 +1,24 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
body {
@apply bg-[#0f172a] text-zinc-200 antialiased;
}
}
/* Glassmorphism utility */
.glass-panel {
@apply bg-[#1e293b]/80 backdrop-blur-md border border-[#334155] rounded-2xl shadow-xl;
}
/* Animations */
@keyframes fade-in-up {
0% { opacity: 0; transform: translateY(10px); }
100% { opacity: 1; transform: translateY(0); }
}
.animate-fade-in-up {
animation: fade-in-up 0.5s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}

149
web/src/views/Dashboard.vue Normal file
View 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
View 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
View 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
View 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>

24
web/tailwind.config.js Normal file
View File

@@ -0,0 +1,24 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{vue,js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
brand: {
primary: '#3b82f6', // Bright professional blue
secondary: '#10b981', // Success green
dark: '#0f172a', // Deep slate
panel: '#1e293b', // Slightly lighter slate
border: '#334155'
}
},
fontFamily: {
sans: ['Inter', 'system-ui', 'Avenir', 'Helvetica', 'Arial', 'sans-serif'],
}
},
},
plugins: [],
}

16
web/tsconfig.app.json Normal file
View File

@@ -0,0 +1,16 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"types": ["vite/client"],
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}

7
web/tsconfig.json Normal file
View File

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

26
web/tsconfig.node.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

7
web/vite.config.ts Normal file
View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()],
})