Compare commits
10 Commits
extension-
...
extension-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b7f308528 | ||
|
|
95ee3fa61d | ||
|
|
d82d59cbe4 | ||
|
|
eb7efae32a | ||
|
|
bb40407761 | ||
|
|
1d5da7532c | ||
|
|
2eabe7e5ef | ||
|
|
1d17ac03e0 | ||
|
|
c686d81d30 | ||
|
|
235fc9c87f |
11
README.md
11
README.md
@@ -14,11 +14,14 @@ InsightReply 采用现代化解耦的三端架构:
|
|||||||
- 数据库:PostgreSQL 14+
|
- 数据库:PostgreSQL 14+
|
||||||
- 功能模块:Nitter 高可用爬虫引擎、多模态 AI 接入层、策略派发与遥测统计引擎。
|
- 功能模块:Nitter 高可用爬虫引擎、多模态 AI 接入层、策略派发与遥测统计引擎。
|
||||||
2. **`/web` (SaaS 管理控制台)**
|
2. **`/web` (SaaS 管理控制台)**
|
||||||
- 核心选型:Vue 3 + Vite + Tailwind 玻璃态美学
|
- 核心选型:Vue 3 + Vite + Tailwind **Apple Pro Max 玻璃态美学**
|
||||||
- 功能模块:数据指标大盘、策略雷达与组合词配置、生成表现记录仪表板。
|
- 功能模块:数据指标大盘、策略雷达、生成表现记录。
|
||||||
3. **`/extension` (Chrome 浏览器助手)**
|
3. **`/extension` (Chrome 浏览器助手)**
|
||||||
- 核心选型:CRXJS + Vue 3
|
- 核心选型:CRXJS + Vue 3 + **macOS Settings 交互规范**
|
||||||
- 功能模块:侵入 X 原生界面,一键触发 AI 分析、克隆高赞评论语言风格。
|
- 功能模块:
|
||||||
|
- **Spatial Sidebar**: 侵入 X 原生界面,全玻璃态侧边栏一键触发 AI 分析。
|
||||||
|
- **Pro Dashboard**: 类似 macOS 系统设置的后台管理,极简高效。
|
||||||
|
- **Magic Toggle**: `Cmd + Shift + I` 全局快捷键秒开侧边栏。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
# InsightReply 用户使用指南
|
# InsightReply 用户使用指南 💎
|
||||||
|
|
||||||
> InsightReply 是一个帮助创始人和独立开发者在 X (Twitter) 行业热点中输出更有洞察力评论的 AI 助手。
|
> InsightReply 是一款专为独立开发者与数字游民设计的 **AI 驱动 X (Twitter) 自动化营销套件**。
|
||||||
> 它是**社交表达增强系统**,不是自动化机器人 —— 所有评论由你决定是否发布。
|
> 它采用 **Apple Pro Max 玻璃态美学** 与 **macOS Settings 交互规范**,为你提供丝滑、专业的社交增长体验。
|
||||||
|
> 所有评论由你决定是否发布 —— 我们是**社交表达增强系统**,不是自动化机器人。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -20,63 +21,67 @@
|
|||||||
|
|
||||||
### 1.2 首次设置 (Onboarding)
|
### 1.2 首次设置 (Onboarding)
|
||||||
|
|
||||||
安装后首次点击扩展图标,会进入 **3 步引导**:
|
安装后首次点击扩展图标,会进入 **Pro Max 引导流程**:
|
||||||
|
|
||||||
| 步骤 | 设置内容 | 说明 |
|
| 步骤 | 设置内容 | 说明 |
|
||||||
|------|---------|------|
|
|------|---------|------|
|
||||||
| Step 1 | 🧑💻 **你的身份** | AI 创始人 / SaaS Builder / 投资人 / 独立开发者 / 技术分析者 |
|
| Step 1 | 🧑💻 **身份定义** | AI 创始人 / SaaS Builder / 投资人 / 独立开发者 / 技术分析者 |
|
||||||
| Step 2 | 🌐 **偏好语言** | English / 中文 / 跟随原推文 (Auto) |
|
| Step 2 | 🌐 **语言智能** | English / 中文 / 跟随原推文 (Auto) |
|
||||||
| Step 3 | 🎨 **风格倾向** | 专业严谨 / 轻松幽默 / 犀利锐评 |
|
| Step 3 | 🎨 **风格倾向** | 专业严谨 / 轻松幽默 / 犀利锐评 |
|
||||||
|
|
||||||
这些设置会影响 AI 生成评论的语气和风格,后续可随时在设置中修改。
|
这些设置会影响 AI 生成评论的语气和风格,后续可随时在设置中修改。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 二、核心功能使用
|
## 二、核心功能:Spatial Sidebar
|
||||||
|
|
||||||
### 2.1 生成评论(主流程)
|
### 2.1 沉浸式生成(主流程)
|
||||||
|
|
||||||
```
|
```
|
||||||
打开 X (Twitter) → 浏览 Timeline → 找到想评论的推文
|
打开 X (Twitter) → 浏览 Timeline → 找到想评论的推文
|
||||||
↓
|
↓
|
||||||
点击推文操作栏旁的紫色 ✦ Insight 按钮
|
点击推文操作栏旁的紫色 ✦ Insight 按钮(或 Cmd+Shift+I 唤起面板)
|
||||||
↓
|
↓
|
||||||
右侧弹出 InsightReply 面板
|
右侧滑出 Spatial Sidebar(高斯模糊玻璃态设计)
|
||||||
↓
|
↓
|
||||||
选择评论策略 → 点击「Generate High-Quality Reply」
|
选择评论策略 → 点击「Generate Pro Reply」
|
||||||
↓
|
↓
|
||||||
AI 返回多条备选评论 → 选择最满意的一条
|
AI 返回多条备选评论 → 选择最满意的一条
|
||||||
↓
|
↓
|
||||||
点击「Copy」→ 粘贴到推文评论框 → 发布
|
点击「COPY」→ 粘贴到推文评论框 → 发布
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2.2 评论策略一览
|
### 2.2 顶级策略库
|
||||||
|
|
||||||
系统内置 5 种策略,适用于不同场景:
|
系统内置 5 种策略,适用于不同场景:
|
||||||
|
|
||||||
| 策略 | 图标 | 适用场景 | 典型开头 |
|
| 策略 | 深度目标 | 适用场景 | 典型 AI 开头 |
|
||||||
|------|------|---------|---------|
|
|------|---------|---------|-------------|
|
||||||
| **认知升级型** | 🧠 | 对话题提出更深层的洞察 | "Most people miss this part..." |
|
| **Cognitive Lift** | 🧠 认知升级 | 对话题提出更深层的洞察 | "Most people miss this part..." |
|
||||||
| **反向观点型** | 🔥 | 提出不同角度,引发讨论 | "Unpopular opinion:" |
|
| **Witty & Fun** | 😄 幽默破冰 | 提出不同角度,引发讨论 | "I can't be the only one who thinks..." |
|
||||||
| **数据补充型** | 📊 | 用数据/案例补充论点 | "Data shows that..." |
|
| **Pro Rigor** | 💼 专业严谨 | 用数据/案例补充论点 | "Based on our engineering data..." |
|
||||||
| **共鸣支持型** | ❤️ | 表达认同并延伸讨论 | "This resonates deeply..." |
|
| **Empathetic** | ❤️ 情感共鸣 | 表达认同并延伸讨论 | "This resonates deeply..." |
|
||||||
| **创始人经验型** | 🚀 | 以自身实战经验为论据 | "We faced this building our product..." |
|
| **Sharp Critique** | 🔥 犀利锐评 | 以自身实战经验为论据 | "Unpopular opinion, but..." |
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> **选择策略的技巧**:
|
> **选择策略的技巧**:
|
||||||
> - 大 V 发的行业观点 → 用「认知升级型」或「反向观点型」更容易获得关注
|
> - 大 V 发的行业观点 → 用「Cognitive Lift」或「Sharp Critique」更容易获得关注
|
||||||
> - 有人吐槽痛点 → 用「创始人经验型」分享你的解决方案
|
> - 有人吐槽痛点 → 用「Empathetic」分享你的解决方案
|
||||||
> - 行业数据/报告 → 用「数据补充型」让你的评论更有说服力
|
> - 行业数据/报告 → 用「Pro Rigor」让你的评论更有说服力
|
||||||
|
|
||||||
### 2.3 识别高价值推文
|
### 2.3 识别高价值推文(智能标签)
|
||||||
|
|
||||||
InsightReply 会在推文旁显示提示标签,帮你快速判断:
|
InsightReply 会在推文旁自动显示提示标签,帮你快速判断互动价值:
|
||||||
|
|
||||||
| 标签 | 含义 | 建议 |
|
| 标签 | 触发条件 | 建议 |
|
||||||
|------|------|------|
|
|------|---------|------|
|
||||||
| 🔥 **Trending** | 高热度推文(Likes > 1000,发帖 < 2h) | 快速评论,争取高曝光 |
|
| 🔥 **Trending** | 综合热度 > 50,000(Likes×1 + Retweets×2 + Replies×3) | 流量巅峰,快速评论争取高曝光 |
|
||||||
| ⚡ **Rising** | 增长中的推文(Likes > 100,发帖 < 1h) | 黄金窗口期,强烈建议评论 |
|
| ⚡ **Rising** | 综合热度 > 5,000 | 黄金窗口期,强烈建议评论 |
|
||||||
| 🎯 **High Relevance** | 与你的产品领域高度相关 | 最适合你评论的推文! |
|
| 🎯 **Relevant** | 推文内容匹配你在产品档案中设置的关键词 | **最适合你评论的推文!** |
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> 热度公式:`heatScore = likes × 1 + retweets × 2 + replies × 3`
|
||||||
|
> 关键词匹配基于你在 Dashboard → Product DNA 中配置的竞品名与相关关键词。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -84,9 +89,9 @@ InsightReply 会在推文旁显示提示标签,帮你快速判断:
|
|||||||
|
|
||||||
> 如果你是创始人/独立开发者,正在用 InsightReply 提升产品曝光,以下功能专为你设计。
|
> 如果你是创始人/独立开发者,正在用 InsightReply 提升产品曝光,以下功能专为你设计。
|
||||||
|
|
||||||
### 3.1 配置你的产品档案
|
### 3.1 配置你的产品档案 (Product DNA)
|
||||||
|
|
||||||
在扩展设置中填写你的产品信息,AI 会自动从你的产品领域出发生成评论:
|
在 Dashboard 中填写你的产品信息,AI 会自动从你的产品领域出发生成评论:
|
||||||
|
|
||||||
| 配置项 | 填什么 | 作用 |
|
| 配置项 | 填什么 | 作用 |
|
||||||
|-------|-------|------|
|
|-------|-------|------|
|
||||||
@@ -103,24 +108,24 @@ InsightReply 会在推文旁显示提示标签,帮你快速判断:
|
|||||||
|
|
||||||
### 3.2 切换与自定义 AI 引擎 (多模型支持)
|
### 3.2 切换与自定义 AI 引擎 (多模型支持)
|
||||||
|
|
||||||
为了满足成本、速度、质量等不同维度的需求,InsightReply 支持四大主流平台,并允许**完全自定义模型**。
|
InsightReply 支持四大主流平台,并允许**完全自定义模型**。
|
||||||
|
|
||||||
在产品档案的「重写 AI 引擎」设置中:
|
在产品档案的「重写 AI 引擎」设置中:
|
||||||
1. **下拉选择**:你可以从系统管理员预设的模型列表中快速选择(如 `gpt-4o-mini`, `claude-3-5-haiku-latest`)。
|
1. **下拉选择**:从系统预设的模型列表中快速切换(如 `gpt-4o-mini`, `claude-3-5-haiku-latest`)。
|
||||||
2. **手动输入 (支持本地/代理)**:如果你使用的平台兼容 OpenAI(如 Groq、vLLM、Ollama),或者你想使用列表中没有的最新模型(如刚刚发布的 `gpt-4.5-turbo`),可以直接在框内**手动打字输入任意模型名称**。
|
2. **手动输入 (支持本地/代理)**:如果你使用兼容 OpenAI 的平台(如 Groq、vLLM、Ollama),可以直接**手动输入任意模型名称**。
|
||||||
|
|
||||||
| 引擎 | 推荐模型 | 适用场景特点 |
|
| 引擎 | 推荐模型 | 适用场景特点 |
|
||||||
|------|---------|-------------|
|
|------|---------|-------------|
|
||||||
| **Anthropic** | `claude-3-5-haiku-latest` | **默认推荐**。响应速度极快,文本语气最自然、最像真人社交媒体发言,不易产生"AI味"。 |
|
| **Anthropic** | `claude-3-5-haiku-latest` | **默认推荐**。语气最自然,不易产生"AI味" |
|
||||||
| **OpenAI (或兼容接口)** | `gpt-4o-mini` | 表现稳定,成本极低,适合大批量生成。 |
|
| **OpenAI (或兼容接口)** | `gpt-4o-mini` | 表现稳定,成本极低,适合大批量生成 |
|
||||||
| **DeepSeek** | `deepseek-chat` | 逻辑分析能力强,中文语感极佳,价格优势明显,适合技术长文讨论。 |
|
| **DeepSeek** | `deepseek-chat` | 逻辑分析强,中文语感极佳,价格优势明显 |
|
||||||
| **Google** | `gemini-2.5-flash` | 速度快,多语言处理能力强。 |
|
| **Google** | `gemini-2.5-flash` | 速度快,多语言处理能力强 |
|
||||||
|
|
||||||
*注:切换平台需要系统后台配置了相应的 API Key 或 Base URL。管理员可将 OpenAI Base URL 指向任意兼容代理,实现模型的无限扩展。*
|
*注:切换平台需要系统后台配置了相应的 API Key 或 Base URL。管理员可将 OpenAI Base URL 指向任意兼容代理,实现模型的无限扩展。*
|
||||||
|
|
||||||
### 3.3 创建自定义策略
|
### 3.3 创建自定义策略
|
||||||
|
|
||||||
除了内置的 5 种策略,你可以创建专属策略:
|
除了内置的 5 种策略,你可以在 Dashboard → Custom Logic 中创建专属策略:
|
||||||
|
|
||||||
**示例:创建「Builder Story」策略**
|
**示例:创建「Builder Story」策略**
|
||||||
|
|
||||||
@@ -148,7 +153,7 @@ Few-shot 示例:
|
|||||||
| `{language}` | 输出语言 |
|
| `{language}` | 输出语言 |
|
||||||
| `{max_length}` | 最大字符数 |
|
| `{max_length}` | 最大字符数 |
|
||||||
|
|
||||||
### 3.3 推广效果最大化小贴士
|
### 3.4 推广效果最大化小贴士
|
||||||
|
|
||||||
1. **优化你的 X Profile**
|
1. **优化你的 X Profile**
|
||||||
- Bio 中明确写 "Building @YourProduct"
|
- Bio 中明确写 "Building @YourProduct"
|
||||||
@@ -161,7 +166,7 @@ Few-shot 示例:
|
|||||||
|
|
||||||
3. **评论质量 > 数量**
|
3. **评论质量 > 数量**
|
||||||
- 一条有深度的评论 > 十条"Great post!"
|
- 一条有深度的评论 > 十条"Great post!"
|
||||||
- 用「反向观点型」或「数据补充型」更容易引发讨论 → 更多曝光
|
- 用「Sharp Critique」或「Pro Rigor」更容易引发讨论 → 更多曝光
|
||||||
|
|
||||||
4. **不要硬推产品**
|
4. **不要硬推产品**
|
||||||
- ❌ "Check out my product SwiftBiu!"
|
- ❌ "Check out my product SwiftBiu!"
|
||||||
@@ -169,9 +174,13 @@ Few-shot 示例:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 四、评论历史与效果追踪
|
## 四、Dashboard (macOS 风格)
|
||||||
|
|
||||||
### 4.1 查看评论历史
|
InsightReply 的管理后台采用 **macOS Settings 侧边栏布局**,操作直观高效。
|
||||||
|
|
||||||
|
打开方式:点击扩展图标 → 点击「Launch Dashboard」按钮,或直接使用快捷键。
|
||||||
|
|
||||||
|
### 4.1 评论历史
|
||||||
|
|
||||||
点击扩展图标 → `History` Tab:
|
点击扩展图标 → `History` Tab:
|
||||||
- 查看所有生成过的评论
|
- 查看所有生成过的评论
|
||||||
@@ -179,18 +188,28 @@ Few-shot 示例:
|
|||||||
- 搜索关键词
|
- 搜索关键词
|
||||||
- 查看复制/跳过状态
|
- 查看复制/跳过状态
|
||||||
|
|
||||||
### 4.2 效果追踪
|
### 4.2 效果追踪 (24h Performance Feedback)
|
||||||
|
|
||||||
InsightReply 会自动追踪你发布的评论效果:
|
InsightReply 会自动追踪你发布的评论效果:
|
||||||
1. 你复制并发布了一条评论
|
1. 你复制并发布了一条评论
|
||||||
2. 24 小时后,当你再次浏览 X 时,系统自动回查该评论的互动数据
|
2. 24 小时后,系统自动回查该评论的互动数据(Likes、Replies)
|
||||||
3. 互动数据(Likes、Replies)会记录到你的个人面板
|
3. 互动数据会记录到你的个人面板
|
||||||
|
|
||||||
> 效果数据积累越多 → AI 越懂你的风格 → 生成质量越高 → 形成正向飞轮 🔄
|
> 效果数据积累越多 → AI 越懂你的风格 → 生成质量越高 → 形成正向飞轮 🔄
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 五、版本与定价
|
## 五、快捷键 (Shortcuts)
|
||||||
|
|
||||||
|
| 功能 | Mac 快捷键 | Win 快捷键 | 状态 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| **唤起/隐藏 Sidebar** | `Cmd + Shift + I` | `Ctrl + Shift + I` | ✅ 已实现 |
|
||||||
|
| **快速生成** | `Alt + G` | `Alt + G` | 📋 规划中 |
|
||||||
|
| **一键复制** | `Alt + C` | `Alt + C` | 📋 规划中 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、版本与定价
|
||||||
|
|
||||||
| 版本 | 价格 | 包含功能 |
|
| 版本 | 价格 | 包含功能 |
|
||||||
|------|------|---------|
|
|------|------|---------|
|
||||||
@@ -200,7 +219,7 @@ InsightReply 会自动追踪你发布的评论效果:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 六、常见问题 (FAQ)
|
## 七、常见问题 (FAQ)
|
||||||
|
|
||||||
### Q: InsightReply 会自动发布评论吗?
|
### Q: InsightReply 会自动发布评论吗?
|
||||||
**不会**。InsightReply 只生成评论建议,你决定是否复制和发布。我们是 AI 写作增强工具,不是自动化机器人。
|
**不会**。InsightReply 只生成评论建议,你决定是否复制和发布。我们是 AI 写作增强工具,不是自动化机器人。
|
||||||
@@ -217,21 +236,14 @@ Chrome 和 Edge(基于 Manifest V3)。Firefox 支持规划中。
|
|||||||
- 我们不会代替你操作你的 X 账号
|
- 我们不会代替你操作你的 X 账号
|
||||||
|
|
||||||
### Q: 如何修改已保存的设置?
|
### Q: 如何修改已保存的设置?
|
||||||
点击扩展图标 → 齿轮图标 ⚙️ → 可修改身份标签、语言偏好、产品档案、自定义策略。
|
点击扩展图标 → 「Launch Dashboard」→ 在 macOS 风格的侧边栏中选择对应模块修改。
|
||||||
|
|
||||||
### Q: 竞品关键词如何工作?
|
### Q: 竞品关键词如何工作?
|
||||||
在产品档案中添加竞品名称后,当 Timeline 中出现包含竞品关键词的推文,InsightReply 会在推文旁显示 🎯 标签,提示你这是一条高价值评论机会。
|
在 Dashboard → Product DNA 中添加竞品名称后,当 Timeline 中出现包含竞品关键词的推文,InsightReply 会在推文旁显示 🎯 标签,提示你这是一条高价值评论机会。
|
||||||
|
|
||||||
|
### Q: 界面被推文遮挡怎么办?
|
||||||
|
我们已升级为 **Shadow DOM 最高优先级渲染**(z-index: 2147483647),面板将永远浮于页面最顶层。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 七、快捷键
|
> **反馈与建议**:如果你有功能建议或遇到问题,欢迎在我们的 [Gitea 仓库](https://git.buildapp.eu.org/) 提交 Issue。
|
||||||
|
|
||||||
| 快捷键 | 功能 |
|
|
||||||
|-------|------|
|
|
||||||
| `Alt + I` | 打开/关闭 InsightReply 面板 |
|
|
||||||
| `Alt + G` | 快速生成评论 |
|
|
||||||
| `Alt + C` | 复制选中的评论 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
> **反馈与建议**:如果你有功能建议或遇到问题,欢迎在我们的 Gitea 仓库提交 Issue。
|
|
||||||
|
|||||||
@@ -33,5 +33,26 @@
|
|||||||
"https://x.com/*"
|
"https://x.com/*"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
"commands": {
|
||||||
|
"toggle-sidebar": {
|
||||||
|
"suggested_key": {
|
||||||
|
"default": "Ctrl+Shift+I",
|
||||||
|
"mac": "Command+Shift+I"
|
||||||
|
},
|
||||||
|
"description": "Toggle InsightReply Sidebar"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"web_accessible_resources": [
|
||||||
|
{
|
||||||
|
"resources": [
|
||||||
|
"src/assets/*",
|
||||||
|
"assets/*"
|
||||||
|
],
|
||||||
|
"matches": [
|
||||||
|
"https://twitter.com/*",
|
||||||
|
"https://x.com/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,24 +1,37 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
|
import Auth from './options/Auth.vue'
|
||||||
|
import Profile from './options/Profile.vue'
|
||||||
|
import Strategies from './options/Strategies.vue'
|
||||||
|
import Competitors from './options/Competitors.vue'
|
||||||
|
import HotTweets from './options/HotTweets.vue'
|
||||||
|
|
||||||
const activeTab = ref<'settings' | 'history'>('settings')
|
const activeTab = ref<'profile' | 'strategies' | 'radar' | 'growth' | 'history'>('growth')
|
||||||
|
const token = ref('')
|
||||||
// Settings State
|
const isLoading = ref(true)
|
||||||
const identity = ref('Independent Developer / Founder')
|
|
||||||
const language = ref('auto')
|
|
||||||
const isSaving = ref(false)
|
|
||||||
|
|
||||||
// History State
|
|
||||||
const historyList = ref<Array<any>>([])
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// Load settings
|
chrome.storage.local.get(['jwt_token'], (res) => {
|
||||||
chrome.storage.sync.get(['identity', 'language'], (res) => {
|
if (res.jwt_token) {
|
||||||
if (res.identity) identity.value = String(res.identity)
|
token.value = String(res.jwt_token)
|
||||||
if (res.language) language.value = String(res.language)
|
}
|
||||||
|
isLoading.value = false
|
||||||
})
|
})
|
||||||
|
})
|
||||||
|
|
||||||
// Load history
|
const onAuthenticated = (newToken: string) => {
|
||||||
|
token.value = newToken
|
||||||
|
}
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
chrome.storage.local.remove(['jwt_token', 'user_id'], () => {
|
||||||
|
token.value = ''
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// History State (Moved to separate view or handled here)
|
||||||
|
const historyList = ref<Array<any>>([])
|
||||||
|
onMounted(() => {
|
||||||
chrome.storage.local.get(['history'], (res) => {
|
chrome.storage.local.get(['history'], (res) => {
|
||||||
if (Array.isArray(res.history)) {
|
if (Array.isArray(res.history)) {
|
||||||
historyList.value = res.history
|
historyList.value = res.history
|
||||||
@@ -26,18 +39,6 @@ onMounted(() => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const saveSettings = () => {
|
|
||||||
isSaving.value = true
|
|
||||||
chrome.storage.sync.set({
|
|
||||||
identity: identity.value,
|
|
||||||
language: language.value
|
|
||||||
}, () => {
|
|
||||||
setTimeout(() => {
|
|
||||||
isSaving.value = false
|
|
||||||
}, 600)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const copyToClipboard = (text: string) => {
|
const copyToClipboard = (text: string) => {
|
||||||
navigator.clipboard.writeText(text)
|
navigator.clipboard.writeText(text)
|
||||||
}
|
}
|
||||||
@@ -50,118 +51,170 @@ const clearHistory = () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="w-[400px] h-[600px] bg-[#0A0A0A] text-[#E5E5E5] flex flex-col font-sans overflow-hidden">
|
<div class="w-[400px] h-[600px] glass-card flex flex-col overflow-hidden text-[#f8fafc] selection:bg-rose-500/30 font-sans">
|
||||||
|
|
||||||
<!-- Header -->
|
<!-- Background Decor -->
|
||||||
<div class="p-5 border-b border-white/10 bg-white/5 flex justify-between items-center">
|
<div class="fixed top-0 left-0 w-full h-full pointer-events-none overflow-hidden -z-10">
|
||||||
<div>
|
<div class="absolute -top-[10%] -left-[10%] w-[50%] h-[50%] bg-rose-900/10 blur-[100px] rounded-full"></div>
|
||||||
<h1 class="text-xl font-semibold tracking-tight bg-gradient-to-r from-violet-500 to-blue-500 bg-clip-text text-transparent">
|
<div class="absolute top-[40%] -right-[5%] w-[40%] h-[60%] bg-blue-900/10 blur-[100px] rounded-full"></div>
|
||||||
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>
|
</div>
|
||||||
|
|
||||||
<!-- Main Content Area -->
|
<!-- Premium Header -->
|
||||||
<div class="flex-1 overflow-y-auto p-5">
|
<div class="px-6 py-4 flex justify-between items-center border-b border-white/5 bg-white/[0.02] backdrop-blur-md sticky top-0 z-50">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
<!-- Settings Tab -->
|
<div class="p-2 rounded-lg bg-gradient-to-br from-rose-500 to-rose-600 shadow-lg shadow-rose-900/20">
|
||||||
<div v-if="activeTab === 'settings'" class="animate-in fade-in slide-in-from-right-4 duration-300 space-y-6">
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" class="text-white"><path d="m12 14 4-4"/><path d="M3.34 19a10 10 0 1 1 17.32 0"/></svg>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-base font-medium text-white mb-1">Your Identity Profile</h2>
|
<h1 class="text-sm font-black tracking-tight bg-gradient-to-br from-white to-zinc-400 bg-clip-text text-transparent">
|
||||||
<p class="text-xs text-zinc-400 mb-4">Set your background so AI can generate relevant, authentic replies matching your persona.</p>
|
InsightReply
|
||||||
|
</h1>
|
||||||
<div class="space-y-4">
|
<div class="flex items-center gap-1.5 mt-[-2px]">
|
||||||
<div class="space-y-1.5">
|
<div class="w-1 h-1 rounded-full bg-emerald-500 animate-pulse" v-if="token"></div>
|
||||||
<label class="text-xs font-medium text-zinc-300">Identity Label</label>
|
<span class="text-[8px] text-zinc-500 uppercase tracking-[0.2em] font-black">{{ token ? 'Pro Max Online' : 'Authentication Required' }}</span>
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button v-if="token" @click="logout" class="text-[10px] font-bold text-zinc-500 hover:text-rose-400 transition-apple uppercase tracking-widest">Logout</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
<!-- Content Area -->
|
||||||
@click="saveSettings"
|
<div class="flex-1 overflow-y-auto custom-scrollbar relative">
|
||||||
:disabled="isSaving"
|
<div v-if="isLoading" class="flex items-center justify-center h-full">
|
||||||
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"
|
<div class="w-8 h-8 border-2 border-white/10 border-t-rose-500 rounded-full animate-spin"></div>
|
||||||
>
|
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
<!-- History Tab -->
|
<div v-else-if="!token" class="p-8 h-full flex flex-col justify-center animate-in fade-in zoom-in-95 duration-500">
|
||||||
<div v-if="activeTab === 'history'" class="animate-in fade-in slide-in-from-left-4 duration-300 flex flex-col h-full">
|
<div class="text-center space-y-3 mb-8">
|
||||||
<div class="flex justify-between items-center mb-4 pb-2 border-b border-white/10">
|
<h2 class="text-xl font-black tracking-tight">Welcome Back</h2>
|
||||||
<h2 class="text-xs font-semibold text-zinc-400 uppercase tracking-widest">Generation History</h2>
|
<p class="text-xs text-zinc-500 font-medium px-4">Synchronize your AI personality and custom strategies across all sessions.</p>
|
||||||
<button @click="clearHistory" class="text-xs text-red-400/80 hover:text-red-400 transition-colors">Clear All</button>
|
</div>
|
||||||
|
<Auth @authenticated="onAuthenticated" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="h-full">
|
||||||
|
<div v-if="activeTab === 'growth'" class="p-6 pb-24 animate-in fade-in slide-in-from-right-4 duration-500">
|
||||||
|
<HotTweets :token="token" />
|
||||||
</div>
|
</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">
|
<div v-if="activeTab === 'profile'" class="p-6 pb-24 animate-in fade-in slide-in-from-right-4 duration-500">
|
||||||
<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>
|
<Profile :token="token" />
|
||||||
<p class="text-sm">No history yet</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="space-y-6">
|
<div v-if="activeTab === 'strategies'" class="p-6 pb-24 animate-in fade-in slide-in-from-right-4 duration-500">
|
||||||
<div v-for="(item, idx) in historyList" :key="idx" class="space-y-3 bg-white/5 rounded-xl p-4 border border-white/5">
|
<Strategies :token="token" />
|
||||||
<div class="text-[10px] text-zinc-500 font-mono">{{ new Date(item.timestamp).toLocaleString() }}</div>
|
</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 v-if="activeTab === 'radar'" class="p-6 pb-24 animate-in fade-in slide-in-from-right-4 duration-500">
|
||||||
<div class="space-y-2 mt-3 pt-3 border-t border-white/5">
|
<Competitors :token="token" />
|
||||||
<div v-for="(reply, rIdx) in item.replies" :key="rIdx" class="bg-[#171717] rounded-lg p-3 group relative">
|
</div>
|
||||||
<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>
|
<div v-if="activeTab === 'history'" class="p-6 pb-24 animate-in fade-in slide-in-from-right-4 duration-500 space-y-6">
|
||||||
<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 class="flex justify-between items-center mb-6">
|
||||||
|
<h2 class="text-xs font-bold text-zinc-500 uppercase tracking-[0.2em]">Activity Log</h2>
|
||||||
|
<button @click="clearHistory" class="text-[10px] font-bold text-rose-500/80 hover:text-rose-400 transition-apple uppercase tracking-widest">Clear</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="historyList.length === 0" class="flex flex-col items-center justify-center py-20 text-zinc-600 space-y-4">
|
||||||
|
<div class="p-5 rounded-full bg-white/[0.02] border border-white/5">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="opacity-30"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs font-bold">No activity detected</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="space-y-6">
|
||||||
|
<div v-for="(item, idx) in historyList" :key="idx" class="bg-white/[0.02] border border-white/5 rounded-2xl p-5 space-y-4">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<div class="text-[9px] font-mono text-zinc-600 tracking-tighter">{{ new Date(item.timestamp).toLocaleString() }}</div>
|
||||||
|
<div class="h-1 w-1 rounded-full bg-rose-500/40"></div>
|
||||||
|
</div>
|
||||||
|
<div class="text-[11px] leading-relaxed text-zinc-400 italic bg-black/20 rounded-xl p-3 border border-white/[0.02]">
|
||||||
|
"{{ item.tweetContent?.substring(0, 80) }}..."
|
||||||
|
</div>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div v-for="(reply, rIdx) in item.replies" :key="rIdx" class="bg-white/[0.03] rounded-xl p-4 group relative border border-white/5 hover:border-rose-500/20 transition-apple">
|
||||||
|
<div class="flex justify-between items-center mb-2">
|
||||||
|
<span class="text-[8px] font-bold text-rose-400 bg-rose-400/10 px-2 py-0.5 rounded-full uppercase tracking-widest">{{ reply.strategy }}</span>
|
||||||
|
<button @click="copyToClipboard(reply.content)" class="opacity-0 group-hover:opacity-100 transition-apple text-[9px] text-zinc-400 hover:text-white flex items-center gap-1.5">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="text-[11px] text-zinc-300 leading-relaxed font-medium">{{ reply.content }}</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-zinc-300 leading-relaxed">{{ reply.content }}</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Apple Style Bottom Tab Bar -->
|
||||||
|
<nav v-if="token" class="h-20 bg-black/40 backdrop-blur-3xl border-t border-white/5 flex items-center justify-around px-4 pb-4 shrink-0 z-[100]">
|
||||||
|
<button @click="activeTab = 'growth'" :class="['tab-item', activeTab === 'growth' ? 'text-orange-400 drop-shadow-[0_0_10px_rgba(251,146,60,0.4)]' : 'text-zinc-600']">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5Z"/></svg>
|
||||||
|
<span>Growth</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button @click="activeTab = 'radar'" :class="['tab-item', activeTab === 'radar' ? 'text-blue-400 drop-shadow-[0_0_10px_rgba(59,130,246,0.4)]' : 'text-zinc-600']">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="M2 12h2"/><path d="M20 12h2"/></svg>
|
||||||
|
<span>Radar</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button @click="activeTab = 'strategies'" :class="['tab-item', activeTab === 'strategies' ? 'text-rose-400 drop-shadow-[0_0_10px_rgba(244,63,94,0.4)]' : 'text-zinc-600']">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="m16 4 4 4-4 4"/><path d="M20 8H4v12"/></svg>
|
||||||
|
<span>AI logic</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button @click="activeTab = 'profile'" :class="['tab-item', activeTab === 'profile' ? 'text-rose-400 drop-shadow-[0_0_10px_rgba(244,63,94,0.4)]' : 'text-zinc-600']">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
||||||
|
<span>DNA</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button @click="activeTab = 'history'" :class="['tab-item', activeTab === 'history' ? 'text-rose-400 drop-shadow-[0_0_10px_rgba(244,63,94,0.4)]' : 'text-zinc-600']">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
||||||
|
<span>Logs</span>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
/* Scoped overrides */
|
.tab-item {
|
||||||
body {
|
display: flex;
|
||||||
margin: 0;
|
flex-direction: column;
|
||||||
padding: 0;
|
items-center: center;
|
||||||
width: 400px;
|
gap: 0.375rem;
|
||||||
height: 600px;
|
transition-property: all;
|
||||||
|
transition-duration: 400ms;
|
||||||
|
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
padding-left: 0.5rem;
|
||||||
|
padding-right: 0.5rem;
|
||||||
|
padding-top: 0.25rem;
|
||||||
|
padding-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
.tab-item:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
.tab-item span {
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 900;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
transition-property: all;
|
||||||
|
transition-duration: 400ms;
|
||||||
|
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-scrollbar::-webkit-scrollbar {
|
||||||
|
width: 4px;
|
||||||
|
}
|
||||||
|
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
.custom-scrollbar:hover::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -103,3 +103,15 @@ chrome.runtime.onMessage.addListener((message: { type: string; payload?: any },
|
|||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Handle Command (Keyboard Shortcut)
|
||||||
|
chrome.commands.onCommand.addListener((command) => {
|
||||||
|
if (command === 'toggle-sidebar') {
|
||||||
|
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
|
||||||
|
const activeTab = tabs[0];
|
||||||
|
if (activeTab?.id) {
|
||||||
|
chrome.tabs.sendMessage(activeTab.id, { type: 'TOGGLE_SIDEBAR' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,50 +1,42 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, inject, computed, type Ref } from 'vue'
|
||||||
|
|
||||||
const props = defineProps<{
|
// Injected reactive refs from sidebar-mount.ts
|
||||||
tweetData?: {
|
const currentTweetData = inject<Ref<any>>('currentTweetData', ref(null))
|
||||||
id: string;
|
const isVisible = inject<Ref<boolean>>('sidebarVisible', ref(false))
|
||||||
author: string;
|
|
||||||
content: string;
|
// Computed getter for template convenience
|
||||||
stats: {
|
const tweetData = computed(() => currentTweetData.value)
|
||||||
replies: string;
|
|
||||||
retweets: string;
|
|
||||||
likes: string;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const isVisible = ref(true)
|
|
||||||
const selectedStrategy = ref('Insightful')
|
const selectedStrategy = ref('Insightful')
|
||||||
const generatedReplies = ref<Array<{strategy: string, content: string}>>([])
|
const generatedReplies = ref<Array<{strategy: string, content: string}>>([])
|
||||||
const isGenerating = ref(false)
|
const isGenerating = ref(false)
|
||||||
|
|
||||||
const defaultStrategies = [
|
const defaultStrategies = [
|
||||||
{ id: 'Insightful', label: '认知升级型', icon: '🧠' },
|
{ id: 'Insightful', label: 'Cognitive Lift', icon: '<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10"/>' },
|
||||||
{ id: 'Humorous', label: '幽默风趣型', icon: '😄' },
|
{ id: 'Humorous', label: 'Witty & Fun', icon: '<circle cx="12" cy="12" r="10"/><path d="M8 14s1.5 2 4 2 4-2 4-2"/><line x1="9" x2="9.01" y1="9" y2="9"/><line x1="15" x2="15.01" y1="9" y2="9"/>' },
|
||||||
{ id: 'Professional', label: '专业严谨型', icon: '⚖️' },
|
{ id: 'Professional', label: 'Pro Rigor', icon: '<path d="M16 20V4a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"/><rect width="20" height="14" x="2" y="6" rx="2"/>' },
|
||||||
{ id: 'Supportive', label: '共鸣支持型', icon: '❤️' },
|
{ id: 'Supportive', label: 'Empathetic', icon: '<path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z"/>' },
|
||||||
{ id: 'Critical', label: '锐评批判型', icon: '🔥' },
|
{ id: 'Critical', label: 'Sharp Critique', icon: '<path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5Z"/>' },
|
||||||
{ id: 'Quote', label: '引用转发型', icon: '💬' }
|
|
||||||
]
|
]
|
||||||
|
|
||||||
const strategies = ref([...defaultStrategies])
|
const strategies = ref([...defaultStrategies])
|
||||||
|
|
||||||
onMounted(() => {
|
// Fetch custom strategies on mount
|
||||||
chrome.runtime.sendMessage({ type: 'FETCH_CUSTOM_STRATEGIES' }, (response) => {
|
chrome.runtime.sendMessage({ type: 'FETCH_CUSTOM_STRATEGIES' }, (response) => {
|
||||||
if (response && response.success && response.data) {
|
if (response && response.success && response.data) {
|
||||||
const customStrategies = response.data.map((s: any) => ({
|
const customStrategies = response.data.map((s: any) => ({
|
||||||
id: s.strategy_key,
|
id: s.strategy_key,
|
||||||
label: s.label,
|
label: s.label,
|
||||||
icon: '✨'
|
icon: '<path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z"/>'
|
||||||
}))
|
}))
|
||||||
strategies.value = [...defaultStrategies, ...customStrategies]
|
strategies.value = [...defaultStrategies, ...customStrategies]
|
||||||
}
|
}
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
const generate = () => {
|
const generate = () => {
|
||||||
if (!props.tweetData) return
|
if (!tweetData.value) return
|
||||||
|
|
||||||
isGenerating.value = true
|
isGenerating.value = true
|
||||||
generatedReplies.value = []
|
generatedReplies.value = []
|
||||||
@@ -60,7 +52,7 @@ const generate = () => {
|
|||||||
chrome.runtime.sendMessage({
|
chrome.runtime.sendMessage({
|
||||||
type: 'GENERATE_REPLY',
|
type: 'GENERATE_REPLY',
|
||||||
payload: {
|
payload: {
|
||||||
tweetContent: props.tweetData!.content,
|
tweetContent: tweetData.value!.content,
|
||||||
strategy: selectedStrategy.value,
|
strategy: selectedStrategy.value,
|
||||||
identity: finalIdentity
|
identity: finalIdentity
|
||||||
}
|
}
|
||||||
@@ -72,7 +64,7 @@ const generate = () => {
|
|||||||
generatedReplies.value = [{ strategy: 'Auth Required', content: 'Connection required. Please log in first.' }]
|
generatedReplies.value = [{ strategy: 'Auth Required', content: 'Connection required. Please log in first.' }]
|
||||||
chrome.runtime.openOptionsPage()
|
chrome.runtime.openOptionsPage()
|
||||||
} else {
|
} else {
|
||||||
generatedReplies.value = [{ strategy: 'Error', content: response?.error || 'Failed to generate reply. Please check your connection or API key.' }]
|
generatedReplies.value = [{ strategy: 'Error', content: response?.error || 'Failed to generate reply. Please check your connection.' }]
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -85,8 +77,7 @@ const copyToClipboard = async (reply: any) => {
|
|||||||
showProfileTip.value = true
|
showProfileTip.value = true
|
||||||
setTimeout(() => { showProfileTip.value = false }, 7000)
|
setTimeout(() => { showProfileTip.value = false }, 7000)
|
||||||
|
|
||||||
// Epic 13: Record generated reply for performance tracking telemetry
|
if (!tweetData.value || !tweetData.value.id) return;
|
||||||
if (!props.tweetData || !props.tweetData.id) return;
|
|
||||||
|
|
||||||
chrome.storage.local.get(['jwt_token'], async (result) => {
|
chrome.storage.local.get(['jwt_token'], async (result) => {
|
||||||
if (result.jwt_token) {
|
if (result.jwt_token) {
|
||||||
@@ -100,14 +91,14 @@ const copyToClipboard = async (reply: any) => {
|
|||||||
'Authorization': `Bearer ${token}`
|
'Authorization': `Bearer ${token}`
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
tweet_id: props.tweetData?.id,
|
tweet_id: tweetData.value?.id,
|
||||||
strategy_type: reply.strategy || 'General',
|
strategy_type: reply.strategy || 'General',
|
||||||
content: reply.content,
|
content: reply.content,
|
||||||
language: 'en'
|
language: 'en'
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to log telemetry:', err) // Non blocking telemetry
|
console.error('Failed to log telemetry:', err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -115,111 +106,131 @@ const copyToClipboard = async (reply: any) => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-if="isVisible" class="fixed right-4 top-20 w-[360px] max-h-[85vh] flex flex-col bg-[#0A0A0A]/90 backdrop-blur-2xl border border-white/10 rounded-2xl shadow-2xl text-[#E5E5E5] font-sans z-[9999] overflow-hidden">
|
<transition
|
||||||
|
enter-active-class="transition-transform duration-500 ease-[cubic-bezier(0.2,0.8,0.2,1)]"
|
||||||
<!-- Header -->
|
enter-from-class="translate-x-full"
|
||||||
<div class="p-4 border-b border-white/5 flex justify-between items-center bg-white/5">
|
enter-to-class="translate-x-0"
|
||||||
<div class="flex items-center gap-2">
|
leave-active-class="transition-transform duration-400 ease-[cubic-bezier(0.2,0.8,0.2,1)]"
|
||||||
<div class="w-2 h-2 rounded-full bg-brand-primary animate-pulse"></div>
|
leave-from-class="translate-x-0"
|
||||||
<span class="text-sm font-medium tracking-wide">InsightReply AI</span>
|
leave-to-class="translate-x-full"
|
||||||
|
>
|
||||||
|
<div v-if="isVisible" class="fixed right-0 top-0 bottom-0 w-[400px] flex flex-col bg-zinc-950/90 backdrop-blur-[40px] border-l border-white/10 shadow-[-10px_0_40px_rgba(0,0,0,0.5)] text-[#f8fafc] font-sans z-[2147483647] overflow-hidden selection:bg-rose-500/30">
|
||||||
|
|
||||||
|
<!-- Premium Header -->
|
||||||
|
<div class="px-6 py-5 flex justify-between items-center bg-white/[0.02] border-b border-white/5 shrink-0">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="p-2 rounded-xl bg-gradient-to-br from-rose-500 to-rose-600 shadow-lg shadow-rose-900/20">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" class="text-white"><path d="m12 14 4-4"/><path d="M3.34 19a10 10 0 1 1 17.32 0"/></svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-[15px] font-black tracking-tight block text-white drop-shadow-sm">InsightReply</span>
|
||||||
|
<span class="text-[9px] text-zinc-400 uppercase tracking-[0.2em] font-bold">Spatial Copilot</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button @click="isVisible = false" class="p-2.5 rounded-full bg-white/5 hover:bg-rose-500/20 hover:text-rose-400 border border-white/5 transition-apple group">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" class="text-zinc-400 group-hover:text-rose-400 transition-apple"><path d="M18 6L6 18M6 6l12 12"/></svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button @click="isVisible = false" class="text-zinc-500 hover:text-white transition-colors">
|
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12"/></svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Content -->
|
<!-- Content -->
|
||||||
<div class="p-5 flex-1 overflow-y-auto space-y-6">
|
<div class="p-6 flex-1 overflow-y-auto space-y-8 custom-scrollbar">
|
||||||
|
|
||||||
<!-- Tweet Context (Small Preview) -->
|
<!-- Context Module -->
|
||||||
<div v-if="tweetData" class="bg-white/5 rounded-xl p-3 border border-white/5">
|
<div v-if="tweetData" class="space-y-3">
|
||||||
<div class="text-[10px] text-zinc-500 mb-1 font-mono uppercase">Context</div>
|
<h3 class="text-[10px] font-bold text-zinc-500 uppercase tracking-widest flex items-center gap-2 px-1">
|
||||||
<div class="text-xs text-zinc-400 line-clamp-2 italic">" {{ tweetData.content }} "</div>
|
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" class="text-rose-500"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
||||||
|
Current Tweet
|
||||||
|
</h3>
|
||||||
|
<div class="bg-black/20 rounded-2xl p-4 border border-white/[0.03] relative group">
|
||||||
|
<div class="text-[13px] text-zinc-400 leading-relaxed font-medium italic">
|
||||||
|
"{{ tweetData.content }}"
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Strategy Selector -->
|
<!-- Strategy Grid -->
|
||||||
<div class="space-y-3">
|
<div class="space-y-4">
|
||||||
<span class="text-xs font-semibold text-zinc-500 uppercase tracking-widest">Generation Strategy</span>
|
<h3 class="text-[10px] font-bold text-zinc-500 uppercase tracking-widest flex items-center gap-2 px-1">
|
||||||
<div class="grid grid-cols-1 gap-2">
|
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" class="text-rose-500"><path d="m16 4 4 4-4 4"/><path d="M20 8H4v12"/></svg>
|
||||||
|
Generation Strategy
|
||||||
|
</h3>
|
||||||
|
<div class="grid grid-cols-1 gap-2.5">
|
||||||
<button
|
<button
|
||||||
v-for="s in strategies"
|
v-for="s in strategies"
|
||||||
:key="s.id"
|
:key="s.id"
|
||||||
@click="selectedStrategy = s.id"
|
@click="selectedStrategy = s.id"
|
||||||
:class="[
|
:class="[
|
||||||
'flex items-center gap-3 p-3 rounded-xl border transition-all duration-200 text-sm group',
|
'flex items-center gap-4 p-4 rounded-2xl border transition-apple group text-sm font-semibold active:scale-[0.98]',
|
||||||
selectedStrategy === s.id
|
selectedStrategy === s.id
|
||||||
? 'bg-brand-primary/20 border-brand-primary text-white shadow-lg shadow-brand-primary/10'
|
? 'bg-rose-500/10 border-rose-500/30 text-rose-50 text-white shadow-[0_8px_16px_rgba(136,19,55,0.15)]'
|
||||||
: 'bg-white/5 border-transparent hover:bg-white/10 text-zinc-400'
|
: 'bg-white/[0.02] border-white/5 hover:bg-white/[0.05] text-zinc-400'
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<span class="text-lg">{{ s.icon }}</span>
|
<div :class="['p-2 rounded-lg transition-apple', selectedStrategy === s.id ? 'bg-rose-500/20 text-rose-400' : 'bg-black/20 text-zinc-500 group-hover:text-zinc-300']">
|
||||||
<span class="flex-1 text-left">{{ s.label }}</span>
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" v-html="s.icon"></svg>
|
||||||
<div v-if="selectedStrategy === s.id" class="w-1.5 h-1.5 rounded-full bg-brand-primary"></div>
|
</div>
|
||||||
|
<span class="flex-1 text-left tracking-tight">{{ s.label }}</span>
|
||||||
|
<div v-if="selectedStrategy === s.id" class="w-2 h-2 rounded-full bg-rose-500 shadow-[0_0_8px_#f43f5e]"></div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Result Area -->
|
<!-- Result Module -->
|
||||||
<div v-if="generatedReplies.length > 0" class="space-y-4 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-4 duration-700">
|
||||||
<div class="flex justify-between items-center">
|
<h3 class="text-[10px] font-bold text-zinc-500 uppercase tracking-widest px-1">AI Suggestions</h3>
|
||||||
<span class="text-xs font-semibold text-zinc-500 uppercase tracking-widest">Assistant Suggestions</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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 v-for="(reply, idx) in generatedReplies" :key="idx" class="bg-white/[0.03] rounded-2xl p-5 border border-white/5 space-y-4 relative group hover:border-rose-500/20 transition-apple">
|
||||||
<div class="flex justify-between items-center">
|
<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">
|
<span class="text-[9px] font-bold text-rose-400 bg-rose-400/10 px-2.5 py-1 rounded-full uppercase tracking-widest">
|
||||||
{{ reply.strategy || 'Suggestion' }}
|
{{ reply.strategy || 'Insight' }}
|
||||||
</span>
|
</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">
|
<button @click="copyToClipboard(reply)" class="opacity-0 group-hover:opacity-100 transition-apple text-[10px] font-bold text-zinc-500 hover:text-white flex items-center gap-2 px-2 py-1 rounded-lg hover:bg-white/5">
|
||||||
<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>
|
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>
|
||||||
Copy
|
COPY
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm leading-relaxed whitespace-pre-wrap text-[#E5E5E5]">
|
<div class="text-[13px] leading-relaxed whitespace-pre-wrap text-zinc-200 font-medium tracking-tight">
|
||||||
{{ reply.content }}
|
{{ reply.content }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Footer Action -->
|
<!-- Sticky Action Footer -->
|
||||||
<div class="p-4 bg-white/5 border-t border-white/5 flex flex-col gap-3">
|
<div class="p-6 bg-white/[0.02] border-t border-white/5 space-y-4 shadow-[0_-12px_24px_rgba(0,0,0,0.2)]">
|
||||||
<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">
|
<transition enter-active-class="transition duration-500 transform ease-out" enter-from-class="opacity-0 translate-y-4" enter-to-class="opacity-100 translate-y-0" leave-active-class="transition duration-300 transform ease-in" leave-from-class="opacity-100 translate-y-0" leave-to-class="opacity-0 translate-y-4">
|
||||||
<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">
|
<div v-if="showProfileTip" class="bg-rose-500/10 border border-rose-500/20 text-rose-400 text-[11px] p-4 rounded-2xl flex gap-3 items-start relative font-medium leading-relaxed">
|
||||||
<span class="text-sm leading-none">💡</span>
|
<span class="text-base 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>
|
<p><strong>Optimize Conversion:</strong> Make sure your X Bio prominently features your product link!</p>
|
||||||
<button @click="showProfileTip = false" class="absolute top-2 right-2 text-blue-400/50 hover:text-blue-400">✕</button>
|
<button @click="showProfileTip = false" class="absolute top-3 right-3 opacity-50 hover:opacity-100">✕</button>
|
||||||
</div>
|
</div>
|
||||||
</transition>
|
</transition>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@click="generate"
|
@click="generate"
|
||||||
:disabled="isGenerating"
|
:disabled="isGenerating"
|
||||||
class="w-full py-3 bg-gradient-to-r from-violet-600 to-blue-600 hover:from-violet-500 hover:to-blue-500 disabled:from-zinc-800 disabled:to-zinc-800 disabled:text-zinc-500 disabled:cursor-not-allowed text-white rounded-xl text-sm font-semibold transition-all shadow-xl shadow-brand-primary/20 flex items-center justify-center gap-2"
|
class="w-full py-4 bg-gradient-to-br from-rose-600 to-rose-500 hover:from-rose-500 hover:to-rose-400 disabled:from-zinc-900 disabled:to-zinc-900 disabled:text-zinc-600 disabled:cursor-not-allowed text-white rounded-[20px] text-[15px] font-bold transition-all shadow-[0_12px_24px_rgba(136,19,55,0.25)] flex items-center justify-center gap-3 active:scale-[0.97]"
|
||||||
>
|
>
|
||||||
<div v-if="isGenerating" class="w-4 h-4 border-2 border-white/20 border-t-white rounded-full animate-spin"></div>
|
<div v-if="isGenerating" class="w-5 h-5 border-2 border-white/20 border-t-white rounded-full animate-spin"></div>
|
||||||
{{ isGenerating ? 'AI Thinking...' : 'Generate High-Quality Reply' }}
|
<svg v-else xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" class="text-rose-100"><path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z"/></svg>
|
||||||
|
{{ isGenerating ? 'AI Thinking...' : 'Generate Pro Reply' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
</transition>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* 局部样式保证不溢出 */
|
.custom-scrollbar::-webkit-scrollbar {
|
||||||
::-webkit-scrollbar {
|
|
||||||
width: 4px;
|
width: 4px;
|
||||||
}
|
}
|
||||||
::-webkit-scrollbar-track {
|
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||||
background: transparent;
|
background: rgba(255, 255, 255, 0.05);
|
||||||
}
|
|
||||||
::-webkit-scrollbar-thumb {
|
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
}
|
}
|
||||||
::-webkit-scrollbar-thumb:hover {
|
.custom-scrollbar:hover::-webkit-scrollbar-thumb {
|
||||||
background: rgba(255, 255, 255, 0.2);
|
background: rgba(255, 255, 255, 0.1);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,54 +1,132 @@
|
|||||||
import { createApp } from 'vue'
|
import { createApp, ref } from 'vue'
|
||||||
import Sidebar from './Sidebar.vue'
|
import Sidebar from './Sidebar.vue'
|
||||||
import '../assets/tailwind.css' // We might need to handle this specially for Shadow DOM
|
|
||||||
|
|
||||||
const MOUNT_ID = 'insight-reply-sidebar-root'
|
const MOUNT_ID = 'insight-reply-sidebar-root'
|
||||||
|
|
||||||
function initSidebar(tweetData?: any) {
|
// Shared reactive tweet data that the Sidebar component will receive
|
||||||
if (document.getElementById(MOUNT_ID)) return
|
const currentTweetData = ref<any>(null)
|
||||||
|
const sidebarVisible = ref(false)
|
||||||
|
|
||||||
|
let isMounted = false
|
||||||
|
let isMounting = false
|
||||||
|
|
||||||
|
async function initSidebar() {
|
||||||
|
if (isMounted || isMounting) return
|
||||||
|
|
||||||
|
let host = document.getElementById(MOUNT_ID)
|
||||||
|
if (host) return
|
||||||
|
|
||||||
|
isMounting = true
|
||||||
|
|
||||||
// 1. Create Host Element
|
// 1. Create Host Element
|
||||||
const host = document.createElement('div')
|
host = document.createElement('div')
|
||||||
host.id = MOUNT_ID
|
host.id = MOUNT_ID
|
||||||
|
host.style.cssText = `
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: 2147483647;
|
||||||
|
pointer-events: none;
|
||||||
|
`
|
||||||
document.body.appendChild(host)
|
document.body.appendChild(host)
|
||||||
|
|
||||||
// 2. Create Shadow Root
|
// 2. Create Shadow Root
|
||||||
const shadowRoot = host.attachShadow({ mode: 'open' })
|
const shadowRoot = host.attachShadow({ mode: 'open' })
|
||||||
|
|
||||||
// 3. Create Container for Vue
|
// 3. Create Container
|
||||||
const container = document.createElement('div')
|
const container = document.createElement('div')
|
||||||
container.id = 'app'
|
container.id = 'app'
|
||||||
|
container.style.cssText = `
|
||||||
|
pointer-events: auto;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
`
|
||||||
shadowRoot.appendChild(container)
|
shadowRoot.appendChild(container)
|
||||||
|
|
||||||
// 4. Inject Styles into Shadow DOM
|
// 4. Inject Styles (Bypassing X.com CSP via fetch)
|
||||||
// Note: In development/build, we need to find the generated CSS and inject it.
|
const injectStyles = async () => {
|
||||||
// CRXJS usually puts CSS in <link> tags in the head for content scripts.
|
try {
|
||||||
// For Shadow DOM, we need to move or clone them into the shadow root.
|
// Check for Vite dev mode styles first
|
||||||
const injectStyles = () => {
|
const devStyles = document.querySelectorAll('style[data-vite-dev-id]')
|
||||||
const styles = document.querySelectorAll('style, link[rel="stylesheet"]')
|
if (devStyles.length > 0) {
|
||||||
styles.forEach(style => {
|
devStyles.forEach(style => shadowRoot.appendChild(style.cloneNode(true)))
|
||||||
// Only clone styles that look like they belong to our extension
|
console.log('[InsightReply] Injected dev styles into shadow DOM')
|
||||||
// This is a heuristic, in a real build we'd use the asset URL
|
return
|
||||||
shadowRoot.appendChild(style.cloneNode(true))
|
}
|
||||||
})
|
|
||||||
|
// Production mode: fetch the CSS file directly to bypass rigid CSP
|
||||||
|
// CRXJS usually puts built CSS in assets/index-[hash].css
|
||||||
|
const cssUrl = chrome.runtime.getURL('assets/index.css')
|
||||||
|
// In Vite dev mode the raw file might be available
|
||||||
|
const devUrl = chrome.runtime.getURL('src/assets/tailwind.css')
|
||||||
|
|
||||||
|
const urlToFetch = chrome.runtime.id.includes('extension') ? cssUrl : devUrl
|
||||||
|
|
||||||
|
const response = await fetch(urlToFetch)
|
||||||
|
if (response.ok) {
|
||||||
|
const cssText = await response.text()
|
||||||
|
const styleEl = document.createElement('style')
|
||||||
|
styleEl.textContent = cssText
|
||||||
|
shadowRoot.appendChild(styleEl)
|
||||||
|
console.log('[InsightReply] Successfully injected fetched CSS into shadow DOM')
|
||||||
|
} else {
|
||||||
|
console.warn('[InsightReply] Failed to fetch CSS file directly, falling back to basic link tag')
|
||||||
|
const linkEl = document.createElement('link')
|
||||||
|
linkEl.rel = 'stylesheet'
|
||||||
|
linkEl.href = chrome.runtime.getURL('src/assets/tailwind.css')
|
||||||
|
shadowRoot.appendChild(linkEl)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[InsightReply] Error injecting styles:', err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initial injection
|
// Inject and wait
|
||||||
injectStyles()
|
await injectStyles()
|
||||||
|
|
||||||
// 5. Create Vue App
|
// 5. Create Vue App with reactive provide
|
||||||
const app = createApp(Sidebar, { tweetData })
|
const app = createApp(Sidebar)
|
||||||
|
|
||||||
|
isMounting = false
|
||||||
|
app.provide('currentTweetData', currentTweetData)
|
||||||
|
app.provide('sidebarVisible', sidebarVisible)
|
||||||
app.mount(container)
|
app.mount(container)
|
||||||
|
|
||||||
console.log('InsightReply Sidebar Mounted in Shadow DOM');
|
isMounted = true
|
||||||
|
console.log('[InsightReply] Sidebar mounted successfully')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Listen for messages to show/hide or update data
|
function showSidebar(tweetData?: any) {
|
||||||
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
|
initSidebar()
|
||||||
if (message.type === 'SHOW_INSIGHT') {
|
|
||||||
console.log('[InsightReply Sidebar Mount] Received SHOW_INSIGHT message:', message.payload);
|
if (tweetData) {
|
||||||
initSidebar(message.payload);
|
currentTweetData.value = tweetData
|
||||||
sendResponse({ received: true });
|
console.log('[InsightReply] Tweet data updated:', tweetData.content?.substring(0, 60))
|
||||||
}
|
}
|
||||||
return true;
|
|
||||||
});
|
sidebarVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSidebar() {
|
||||||
|
initSidebar()
|
||||||
|
sidebarVisible.value = !sidebarVisible.value
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for messages from background script
|
||||||
|
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
|
||||||
|
console.log('[InsightReply SidebarMount] Received message:', message.type)
|
||||||
|
|
||||||
|
if (message.type === 'SHOW_INSIGHT') {
|
||||||
|
showSidebar(message.payload)
|
||||||
|
sendResponse({ received: true })
|
||||||
|
} else if (message.type === 'TOGGLE_SIDEBAR') {
|
||||||
|
toggleSidebar()
|
||||||
|
sendResponse({ received: true })
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const API_BASE = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080/api
|
|||||||
|
|
||||||
const submitAuth = async () => {
|
const submitAuth = async () => {
|
||||||
if (!form.value.email || !form.value.password) {
|
if (!form.value.email || !form.value.password) {
|
||||||
errorMsg.value = 'Email and password are required'
|
errorMsg.value = 'Security credentials required.'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,7 +38,7 @@ const submitAuth = async () => {
|
|||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
|
|
||||||
if (!res.ok || data.code !== 200) {
|
if (!res.ok || data.code !== 200) {
|
||||||
throw new Error(data.message || 'Authentication failed')
|
throw new Error(data.message || 'Authentication sequence failed.')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auth Success
|
// Auth Success
|
||||||
@@ -58,52 +58,65 @@ const submitAuth = async () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="space-y-4">
|
<div class="space-y-8">
|
||||||
<div v-if="errorMsg" class="p-3 bg-red-500/10 border border-red-500/20 text-red-400 text-sm rounded-lg">
|
<transition enter-active-class="transition duration-300 transform" enter-from-class="opacity-0 -translate-y-2" enter-to-class="opacity-100 translate-y-0">
|
||||||
{{ errorMsg }}
|
<div v-if="errorMsg" class="p-4 bg-rose-500/10 border border-rose-500/20 text-rose-400 text-xs font-bold rounded-2xl flex items-center gap-3">
|
||||||
</div>
|
<span class="text-base font-normal">⚠️</span>
|
||||||
|
{{ errorMsg }}
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
|
||||||
<form @submit.prevent="submitAuth" class="space-y-4 text-left">
|
<form @submit.prevent="submitAuth" class="space-y-6 text-left animate-in fade-in slide-in-from-bottom-2 duration-500">
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-3">
|
||||||
<label class="text-xs font-medium text-zinc-300">Email Address</label>
|
<label class="text-[10px] font-bold text-zinc-500 uppercase tracking-[0.2em] ml-1">Universal Identity (Email)</label>
|
||||||
<input
|
<input
|
||||||
v-model="form.email"
|
v-model="form.email"
|
||||||
type="email"
|
type="email"
|
||||||
required
|
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"
|
class="w-full bg-black/40 border border-white/5 rounded-2xl px-5 py-3.5 text-sm font-semibold transition-apple focus:outline-none focus:ring-4 focus:ring-rose-500/10 focus:border-rose-500/40 placeholder:text-zinc-700"
|
||||||
placeholder="your@email.com"
|
placeholder="e.g. user@insight-reply.pro"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-3">
|
||||||
<label class="text-xs font-medium text-zinc-300">Password</label>
|
<label class="text-[10px] font-bold text-zinc-500 uppercase tracking-[0.2em] ml-1">Security Key (Password)</label>
|
||||||
<input
|
<input
|
||||||
v-model="form.password"
|
v-model="form.password"
|
||||||
type="password"
|
type="password"
|
||||||
required
|
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"
|
class="w-full bg-black/40 border border-white/5 rounded-2xl px-5 py-3.5 text-sm font-semibold transition-apple focus:outline-none focus:ring-4 focus:ring-rose-500/10 focus:border-rose-500/40 placeholder:text-zinc-700"
|
||||||
placeholder="••••••••"
|
placeholder="••••••••••••"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
:disabled="isLoading"
|
: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"
|
:class="[
|
||||||
|
'w-full py-4 rounded-2xl text-[15px] font-bold transition-all shadow-xl flex items-center justify-center gap-3 active:scale-[0.98] disabled:opacity-50 mt-4',
|
||||||
|
isLogin
|
||||||
|
? 'bg-rose-500 hover:bg-rose-400 text-white shadow-rose-900/40'
|
||||||
|
: 'bg-white hover:bg-zinc-200 text-black shadow-white/5'
|
||||||
|
]"
|
||||||
>
|
>
|
||||||
<span v-if="isLoading" class="w-4 h-4 border-2 border-white/20 border-t-white rounded-full animate-spin"></span>
|
<span v-if="isLoading" class="w-5 h-5 border-2 border-current/20 border-t-current rounded-full animate-spin"></span>
|
||||||
{{ isLoading ? 'Processing...' : (isLogin ? 'Sign In' : 'Create Account') }}
|
<svg v-else xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/><polyline points="10 17 15 12 10 7"/><line x1="15" x2="3" y1="12" y2="12"/></svg>
|
||||||
|
{{ isLoading ? 'Synchronizing...' : (isLogin ? 'Initiate Session' : 'Create Intelligence Profile') }}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="text-center mt-4 border-t border-white/10 pt-4">
|
<div class="text-center pt-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@click="isLogin = !isLogin"
|
@click="isLogin = !isLogin"
|
||||||
class="text-xs text-zinc-400 hover:text-white transition-colors"
|
class="text-xs font-bold text-zinc-500 hover:text-rose-400 transition-apple px-4 py-2 rounded-full hover:bg-rose-500/5 active:scale-95"
|
||||||
>
|
>
|
||||||
{{ isLogin ? "Don't have an account? Sign up" : "Already have an account? Log in" }}
|
{{ isLogin ? "New to InsightReply? Secure an account" : "Existive user? Authenticate here" }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* No scoped styles needed as we use utility classes for maximum performance and build stability */
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -51,7 +51,6 @@ const createCompetitor = async () => {
|
|||||||
throw new Error(data.message || 'Failed to add competitor')
|
throw new Error(data.message || 'Failed to add competitor')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset form & reload
|
|
||||||
form.value = { competitor_name: '', platform: 'twitter', target_handle: '', keywords: '' }
|
form.value = { competitor_name: '', platform: 'twitter', target_handle: '', keywords: '' }
|
||||||
showForm.value = false
|
showForm.value = false
|
||||||
await fetchCompetitors()
|
await fetchCompetitors()
|
||||||
@@ -85,108 +84,123 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="space-y-6">
|
<div class="space-y-8 animate-in fade-in duration-700">
|
||||||
|
|
||||||
<div v-if="isLoading" class="p-10 flex justify-center">
|
<div v-if="!showForm" class="flex justify-end">
|
||||||
<div class="w-6 h-6 border-2 border-white/20 border-t-white rounded-full animate-spin"></div>
|
<button
|
||||||
|
@click="showForm = true"
|
||||||
|
class="w-full py-4 bg-blue-600 hover:bg-blue-500 text-white rounded-2xl text-sm font-bold transition-apple shadow-lg shadow-blue-900/20 active:scale-95 flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/><path d="M12 5v14"/></svg>
|
||||||
|
Add Target
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else>
|
<div v-if="isLoading" class="py-20 flex justify-center">
|
||||||
<div class="flex justify-between items-center mb-6">
|
<div class="w-8 h-8 border-2 border-white/10 border-t-blue-500 rounded-full animate-spin"></div>
|
||||||
<p class="text-sm text-zinc-400">Track competitors tweets & mentions to find opportunistic conversations.</p>
|
</div>
|
||||||
<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 -->
|
<div v-else class="space-y-6">
|
||||||
<form v-if="showForm" @submit.prevent="createCompetitor" class="bg-[#171717] border border-white/10 rounded-xl p-6 space-y-4 mb-8">
|
<!-- Create Form Modal View -->
|
||||||
<div class="flex justify-between items-center mb-2 border-b border-white/10 pb-2">
|
<transition enter-active-class="transition duration-500 ease-out transform" enter-from-class="opacity-0 scale-95 -translate-y-4" enter-to-class="opacity-100 scale-100 translate-y-0">
|
||||||
<h3 class="text-sm font-semibold text-white">Add Target Radar</h3>
|
<form v-if="showForm" @submit.prevent="createCompetitor" class="bg-white/[0.03] border border-white/5 rounded-[32px] p-6 space-y-6 shadow-2xl relative">
|
||||||
<button type="button" @click="showForm = false" class="text-zinc-500 hover:text-white">✕</button>
|
<div class="flex justify-between items-center">
|
||||||
</div>
|
<h3 class="text-base font-bold">New Radar Target</h3>
|
||||||
|
<button type="button" @click="showForm = false" class="p-2 rounded-full hover:bg-white/5 transition-apple">
|
||||||
<div v-if="errorMsg" class="p-3 bg-red-500/10 border border-red-500/20 text-red-400 text-xs rounded-lg">
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12"/></svg>
|
||||||
{{ errorMsg }}
|
</button>
|
||||||
</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>
|
||||||
<div class="space-y-1.5">
|
|
||||||
<label class="text-xs font-medium text-zinc-300">Platform</label>
|
<div v-if="errorMsg" class="p-4 bg-rose-500/10 border border-rose-500/20 text-rose-400 text-xs font-bold rounded-xl">
|
||||||
<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">
|
{{ errorMsg }}
|
||||||
<option value="twitter">Twitter / X</option>
|
|
||||||
<option value="reddit">Reddit (Coming Soon)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
<div class="grid grid-cols-1 gap-6">
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="space-y-3">
|
||||||
<div class="space-y-1.5">
|
<label class="text-[10px] font-bold text-zinc-500 uppercase tracking-[0.2em] ml-1">Competitor Name</label>
|
||||||
<label class="text-xs font-medium text-zinc-300">Target Handle</label>
|
<input v-model="form.competitor_name" required class="w-full bg-black/40 border border-white/5 rounded-2xl px-5 py-3.5 text-sm font-semibold transition-apple focus:outline-none focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500/40 placeholder:text-zinc-700" placeholder="e.g. Acme Corp" />
|
||||||
<div class="relative">
|
</div>
|
||||||
<span class="absolute inset-y-0 left-0 pl-3 flex items-center text-zinc-500 pointer-events-none text-sm">@</span>
|
<div class="space-y-3">
|
||||||
<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" />
|
<label class="text-[10px] font-bold text-zinc-500 uppercase tracking-[0.2em] ml-1">Platform</label>
|
||||||
|
<div class="relative">
|
||||||
|
<select v-model="form.platform" class="w-full bg-black/40 border border-white/5 rounded-2xl px-5 py-3.5 text-sm font-semibold transition-apple focus:outline-none focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500/40 placeholder:text-zinc-700 appearance-none cursor-pointer">
|
||||||
|
<option value="twitter">Twitter / X</option>
|
||||||
|
<option value="reddit">Reddit (Soon)</option>
|
||||||
|
</select>
|
||||||
|
<div class="absolute right-5 top-1/2 -translate-y-1/2 pointer-events-none text-zinc-600">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-1.5">
|
</div>
|
||||||
<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 class="grid grid-cols-1 gap-6">
|
||||||
|
<div class="space-y-3">
|
||||||
|
<label class="text-[10px] font-bold text-zinc-500 uppercase tracking-[0.2em] ml-1">X Handle</label>
|
||||||
|
<div class="relative">
|
||||||
|
<span class="absolute inset-y-0 left-5 flex items-center text-zinc-600 pointer-events-none text-sm font-bold">@</span>
|
||||||
|
<input v-model="form.target_handle" class="w-full bg-black/40 border border-white/5 rounded-2xl px-5 py-3.5 text-sm font-semibold transition-apple focus:outline-none focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500/40 placeholder:text-zinc-700 pl-10" placeholder="handle" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="space-y-3">
|
||||||
|
<label class="text-[10px] font-bold text-zinc-500 uppercase tracking-[0.2em] ml-1">Monitor Keywords</label>
|
||||||
|
<input v-model="form.keywords" required class="w-full bg-black/40 border border-white/5 rounded-2xl px-5 py-3.5 text-sm font-semibold transition-apple focus:outline-none focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500/40 placeholder:text-zinc-700" placeholder="e.g. acme sucks..." />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-end pt-4">
|
<button type="submit" :disabled="isSubmitting" class="w-full py-4 bg-blue-600 hover:bg-blue-500 text-white rounded-2xl text-sm font-bold transition-apple shadow-xl shadow-blue-900/30 flex items-center justify-center gap-3 active:scale-95 disabled:opacity-50">
|
||||||
<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">
|
<div v-if="isSubmitting" class="w-4 h-4 border-2 border-white/20 border-t-white rounded-full animate-spin"></div>
|
||||||
<span v-if="isSubmitting" class="w-4 h-4 border-2 border-white/20 border-t-white rounded-full animate-spin"></span>
|
{{ isSubmitting ? 'Calibrating...' : 'Initialize Radar' }}
|
||||||
{{ isSubmitting ? 'Saving...' : 'Start Tracking' }}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</form>
|
||||||
</form>
|
</transition>
|
||||||
|
|
||||||
<!-- List -->
|
<!-- Radar List -->
|
||||||
<div v-if="competitors.length === 0 && !showForm" class="text-center p-12 border border-dashed border-white/10 rounded-xl text-zinc-500">
|
<div v-if="competitors.length === 0 && !showForm" class="text-center py-20 bg-white/[0.01] border-2 border-dashed border-white/5 rounded-[40px] space-y-4">
|
||||||
You are not tracking any competitors.
|
<div class="w-16 h-16 bg-white/[0.03] rounded-3xl mx-auto flex items-center justify-center border border-white/5">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-zinc-700"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="M2 12h2"/><path d="M20 12h2"/></svg>
|
||||||
|
</div>
|
||||||
|
<p class="text-zinc-500 font-bold tracking-tight">Radar is currently silent. No competitors added.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 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 v-for="c in competitors" :key="c.id" class="group bg-white/[0.02] border border-white/5 rounded-[24px] p-4 flex flex-col justify-between transition-apple hover:bg-white/[0.04] relative overflow-hidden">
|
||||||
|
|
||||||
<div class="flex justify-between items-start mb-4">
|
<div class="flex justify-between items-start mb-4 z-10">
|
||||||
<div class="flex items-center gap-3">
|
<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">
|
<div class="w-10 h-10 rounded-xl bg-blue-500/10 border border-blue-500/20 flex items-center justify-center text-blue-500 group-hover:scale-110 transition-apple shrink-0">
|
||||||
<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>
|
<svg v-if="c.platform === 'twitter'" width="18" height="18" 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>
|
||||||
|
<svg v-else width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="M2 12h2"/><path d="M20 12h2"/></svg>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="min-w-0">
|
||||||
<h4 class="text-sm font-semibold text-white">{{ c.competitor_name }}</h4>
|
<h4 class="text-sm font-black tracking-tight group-hover:text-blue-400 transition-apple truncate">{{ c.competitor_name }}</h4>
|
||||||
<p v-if="c.target_handle" class="text-xs text-zinc-500">@{{ c.target_handle }}</p>
|
<p v-if="c.target_handle" class="text-[9px] font-bold text-zinc-600 mt-0.5 tracking-tight font-mono">@{{ c.target_handle }}</p>
|
||||||
</div>
|
</div>
|
||||||
</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 class="flex items-center gap-1.5 px-2 py-0.5 bg-emerald-500/10 rounded-full border border-emerald-500/20">
|
||||||
|
<div class="w-1 h-1 rounded-full bg-emerald-500 animate-pulse"></div>
|
||||||
|
<span class="text-[7px] font-black text-emerald-400 uppercase tracking-widest">Active</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-1">
|
<div class="space-y-2 z-10">
|
||||||
<p class="text-[10px] text-zinc-500 uppercase tracking-wider font-semibold">Keywords Targeted</p>
|
|
||||||
<div class="flex flex-wrap gap-1.5">
|
<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">
|
<span v-for="kw in c.keywords.split(',')" :key="kw" class="px-2 py-0.5 bg-black/40 text-zinc-500 text-[10px] font-bold rounded border border-white/5 transition-apple group-hover:border-blue-500/30 group-hover:text-zinc-300">
|
||||||
{{ kw.trim() }}
|
{{ kw.trim() }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<button @click="deleteCompetitor(c.id)" class="absolute bottom-2 right-2 p-3 text-zinc-800 hover:text-rose-500 transition-apple active:scale-90 z-20">
|
||||||
<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>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><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>
|
</button>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* No scoped styles needed as we use utility classes for maximum performance and build stability */
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,80 +1,3 @@
|
|||||||
<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">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
|
|
||||||
@@ -102,7 +25,7 @@ const error = ref('')
|
|||||||
|
|
||||||
const loadHotTweets = async () => {
|
const loadHotTweets = async () => {
|
||||||
if (!props.token) {
|
if (!props.token) {
|
||||||
error.value = 'Unauthenticated. Please login.'
|
error.value = 'Unauthenticated intelligence profile. Please synchronize.'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,21 +39,25 @@ const loadHotTweets = async () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (response.status === 429) {
|
||||||
|
error.value = '请求频率达到上限,请稍后再试。Free 用户每日限额 10 次 API 调用。'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Failed to load hot opportunities (${response.status})`)
|
throw new Error(`Opportunity discovery failed (${response.status})`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
tweets.value = data || []
|
tweets.value = data || []
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
error.value = err.message || 'Network error while fetching tweets.'
|
error.value = err.message || 'Network connection error.'
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const openTweet = (handle: string, tweetId: string) => {
|
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}`
|
const url = `https://twitter.com/${handle}/status/${tweetId}`
|
||||||
chrome.tabs.create({ url })
|
chrome.tabs.create({ url })
|
||||||
}
|
}
|
||||||
@@ -139,3 +66,93 @@ onMounted(() => {
|
|||||||
loadHotTweets()
|
loadHotTweets()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="h-full flex flex-col space-y-6 animate-in fade-in duration-700">
|
||||||
|
<header class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<h2 class="text-sm font-black uppercase tracking-widest text-zinc-400">Opportunities</h2>
|
||||||
|
<div class="px-2 py-0.5 bg-orange-500/10 text-orange-500 border border-orange-500/20 rounded-full text-[8px] font-black uppercase tracking-widest animate-pulse font-mono">Live</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
@click="loadHotTweets"
|
||||||
|
class="p-2 bg-white/[0.03] border border-white/10 rounded-xl hover:bg-white/[0.08] transition-apple active:scale-95 group shadow-xl"
|
||||||
|
title="Refresh Radar"
|
||||||
|
>
|
||||||
|
<svg :class="['w-4 h-4 text-zinc-500 group-hover:text-white transition-apple', loading ? 'animate-spin' : '']" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
|
||||||
|
<path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Notifications -->
|
||||||
|
<transition enter-active-class="transition duration-300 transform" enter-from-class="opacity-0 -translate-y-2" enter-to-class="opacity-100 translate-y-0">
|
||||||
|
<div v-if="error" class="bg-rose-500/10 border border-rose-500/20 text-rose-400 p-4 rounded-[20px] flex items-center gap-3 font-bold text-xs">
|
||||||
|
<span class="text-base font-normal">⚠️</span>
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
|
||||||
|
<!-- Empty State -->
|
||||||
|
<div v-if="!loading && tweets.length === 0" class="flex-1 flex flex-col items-center justify-center text-center py-20 bg-white/[0.01] border-2 border-dashed border-white/5 rounded-[40px] space-y-6">
|
||||||
|
<div class="w-20 h-20 bg-white/[0.03] rounded-[32px] flex items-center justify-center border border-white/5 shadow-2xl">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="text-zinc-700"><path d="M3 12c.5-2 2.5-4.5 4.5-4.5S11 10 11 12s2 4.5 4.5 4.5S20.5 14 21 12"/><path d="M3 12h18"/><path d="M9 12a3 3 0 1 0 6 0 3 3 0 1 0-6 0Z"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<h3 class="text-white font-black tracking-tight text-lg">Radar Scanning...</h3>
|
||||||
|
<p class="text-sm text-zinc-600 max-w-sm font-medium leading-relaxed">
|
||||||
|
Autonomous agents are currently mapping X threads. High-momentum opportunities will appear here momentarily.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Feed Grid -->
|
||||||
|
<div v-else class="grid gap-4 grid-cols-1">
|
||||||
|
<div v-for="tweet in tweets" :key="tweet.id" class="group bg-white/[0.02] border border-white/5 hover:border-orange-500/20 hover:bg-white/[0.04] transition-apple rounded-[24px] overflow-hidden p-5 flex flex-col justify-between shadow-xl relative">
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<header class="flex justify-between items-start mb-4">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="w-8 h-8 rounded-full bg-black/40 border border-white/5 flex items-center justify-center text-[9px] font-black font-mono text-zinc-500 uppercase">
|
||||||
|
{{ tweet.author_handle.slice(0, 2) }}
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<span class="font-black text-sm text-white tracking-tight group-hover:text-orange-400 transition-apple">@{{ tweet.author_handle }}</span>
|
||||||
|
<span class="text-[8px] text-zinc-600 font-black uppercase tracking-widest">{{ new Date(tweet.posted_at).toLocaleDateString() }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1.5 px-2 py-0.5 bg-rose-500/10 border border-rose-500/20 rounded-full">
|
||||||
|
<div class="w-1 h-1 rounded-full bg-rose-500"></div>
|
||||||
|
<span class="text-[8px] font-black text-rose-400 uppercase tracking-widest">{{ tweet.heat_score }}</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<p class="text-zinc-400 text-[12px] leading-relaxed mb-4 font-medium line-clamp-3 relative group-hover:text-zinc-300 transition-apple">
|
||||||
|
"{{ tweet.content }}"
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="flex items-center justify-between border-t border-white/5 pt-4 mt-2">
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<div class="flex items-center gap-1 text-zinc-600 group-hover:text-zinc-500 transition-apple font-bold text-[9px]">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
||||||
|
{{ tweet.reply_count }}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1 text-zinc-600 group-hover:text-zinc-500 transition-apple font-bold text-[9px]">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z"/></svg>
|
||||||
|
{{ tweet.like_count }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
@click="openTweet(tweet.author_handle, tweet.x_tweet_id)"
|
||||||
|
class="px-4 py-2 bg-zinc-800 hover:bg-orange-500 text-white text-[9px] font-black uppercase tracking-widest rounded-lg transition-apple shadow-lg active:scale-95"
|
||||||
|
>
|
||||||
|
Engage ↗
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import HotTweets from './HotTweets.vue'
|
|||||||
|
|
||||||
const token = ref('')
|
const token = ref('')
|
||||||
const isLoading = ref(true)
|
const isLoading = ref(true)
|
||||||
const activeTab = ref('profile') // 'profile', 'strategies', 'competitors'
|
const activeTab = ref('profile')
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
chrome.storage.local.get(['jwt_token'], (res) => {
|
chrome.storage.local.get(['jwt_token'], (res) => {
|
||||||
@@ -31,93 +31,133 @@ const logout = () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="min-h-screen flex flex-col items-center pt-20 pb-10 font-sans">
|
<div class="min-h-screen bg-[#09090b] text-[#f8fafc] font-sans selection:bg-rose-500/30 overflow-x-hidden">
|
||||||
|
|
||||||
<div class="w-full max-w-4xl px-6">
|
<!-- Background Decor -->
|
||||||
<div class="mb-10 flex justify-between items-end border-b border-white/10 pb-4">
|
<div class="fixed top-0 left-0 w-full h-full pointer-events-none overflow-hidden -z-10">
|
||||||
<div>
|
<div class="absolute -top-[10%] -left-[10%] w-[40%] h-[40%] bg-rose-900/10 blur-[120px] rounded-full"></div>
|
||||||
<h1 class="text-3xl font-bold tracking-tight bg-gradient-to-r from-violet-500 to-blue-500 bg-clip-text text-transparent">
|
<div class="absolute top-[40%] -right-[5%] w-[30%] h-[50%] bg-blue-900/10 blur-[120px] rounded-full"></div>
|
||||||
InsightReply Dashboard
|
</div>
|
||||||
</h1>
|
|
||||||
<p class="text-zinc-400 mt-2">Configure your custom AI strategies, product context, and monitor radar.</p>
|
<div class="max-w-[1200px] mx-auto px-8 py-12">
|
||||||
|
|
||||||
|
<!-- Premium Header -->
|
||||||
|
<header class="mb-16 flex justify-between items-center animate-in fade-in slide-in-from-top-4 duration-700">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="p-2.5 rounded-xl bg-gradient-to-br from-rose-500 to-rose-600 shadow-lg shadow-rose-900/20">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" class="text-white"><path d="m12 14 4-4"/><path d="M3.34 19a10 10 0 1 1 17.32 0"/></svg>
|
||||||
|
</div>
|
||||||
|
<h1 class="text-4xl font-black tracking-tight bg-gradient-to-br from-white via-white to-zinc-500 bg-clip-text text-transparent">
|
||||||
|
InsightReply <span class="text-rose-500">Dashboard</span>
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<p class="text-zinc-500 font-medium ml-1 flex items-center gap-2">
|
||||||
|
Professional Social Copilot
|
||||||
|
<span class="h-1 w-1 rounded-full bg-zinc-700"></span>
|
||||||
|
<span class="text-zinc-600 text-xs tracking-widest uppercase font-bold">Pro Max Edition</span>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="token" class="flex items-center gap-4">
|
|
||||||
<span class="text-sm text-green-400 flex items-center gap-2">
|
<div v-if="token" class="flex items-center gap-6 bg-white/[0.03] border border-white/5 px-6 py-3 rounded-2xl backdrop-blur-xl">
|
||||||
<div class="w-2 h-2 rounded-full bg-green-400"></div> Connected
|
<div class="flex items-center gap-2.5 px-3 py-1.5 rounded-full bg-emerald-500/10 border border-emerald-500/20">
|
||||||
</span>
|
<div class="w-2 h-2 rounded-full bg-emerald-500 shadow-[0_0_8px_#10b981]"></div>
|
||||||
<button @click="logout" class="text-sm text-zinc-500 hover:text-white transition-colors">Sign out</button>
|
<span class="text-xs font-bold text-emerald-400 uppercase tracking-widest">Active System</span>
|
||||||
|
</div>
|
||||||
|
<button @click="logout" class="text-sm font-bold text-zinc-500 hover:text-rose-400 transition-apple decoration-rose-500/30 underline-offset-4 hover:underline">Disconnect</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div v-if="isLoading" class="flex justify-center py-32">
|
||||||
|
<div class="w-10 h-10 border-2 border-white/10 border-t-rose-500 rounded-full animate-spin"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Auth State -->
|
||||||
|
<div v-else-if="!token" class="flex justify-center py-10 animate-in fade-in zoom-in-95 duration-500">
|
||||||
|
<div class="w-full max-w-md bg-white/[0.02] border border-white/5 backdrop-blur-3xl rounded-[32px] p-10 shadow-2xl space-y-8">
|
||||||
|
<div class="text-center space-y-2">
|
||||||
|
<h2 class="text-2xl font-bold">Welcome Back</h2>
|
||||||
|
<p class="text-zinc-500 font-medium">Connect your account to synchronize your AI personality and custom strategies.</p>
|
||||||
|
</div>
|
||||||
|
<Auth @authenticated="onAuthenticated" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="isLoading" class="flex justify-center p-20">
|
<!-- Main Dashboard Grid -->
|
||||||
<div class="w-8 h-8 border-4 border-white/20 border-t-brand-primary rounded-full animate-spin"></div>
|
<div v-else class="grid grid-cols-[280px_1fr] gap-12 items-start">
|
||||||
</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" />
|
<!-- macOS Style Sidebar -->
|
||||||
</div>
|
<nav class="flex flex-col gap-1 animate-in fade-in slide-in-from-left-4 duration-700">
|
||||||
|
<div class="px-3 mb-4">
|
||||||
<div v-else class="flex gap-8 items-start">
|
<h3 class="text-[10px] font-bold text-zinc-600 uppercase tracking-[0.2em]">Personalization</h3>
|
||||||
|
</div>
|
||||||
<!-- Sidebar Menu -->
|
|
||||||
<div class="w-64 flex flex-col gap-2 shrink-0">
|
<button @click="activeTab = 'profile'"
|
||||||
<button
|
:class="['flex items-center gap-4 px-4 py-3.5 rounded-2xl text-[13px] font-bold transition-apple tracking-tight active:scale-95',
|
||||||
@click="activeTab = 'profile'"
|
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']"
|
? 'bg-rose-500/10 text-rose-500 border border-rose-500/20 shadow-[0_4px_12px_rgba(244,63,94,0.1)]'
|
||||||
>
|
: 'text-zinc-500 hover:text-zinc-200 hover:bg-white/[0.03] border border-transparent']">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" :class="['transition-apple', activeTab === 'profile' ? 'text-rose-500' : '']"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
||||||
Product Profile
|
Product Profile
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
@click="activeTab = 'strategies'"
|
<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']"
|
:class="['flex items-center gap-4 px-4 py-3.5 rounded-2xl text-[13px] font-bold transition-apple tracking-tight active:scale-95',
|
||||||
>
|
activeTab === 'strategies'
|
||||||
Custom Strategies
|
? 'bg-rose-500/10 text-rose-500 border border-rose-500/20 shadow-[0_4px_12px_rgba(244,63,94,0.1)]'
|
||||||
|
: 'text-zinc-500 hover:text-zinc-200 hover:bg-white/[0.03] border border-transparent']">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" :class="['transition-apple', activeTab === 'strategies' ? 'text-rose-500' : '']"><path d="m16 4 4 4-4 4"/><path d="M20 8H4v12"/></svg>
|
||||||
|
AI Strategies
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
@click="activeTab = 'competitors'"
|
<div class="px-3 mt-8 mb-4">
|
||||||
: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']"
|
<h3 class="text-[10px] font-bold text-zinc-600 uppercase tracking-[0.2em]">Market Intelligence</h3>
|
||||||
>
|
</div>
|
||||||
|
|
||||||
|
<button @click="activeTab = 'competitors'"
|
||||||
|
:class="['flex items-center gap-4 px-4 py-3.5 rounded-2xl text-[13px] font-bold transition-apple tracking-tight active:scale-95',
|
||||||
|
activeTab === 'competitors'
|
||||||
|
? 'bg-rose-500/10 text-rose-500 border border-rose-500/20 shadow-[0_4px_12px_rgba(244,63,94,0.1)]'
|
||||||
|
: 'text-zinc-500 hover:text-zinc-200 hover:bg-white/[0.03] border border-transparent']">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" :class="['transition-apple', activeTab === 'competitors' ? 'text-rose-500' : '']"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="M2 12h2"/><path d="M20 12h2"/></svg>
|
||||||
Competitor Radar
|
Competitor Radar
|
||||||
</button>
|
</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 -->
|
<button @click="activeTab = 'hottweets'"
|
||||||
<div class="flex-1 bg-[#171717] border border-white/10 rounded-2xl p-8 shadow-xl min-h-[500px]">
|
:class="['flex items-center gap-4 px-4 py-3.5 rounded-2xl text-[13px] font-bold transition-apple tracking-tight active:scale-95',
|
||||||
<div v-show="activeTab === 'profile'" class="animate-in fade-in duration-300">
|
activeTab === 'hottweets'
|
||||||
<h2 class="text-lg font-semibold mb-6 border-b border-white/10 pb-4">Product Profile Configuration</h2>
|
? 'bg-orange-500/10 text-orange-500 border border-orange-500/20 shadow-[0_4px_12px_rgba(249,115,22,0.1)]'
|
||||||
|
: 'text-zinc-500 hover:text-zinc-200 hover:bg-white/[0.03] border border-transparent']">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" :class="['transition-apple', activeTab === 'hottweets' ? 'text-orange-500' : '']"><path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5Z"/></svg>
|
||||||
|
Growth Opportunities
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Dynamic Content Window -->
|
||||||
|
<main class="bg-white/[0.02] border border-white/5 rounded-[40px] p-12 shadow-2xl backdrop-blur-3xl animate-in fade-in slide-in-from-right-8 duration-1000 min-h-[600px] hover:border-white/10 transition-apple">
|
||||||
|
<div v-show="activeTab === 'profile'" class="animate-in fade-in duration-500">
|
||||||
<Profile :token="token" />
|
<Profile :token="token" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-show="activeTab === 'strategies'" class="animate-in fade-in duration-300">
|
<div v-show="activeTab === 'strategies'" class="animate-in fade-in duration-500">
|
||||||
<h2 class="text-lg font-semibold mb-6 border-b border-white/10 pb-4">Custom Generation Strategies</h2>
|
|
||||||
<Strategies :token="token" />
|
<Strategies :token="token" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-show="activeTab === 'competitors'" class="animate-in fade-in duration-300">
|
<div v-show="activeTab === 'competitors'" class="animate-in fade-in duration-500">
|
||||||
<h2 class="text-lg font-semibold mb-6 border-b border-white/10 pb-4">Competitor Monitoring</h2>
|
|
||||||
<Competitors :token="token" />
|
<Competitors :token="token" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-show="activeTab === 'hottweets'" class="animate-in fade-in duration-300 h-full">
|
<div v-show="activeTab === 'hottweets'" class="animate-in fade-in duration-500 h-full">
|
||||||
<HotTweets :token="token" />
|
<HotTweets :token="token" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</main>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* No scoped styles needed as we use utility classes for maximum performance and build stability */
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const isSaving = ref(false)
|
|||||||
const savedMessage = ref('')
|
const savedMessage = ref('')
|
||||||
const errorMsg = 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)
|
// Default LLM Options
|
||||||
const providers = ['openai', 'anthropic', 'gemini', 'deepseek']
|
const providers = ['openai', 'anthropic', 'gemini', 'deepseek']
|
||||||
|
|
||||||
const form = ref({
|
const form = ref({
|
||||||
@@ -43,7 +43,6 @@ const fetchProfile = async () => {
|
|||||||
default_llm_model: p.default_llm_model || ''
|
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 })
|
chrome.storage.local.set({ relevance_keywords: form.value.relevance_keywords })
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@@ -74,11 +73,10 @@ const saveProfile = async () => {
|
|||||||
throw new Error(data.message || 'Failed to save profile')
|
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 })
|
chrome.storage.local.set({ relevance_keywords: form.value.relevance_keywords })
|
||||||
|
|
||||||
savedMessage.value = 'Profile saved successfully!'
|
savedMessage.value = 'Profile successfully synchronized.'
|
||||||
setTimeout(() => { savedMessage.value = '' }, 3000)
|
setTimeout(() => { savedMessage.value = '' }, 4000)
|
||||||
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
errorMsg.value = err.message
|
errorMsg.value = err.message
|
||||||
@@ -95,121 +93,109 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-if="isLoading" class="p-10 flex justify-center">
|
<div v-if="isLoading" class="py-20 flex justify-center">
|
||||||
<div class="w-6 h-6 border-2 border-white/20 border-t-white rounded-full animate-spin"></div>
|
<div class="w-8 h-8 border-2 border-white/10 border-t-rose-500 rounded-full animate-spin"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form v-else @submit.prevent="saveProfile" class="space-y-6">
|
<div v-else class="space-y-8 animate-in fade-in duration-700">
|
||||||
<div v-if="errorMsg" class="p-3 bg-red-500/10 border border-red-500/20 text-red-400 text-sm rounded-lg">
|
<form @submit.prevent="saveProfile" class="space-y-8">
|
||||||
{{ errorMsg }}
|
|
||||||
</div>
|
<!-- Notifications -->
|
||||||
<div v-if="savedMessage" class="p-3 bg-green-500/10 border border-green-500/20 text-green-400 text-sm rounded-lg">
|
<transition enter-active-class="transition duration-300 transform" enter-from-class="opacity-0 -translate-y-2" enter-to-class="opacity-100 translate-y-0">
|
||||||
{{ savedMessage }}
|
<div v-if="errorMsg" class="p-4 bg-rose-500/10 border border-rose-500/20 text-rose-400 text-xs font-bold rounded-2xl flex items-center gap-3">
|
||||||
</div>
|
<span class="text-base">⚠️</span>
|
||||||
|
{{ errorMsg }}
|
||||||
<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>
|
||||||
<div class="space-y-1.5">
|
</transition>
|
||||||
<label class="text-xs font-medium text-zinc-400">Model Name</label>
|
|
||||||
<input
|
<transition enter-active-class="transition duration-300 transform" enter-from-class="opacity-0 -translate-y-2" enter-to-class="opacity-100 translate-y-0">
|
||||||
v-model="form.default_llm_model"
|
<div v-if="savedMessage" class="p-4 bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 text-xs font-bold rounded-2xl flex items-center gap-3">
|
||||||
type="text"
|
<span class="text-base">✨</span>
|
||||||
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"
|
{{ savedMessage }}
|
||||||
placeholder="e.g. gpt-4o, claude-3-5-sonnet"
|
</div>
|
||||||
/>
|
</transition>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="grid grid-cols-1 gap-6">
|
||||||
|
<div class="space-y-3">
|
||||||
|
<label class="text-[10px] font-bold text-zinc-500 uppercase tracking-[0.2em] ml-1">Product Name</label>
|
||||||
|
<input v-model="form.product_name" required class="w-full bg-black/40 border border-white/5 rounded-2xl px-5 py-3.5 text-sm font-semibold transition-apple focus:outline-none focus:ring-4 focus:ring-rose-500/10 focus:border-rose-500/40 placeholder:text-zinc-700" placeholder="e.g. InsightReply" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<label class="text-[10px] font-bold text-zinc-500 uppercase tracking-[0.2em] ml-1">Industry / Domain</label>
|
||||||
|
<input v-model="form.industry" class="w-full bg-black/40 border border-white/5 rounded-2xl px-5 py-3.5 text-sm font-semibold transition-apple focus:outline-none focus:ring-4 focus:ring-rose-500/10 focus:border-rose-500/40 placeholder:text-zinc-700" placeholder="e.g. AI-Powered Marketing" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<label class="text-[10px] font-bold text-zinc-500 uppercase tracking-[0.2em] ml-1">Target Persona</label>
|
||||||
|
<input v-model="form.target_audience" class="w-full bg-black/40 border border-white/5 rounded-2xl px-5 py-3.5 text-sm font-semibold transition-apple focus:outline-none focus:ring-4 focus:ring-rose-500/10 focus:border-rose-500/40 placeholder:text-zinc-700" placeholder="e.g. Founders & Indie Hackers on X" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="pt-4 flex justify-end">
|
<!-- Knowledge Base Section -->
|
||||||
<button
|
<section class="space-y-8 pt-4">
|
||||||
type="submit"
|
<div class="space-y-3">
|
||||||
:disabled="isSaving"
|
<label class="text-[10px] font-bold text-zinc-500 uppercase tracking-[0.2em] ml-1 flex items-center gap-2">
|
||||||
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"
|
Value Propositions
|
||||||
>
|
<span class="text-[9px] text-zinc-700 font-black lowercase tracking-normal bg-zinc-900 px-2 py-0.5 rounded border border-white/5 italic">essential knowledge</span>
|
||||||
<span v-if="isSaving" class="w-4 h-4 border-2 border-white/20 border-t-white rounded-full animate-spin"></span>
|
</label>
|
||||||
{{ isSaving ? 'Saving...' : 'Save Profile' }}
|
<textarea v-model="form.core_features" rows="4" class="w-full bg-black/40 border border-white/5 rounded-2xl px-5 py-3.5 text-sm font-semibold transition-apple focus:outline-none focus:ring-4 focus:ring-rose-500/10 focus:border-rose-500/40 placeholder:text-zinc-700 resize-none py-4" placeholder="Distill 3-5 unique features that set your product apart."></textarea>
|
||||||
</button>
|
</div>
|
||||||
</div>
|
|
||||||
</form>
|
<div class="space-y-3">
|
||||||
|
<label class="text-[10px] font-bold text-zinc-500 uppercase tracking-[0.2em] ml-1">The "Hook" / Conversation Intent</label>
|
||||||
|
<textarea v-model="form.user_intent" rows="3" class="w-full bg-black/40 border border-white/5 rounded-2xl px-5 py-3.5 text-sm font-semibold transition-apple focus:outline-none focus:ring-4 focus:ring-rose-500/10 focus:border-rose-500/40 placeholder:text-zinc-700 resize-none py-4" placeholder="How do you want to position yourself in replies? Helpful advisor, sharp critic, etc."></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<label class="text-[10px] font-bold text-zinc-500 uppercase tracking-[0.2em] ml-1">Radar Keywords <span class="text-zinc-700">(comma-separated)</span></label>
|
||||||
|
<input v-model="form.relevance_keywords" class="w-full bg-black/40 border border-white/5 rounded-2xl px-5 py-3.5 text-sm font-semibold transition-apple focus:outline-none focus:ring-4 focus:ring-rose-500/10 focus:border-rose-500/40 placeholder:text-zinc-700" placeholder="e.g. marketing, automation, twitter growth, productivity" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- System Logic Section -->
|
||||||
|
<section class="pt-8 border-t border-white/5 space-y-6">
|
||||||
|
<header class="space-y-1">
|
||||||
|
<h3 class="text-xs font-bold text-zinc-400 uppercase tracking-[0.1em]">Engine Overrides</h3>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-6">
|
||||||
|
<div class="space-y-3">
|
||||||
|
<label class="text-[10px] font-bold text-zinc-500 uppercase tracking-[0.2em] ml-1">LLM Provider</label>
|
||||||
|
<div class="relative">
|
||||||
|
<select v-model="form.default_llm_provider" class="w-full bg-black/40 border border-white/5 rounded-2xl px-5 py-3.5 text-sm font-semibold transition-apple focus:outline-none focus:ring-4 focus:ring-rose-500/10 focus:border-rose-500/40 placeholder:text-zinc-700 appearance-none cursor-pointer">
|
||||||
|
<option value="">Global Default</option>
|
||||||
|
<option v-for="p in providers" :key="p" :value="p">{{ p.charAt(0).toUpperCase() + p.slice(1) }}</option>
|
||||||
|
</select>
|
||||||
|
<div class="absolute right-5 top-1/2 -translate-y-1/2 pointer-events-none text-zinc-600">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<label class="text-[10px] font-bold text-zinc-500 uppercase tracking-[0.2em] ml-1">Model Override</label>
|
||||||
|
<input v-model="form.default_llm_model" class="w-full bg-black/40 border border-white/5 rounded-2xl px-5 py-3.5 text-sm font-semibold transition-apple focus:outline-none focus:ring-4 focus:ring-rose-500/10 focus:border-rose-500/40 placeholder:text-zinc-700" placeholder="e.g. gpt-4o" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Action -->
|
||||||
|
<footer class="pt-4 pb-8">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
:disabled="isSaving"
|
||||||
|
class="w-full py-4 bg-rose-500 hover:bg-rose-400 text-white rounded-2xl text-sm font-bold transition-apple shadow-xl shadow-rose-900/40 flex items-center justify-center gap-3 active:scale-95 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<div v-if="isSaving" class="w-4 h-4 border-2 border-white/20 border-t-white rounded-full animate-spin"></div>
|
||||||
|
<svg v-else xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" class="text-rose-100"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
||||||
|
{{ isSaving ? 'Committing...' : 'Save Product DNA' }}
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* No scoped styles needed as we use utility classes for maximum performance and build stability */
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -50,7 +50,6 @@ const createStrategy = async () => {
|
|||||||
throw new Error(data.message || 'Failed to create strategy')
|
throw new Error(data.message || 'Failed to create strategy')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset form & reload
|
|
||||||
form.value = { strategy_key: '', label: '', description: '' }
|
form.value = { strategy_key: '', label: '', description: '' }
|
||||||
showForm.value = false
|
showForm.value = false
|
||||||
await fetchStrategies()
|
await fetchStrategies()
|
||||||
@@ -84,75 +83,84 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="space-y-6">
|
<div class="space-y-8 animate-in fade-in duration-700">
|
||||||
|
|
||||||
<div v-if="isLoading" class="p-10 flex justify-center">
|
<div v-if="!showForm" class="flex justify-end">
|
||||||
<div class="w-6 h-6 border-2 border-white/20 border-t-white rounded-full animate-spin"></div>
|
<button
|
||||||
|
@click="showForm = true"
|
||||||
|
class="w-full py-4 bg-rose-500 hover:bg-rose-400 text-white rounded-2xl text-sm font-bold transition-apple shadow-lg shadow-rose-900/20 active:scale-95 flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/><path d="M12 5v14"/></svg>
|
||||||
|
New Prompt Angle
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else>
|
<div v-if="isLoading" class="py-20 flex justify-center">
|
||||||
<div class="flex justify-between items-center mb-6">
|
<div class="w-8 h-8 border-2 border-white/10 border-t-rose-500 rounded-full animate-spin"></div>
|
||||||
<p class="text-sm text-zinc-400">These custom prompt angles will appear dynamically in your Twitter sidebar.</p>
|
</div>
|
||||||
<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 -->
|
<div v-else class="space-y-6">
|
||||||
<form v-if="showForm" @submit.prevent="createStrategy" class="bg-white/5 border border-white/10 rounded-xl p-6 space-y-4 mb-8">
|
<!-- Create Form Modal View -->
|
||||||
<div class="flex justify-between items-center mb-2">
|
<transition enter-active-class="transition duration-500 ease-out transform" enter-from-class="opacity-0 scale-95 -translate-y-4" enter-to-class="opacity-100 scale-100 translate-y-0">
|
||||||
<h3 class="text-sm font-semibold text-white">New Strategy</h3>
|
<form v-if="showForm" @submit.prevent="createStrategy" class="bg-white/[0.03] border border-white/5 rounded-[32px] p-6 space-y-6 shadow-2xl relative">
|
||||||
<button type="button" @click="showForm = false" class="text-zinc-500 hover:text-white">✕</button>
|
<div class="flex justify-between items-center">
|
||||||
</div>
|
<h3 class="text-base font-bold">Configure Angle</h3>
|
||||||
|
<button type="button" @click="showForm = false" class="p-2 rounded-full hover:bg-white/5 transition-apple">
|
||||||
<div v-if="errorMsg" class="p-3 bg-red-500/10 border border-red-500/20 text-red-400 text-xs rounded-lg">
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12"/></svg>
|
||||||
{{ errorMsg }}
|
</button>
|
||||||
</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>
|
||||||
<div class="space-y-1.5">
|
|
||||||
<label class="text-[11px] font-medium text-zinc-400 uppercase tracking-widest">Display Label (Sidebar)</label>
|
<div v-if="errorMsg" class="p-4 bg-rose-500/10 border border-rose-500/20 text-rose-400 text-xs font-bold rounded-xl">
|
||||||
<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" />
|
{{ errorMsg }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-1.5">
|
<div class="grid grid-cols-1 gap-6">
|
||||||
<label class="text-[11px] font-medium text-zinc-400 uppercase tracking-widest">Prompt Instructions</label>
|
<div class="space-y-3">
|
||||||
<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>
|
<label class="text-[10px] font-bold text-zinc-500 uppercase tracking-[0.2em] ml-1">Internal key</label>
|
||||||
</div>
|
<input v-model="form.strategy_key" required class="w-full bg-black/40 border border-white/5 rounded-2xl px-5 py-3.5 text-sm font-semibold transition-apple focus:outline-none focus:ring-4 focus:ring-rose-500/10 focus:border-rose-500/40 placeholder:text-zinc-700 font-mono" placeholder="e.g. vc_advisor" />
|
||||||
|
</div>
|
||||||
<div class="flex justify-end pt-2">
|
<div class="space-y-3">
|
||||||
<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">
|
<label class="text-[10px] font-bold text-zinc-500 uppercase tracking-[0.2em] ml-1">Display Label</label>
|
||||||
<span v-if="isSubmitting" class="w-3 h-3 border-2 border-white/20 border-t-white rounded-full animate-spin"></span>
|
<input v-model="form.label" required class="w-full bg-black/40 border border-white/5 rounded-2xl px-5 py-3.5 text-sm font-semibold transition-apple focus:outline-none focus:ring-4 focus:ring-rose-500/10 focus:border-rose-500/40 placeholder:text-zinc-700" placeholder="e.g. VC Advisor" />
|
||||||
{{ 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>
|
</div>
|
||||||
<p class="text-xs text-zinc-400 leading-relaxed max-w-2xl">{{ s.description }}</p>
|
|
||||||
</div>
|
</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>
|
<div class="space-y-3">
|
||||||
|
<label class="text-[10px] font-bold text-zinc-500 uppercase tracking-[0.2em] ml-1">Instructions</label>
|
||||||
|
<textarea v-model="form.description" required rows="4" class="w-full bg-black/40 border border-white/5 rounded-2xl px-5 py-3.5 text-sm font-semibold transition-apple focus:outline-none focus:ring-4 focus:ring-rose-500/10 focus:border-rose-500/40 placeholder:text-zinc-700 resize-none py-4" placeholder="Be authoritative yet encouraging..."></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" :disabled="isSubmitting" class="w-full py-4 bg-rose-500 hover:bg-rose-400 text-white rounded-2xl text-sm font-bold transition-apple shadow-xl shadow-rose-900/30 flex items-center justify-center gap-3 active:scale-95 disabled:opacity-50">
|
||||||
|
<div v-if="isSubmitting" class="w-4 h-4 border-2 border-white/20 border-t-white rounded-full animate-spin"></div>
|
||||||
|
{{ isSubmitting ? 'Architecting...' : 'Deploy Strategy' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</transition>
|
||||||
|
|
||||||
|
<!-- Strategy List -->
|
||||||
|
<div v-if="strategies.length === 0 && !showForm" class="text-center py-20 bg-white/[0.01] border-2 border-dashed border-white/5 rounded-[40px] space-y-4">
|
||||||
|
<div class="w-16 h-16 bg-white/[0.03] rounded-3xl mx-auto flex items-center justify-center border border-white/5">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-zinc-700"><path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z"/></svg>
|
||||||
|
</div>
|
||||||
|
<p class="text-zinc-500 font-bold tracking-tight">No custom strategies deployed yet.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-4">
|
||||||
|
<div v-for="s in strategies" :key="s.id" class="group bg-white/[0.02] border border-white/5 rounded-[24px] p-4 flex justify-between items-center transition-apple hover:bg-white/[0.04]">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<div class="w-10 h-10 bg-rose-500/5 border border-rose-500/10 rounded-xl flex items-center justify-center text-rose-500 transition-apple shrink-0">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-sm font-black tracking-tight truncate group-hover:text-rose-400 transition-apple">{{ s.label }}</span>
|
||||||
|
<span class="text-[8px] font-black font-mono text-zinc-600 bg-black/40 px-1.5 py-0.5 rounded border border-white/5 uppercase tracking-tighter">{{ s.strategy_key }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-[11px] text-zinc-500 leading-relaxed font-medium truncate group-hover:text-zinc-400 transition-apple">{{ s.description }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button @click="deleteStrategy(s.id)" class="p-3 rounded-xl text-zinc-700 hover:text-rose-500 transition-apple active:scale-95">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -160,3 +168,7 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* No scoped styles needed as we use utility classes for maximum performance and build stability */
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,79 +1,54 @@
|
|||||||
:root {
|
:root {
|
||||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
--apple-bg-base: #09090b;
|
||||||
|
--apple-bg-translucent: rgba(9, 9, 11, 0.7);
|
||||||
|
--apple-border: rgba(255, 255, 255, 0.08);
|
||||||
|
--apple-text-primary: #f8fafc;
|
||||||
|
--apple-text-secondary: #94a3b8;
|
||||||
|
--apple-accent-rose: #881337;
|
||||||
|
--apple-accent-blue: #3b82f6;
|
||||||
|
--apple-blur: blur(20px) saturate(180%);
|
||||||
|
|
||||||
|
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
|
|
||||||
color-scheme: light dark;
|
|
||||||
color: rgba(255, 255, 255, 0.87);
|
|
||||||
background-color: #242424;
|
|
||||||
|
|
||||||
font-synthesis: none;
|
|
||||||
text-rendering: optimizeLegibility;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
font-weight: 500;
|
|
||||||
color: #646cff;
|
|
||||||
text-decoration: inherit;
|
|
||||||
}
|
|
||||||
a:hover {
|
|
||||||
color: #535bf2;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
display: flex;
|
padding: 0;
|
||||||
place-items: center;
|
background-color: transparent; /* Allows glassmorphism */
|
||||||
min-width: 320px;
|
color: var(--apple-text-primary);
|
||||||
min-height: 100vh;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
/* Custom Scrollbar - Apple Style */
|
||||||
font-size: 3.2em;
|
::-webkit-scrollbar {
|
||||||
line-height: 1.1;
|
width: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
::-webkit-scrollbar-track {
|
||||||
border-radius: 8px;
|
background: transparent;
|
||||||
border: 1px solid transparent;
|
|
||||||
padding: 0.6em 1.2em;
|
|
||||||
font-size: 1em;
|
|
||||||
font-weight: 500;
|
|
||||||
font-family: inherit;
|
|
||||||
background-color: #1a1a1a;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: border-color 0.25s;
|
|
||||||
}
|
|
||||||
button:hover {
|
|
||||||
border-color: #646cff;
|
|
||||||
}
|
|
||||||
button:focus,
|
|
||||||
button:focus-visible {
|
|
||||||
outline: 4px auto -webkit-focus-ring-color;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.card {
|
::-webkit-scrollbar-thumb {
|
||||||
padding: 2em;
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#app {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
max-width: 1280px;
|
background: rgba(255, 255, 255, 0.2);
|
||||||
margin: 0 auto;
|
|
||||||
padding: 2rem;
|
|
||||||
text-align: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: light) {
|
/* Base Transitions */
|
||||||
:root {
|
.transition-apple {
|
||||||
color: #213547;
|
transition: all 400ms cubic-bezier(0.23, 1, 0.32, 1);
|
||||||
background-color: #ffffff;
|
}
|
||||||
}
|
|
||||||
a:hover {
|
/* Glass Card */
|
||||||
color: #747bff;
|
.glass-card {
|
||||||
}
|
background: var(--apple-bg-translucent);
|
||||||
button {
|
backdrop-filter: var(--apple-blur);
|
||||||
background-color: #f9f9f9;
|
-webkit-backdrop-filter: var(--apple-blur);
|
||||||
}
|
border: 1px solid var(--apple-border);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ globs: *.ts, *.tsx, *.vue, *.css, *.html
|
|||||||
---
|
---
|
||||||
# InsightReply 前端 UI/UX 编码规范与实现方案
|
# InsightReply 前端 UI/UX 编码规范与实现方案
|
||||||
|
|
||||||
本文档基于 `ui-ux-pro-max` 高级设计理念以及现代前端工程化标准,定义了 InsightReply 浏览器插件和 Web 后台的开发规范。
|
本文档基于 /Users/zs/.agents/skills `ui-ux-pro-max` 高级设计理念以及现代前端工程化标准,定义了 InsightReply 浏览器插件和 Web 后台的开发规范。
|
||||||
|
|
||||||
## 一、 核心视觉原则 (ui-ux-pro-max 特性)
|
## 一、 核心视觉原则 (ui-ux-pro-max 特性)
|
||||||
|
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ func main() {
|
|||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
r.Use(chiMiddleware.Logger)
|
r.Use(chiMiddleware.Logger)
|
||||||
r.Use(chiMiddleware.Recoverer)
|
r.Use(chiMiddleware.Recoverer)
|
||||||
|
r.Use(chiMiddleware.StripSlashes)
|
||||||
|
|
||||||
// CORS Configuration
|
// CORS Configuration
|
||||||
corsOrigins := os.Getenv("CORS_ORIGINS")
|
corsOrigins := os.Getenv("CORS_ORIGINS")
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package handler
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -53,6 +54,7 @@ func (h *AIHandler) Generate(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
userID := ctx.Value("userID").(string)
|
userID := ctx.Value("userID").(string)
|
||||||
|
log.Printf("[AIHandler] Generate request from userID=%s strategy=%q provider=%q model=%q", userID, body.Strategy, body.Provider, body.Model)
|
||||||
|
|
||||||
// Fetch Product Profile Context
|
// Fetch Product Profile Context
|
||||||
var productContext string
|
var productContext string
|
||||||
@@ -79,9 +81,11 @@ func (h *AIHandler) Generate(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
replyString, err := h.svc.GenerateReply(ctx, body.TweetContent, productContext, body.Identity, body.Provider, body.Model)
|
replyString, err := h.svc.GenerateReply(ctx, body.TweetContent, productContext, body.Identity, body.Provider, body.Model)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Printf("[AIHandler] ERROR GenerateReply for userID=%s: %v", userID, err)
|
||||||
SendError(w, http.StatusBadGateway, 5002, "Failed to generate AI reply: "+err.Error())
|
SendError(w, http.StatusBadGateway, 5002, "Failed to generate AI reply: "+err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
log.Printf("[AIHandler] GenerateReply success for userID=%s, reply length=%d", userID, len(replyString))
|
||||||
|
|
||||||
// Clean up potential markdown wrappers from LLM output
|
// Clean up potential markdown wrappers from LLM output
|
||||||
cleanReply := strings.TrimSpace(replyString)
|
cleanReply := strings.TrimSpace(replyString)
|
||||||
|
|||||||
59
server/internal/handler/router_test.go
Normal file
59
server/internal/handler/router_test.go
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
package handler_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRouterStripSlashes(t *testing.T) {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Use(middleware.StripSlashes)
|
||||||
|
|
||||||
|
r.Route("/api/v1", func(r chi.Router) {
|
||||||
|
r.Post("/auth/login", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{"code":200}`))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
method string
|
||||||
|
url string
|
||||||
|
expectedStatus int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Login without trailing slash",
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/v1/auth/login",
|
||||||
|
expectedStatus: http.StatusOK,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Login with trailing slash",
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/v1/auth/login/",
|
||||||
|
expectedStatus: http.StatusOK,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
req, _ := http.NewRequest(tt.method, tt.url, nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != tt.expectedStatus {
|
||||||
|
t.Errorf("expected status %v, got %v for %s", tt.expectedStatus, rr.Code, tt.url)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it redirected (it shouldn't with StripSlashes middleware correctly placed)
|
||||||
|
if rr.Code == http.StatusPermanentRedirect || rr.Code == http.StatusMovedPermanently {
|
||||||
|
t.Errorf("got redirect %v for %s", rr.Code, tt.url)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package handler
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/zs/InsightReply/internal/repository"
|
"github.com/zs/InsightReply/internal/repository"
|
||||||
@@ -17,15 +18,18 @@ func NewTweetHandler(repo *repository.TweetRepository) *TweetHandler {
|
|||||||
|
|
||||||
// GetHotTweets returns the top heating tweets spanning across all tracking targets
|
// GetHotTweets returns the top heating tweets spanning across all tracking targets
|
||||||
func (h *TweetHandler) GetHotTweets(w http.ResponseWriter, r *http.Request) {
|
func (h *TweetHandler) GetHotTweets(w http.ResponseWriter, r *http.Request) {
|
||||||
// Standardize to take the top 50 hottest tweets that haven't been manually marked as processed
|
log.Printf("[TweetHandler] GetHotTweets called from %s", r.RemoteAddr)
|
||||||
|
|
||||||
tweets, err := h.repo.GetTopHeatingTweets(50)
|
tweets, err := h.repo.GetTopHeatingTweets(50)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Printf("[TweetHandler] ERROR GetTopHeatingTweets: %v", err)
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
json.NewEncoder(w).Encode(map[string]interface{}{"error": "failed to retrieve hot tweets"})
|
json.NewEncoder(w).Encode(map[string]interface{}{"error": "failed to retrieve hot tweets"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Printf("[TweetHandler] GetHotTweets returning %d tweets", len(tweets))
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(tweets)
|
json.NewEncoder(w).Encode(tweets)
|
||||||
}
|
}
|
||||||
@@ -34,15 +38,18 @@ func (h *TweetHandler) GetHotTweets(w http.ResponseWriter, r *http.Request) {
|
|||||||
func (h *TweetHandler) GetSearchTweets(w http.ResponseWriter, r *http.Request) {
|
func (h *TweetHandler) GetSearchTweets(w http.ResponseWriter, r *http.Request) {
|
||||||
keyword := r.URL.Query().Get("keyword")
|
keyword := r.URL.Query().Get("keyword")
|
||||||
handle := r.URL.Query().Get("handle")
|
handle := r.URL.Query().Get("handle")
|
||||||
|
log.Printf("[TweetHandler] SearchTweets called: keyword=%q handle=%q", keyword, handle)
|
||||||
|
|
||||||
tweets, err := h.repo.SearchTweets(keyword, handle, 50)
|
tweets, err := h.repo.SearchTweets(keyword, handle, 50)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Printf("[TweetHandler] ERROR SearchTweets: %v", err)
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
json.NewEncoder(w).Encode(map[string]interface{}{"error": "failed to search tweets"})
|
json.NewEncoder(w).Encode(map[string]interface{}{"error": "failed to search tweets"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Printf("[TweetHandler] SearchTweets returning %d tweets", len(tweets))
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(tweets)
|
json.NewEncoder(w).Encode(tweets)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package handler
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/zs/InsightReply/internal/service"
|
"github.com/zs/InsightReply/internal/service"
|
||||||
@@ -29,7 +30,8 @@ func (h *UserHandler) Register(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
user, err := h.svc.Register(body.Email, body.Password, body.Identity)
|
user, err := h.svc.Register(body.Email, body.Password, body.Identity)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
SendError(w, http.StatusInternalServerError, 5001, "Failed to register user")
|
log.Printf("[Register] Failed to register user %s: %v", body.Email, err)
|
||||||
|
SendError(w, http.StatusInternalServerError, 5001, "Failed to register user: "+err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -49,6 +50,7 @@ func RateLimit(db *gorm.DB) func(http.Handler) http.Handler {
|
|||||||
// For now, fallback to generic tight limit for anonymous usage
|
// For now, fallback to generic tight limit for anonymous usage
|
||||||
ipLimiter := getLimiter(r.RemoteAddr, "Free")
|
ipLimiter := getLimiter(r.RemoteAddr, "Free")
|
||||||
if !ipLimiter.Allow() {
|
if !ipLimiter.Allow() {
|
||||||
|
log.Printf("[RateLimit] 429 for anonymous IP=%s path=%s", r.RemoteAddr, r.URL.Path)
|
||||||
http.Error(w, `{"code":429, "message":"Too Many Requests: Rate limit exceeded"}`, http.StatusTooManyRequests)
|
http.Error(w, `{"code":429, "message":"Too Many Requests: Rate limit exceeded"}`, http.StatusTooManyRequests)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -72,6 +74,7 @@ func RateLimit(db *gorm.DB) func(http.Handler) http.Handler {
|
|||||||
|
|
||||||
limiter := getLimiter(userID, tier)
|
limiter := getLimiter(userID, tier)
|
||||||
if !limiter.Allow() {
|
if !limiter.Allow() {
|
||||||
|
log.Printf("[RateLimit] 429 for userID=%s tier=%s path=%s", userID, tier, r.URL.Path)
|
||||||
http.Error(w, `{"code":429, "message":"Too Many Requests: Daily quota or rate limit exceeded"}`, http.StatusTooManyRequests)
|
http.Error(w, `{"code":429, "message":"Too Many Requests: Daily quota or rate limit exceeded"}`, http.StatusTooManyRequests)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
2
web/.env
2
web/.env
@@ -1 +1 @@
|
|||||||
VITE_API_BASE_URL=http://insight.buildapp.eu.org/api/v1
|
VITE_API_BASE_URL=https://insight.buildapp.eu.org/api/v1
|
||||||
@@ -25,9 +25,11 @@ const handleLogin = async () => {
|
|||||||
if (!res.ok) throw new Error('Invalid credentials')
|
if (!res.ok) throw new Error('Invalid credentials')
|
||||||
|
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (data.token) {
|
if (data.data && data.data.token) {
|
||||||
localStorage.setItem('jwt_token', data.token)
|
localStorage.setItem('jwt_token', data.data.token)
|
||||||
router.push('/dashboard')
|
router.push('/dashboard')
|
||||||
|
} else {
|
||||||
|
throw new Error(data.message || 'Login failed')
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
errorMsg.value = err.message || 'Login failed'
|
errorMsg.value = err.message || 'Login failed'
|
||||||
|
|||||||
7
web/src/vite-env.d.ts
vendored
Normal file
7
web/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
declare module "*.vue" {
|
||||||
|
import type { DefineComponent } from "vue";
|
||||||
|
const component: DefineComponent<{}, {}, any>;
|
||||||
|
export default component;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user