openvolo 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +175 -0
- package/components.json +20 -0
- package/dist/cli.js +992 -0
- package/drizzle.config.ts +14 -0
- package/next.config.mjs +7 -0
- package/package.json +91 -0
- package/postcss.config.mjs +7 -0
- package/public/android-chrome-192x192.png +0 -0
- package/public/android-chrome-512x512.png +0 -0
- package/public/apple-touch-icon.png +0 -0
- package/public/assets/openvolo-logo-black.png +0 -0
- package/public/assets/openvolo-logo-name.png +0 -0
- package/public/assets/openvolo-logo-transparent.png +0 -0
- package/public/favicon-16x16.png +0 -0
- package/public/favicon-32x32.png +0 -0
- package/public/favicon.ico +0 -0
- package/public/site.webmanifest +19 -0
- package/src/app/api/analytics/agents/route.ts +30 -0
- package/src/app/api/analytics/content/route.ts +24 -0
- package/src/app/api/analytics/engagement/route.ts +24 -0
- package/src/app/api/analytics/overview/route.ts +22 -0
- package/src/app/api/analytics/sync-health/route.ts +22 -0
- package/src/app/api/contacts/[id]/identities/[identityId]/route.ts +24 -0
- package/src/app/api/contacts/[id]/identities/route.ts +61 -0
- package/src/app/api/contacts/[id]/route.ts +72 -0
- package/src/app/api/contacts/route.ts +91 -0
- package/src/app/api/content/[id]/route.ts +61 -0
- package/src/app/api/content/route.ts +48 -0
- package/src/app/api/platforms/gmail/auth/route.ts +50 -0
- package/src/app/api/platforms/gmail/callback/route.ts +126 -0
- package/src/app/api/platforms/gmail/route.ts +60 -0
- package/src/app/api/platforms/gmail/sync/route.ts +96 -0
- package/src/app/api/platforms/linkedin/auth/route.ts +40 -0
- package/src/app/api/platforms/linkedin/callback/route.ts +128 -0
- package/src/app/api/platforms/linkedin/import/route.ts +40 -0
- package/src/app/api/platforms/linkedin/route.ts +60 -0
- package/src/app/api/platforms/linkedin/sync/route.ts +85 -0
- package/src/app/api/platforms/x/auth/route.ts +52 -0
- package/src/app/api/platforms/x/browser-session/route.ts +79 -0
- package/src/app/api/platforms/x/callback/route.ts +130 -0
- package/src/app/api/platforms/x/compose/route.ts +247 -0
- package/src/app/api/platforms/x/engage/route.ts +113 -0
- package/src/app/api/platforms/x/enrich/route.ts +79 -0
- package/src/app/api/platforms/x/route.ts +63 -0
- package/src/app/api/platforms/x/sync/route.ts +142 -0
- package/src/app/api/settings/route.ts +43 -0
- package/src/app/api/settings/search-api/route.ts +180 -0
- package/src/app/api/tasks/[id]/route.ts +60 -0
- package/src/app/api/tasks/route.ts +39 -0
- package/src/app/api/workflows/[id]/progress/route.ts +45 -0
- package/src/app/api/workflows/[id]/route.ts +20 -0
- package/src/app/api/workflows/route.ts +30 -0
- package/src/app/api/workflows/run-agent/route.ts +44 -0
- package/src/app/api/workflows/templates/[id]/activate/route.ts +64 -0
- package/src/app/api/workflows/templates/[id]/route.ts +75 -0
- package/src/app/api/workflows/templates/route.ts +60 -0
- package/src/app/dashboard/analytics/analytics-dashboard.tsx +535 -0
- package/src/app/dashboard/analytics/page.tsx +15 -0
- package/src/app/dashboard/contacts/[id]/contact-detail-client.tsx +334 -0
- package/src/app/dashboard/contacts/[id]/page.tsx +21 -0
- package/src/app/dashboard/contacts/contact-list-client.tsx +213 -0
- package/src/app/dashboard/contacts/page.tsx +38 -0
- package/src/app/dashboard/content/[id]/engagement-actions.tsx +167 -0
- package/src/app/dashboard/content/[id]/page.tsx +253 -0
- package/src/app/dashboard/content/content-list-client.tsx +428 -0
- package/src/app/dashboard/content/page.tsx +39 -0
- package/src/app/dashboard/help/page.tsx +1247 -0
- package/src/app/dashboard/layout.tsx +19 -0
- package/src/app/dashboard/page.tsx +187 -0
- package/src/app/dashboard/settings/page.tsx +1664 -0
- package/src/app/dashboard/workflows/[id]/page.tsx +90 -0
- package/src/app/dashboard/workflows/[id]/workflow-detail-steps.tsx +55 -0
- package/src/app/dashboard/workflows/[id]/workflow-run-live.tsx +195 -0
- package/src/app/dashboard/workflows/activate-dialog.tsx +251 -0
- package/src/app/dashboard/workflows/page.tsx +41 -0
- package/src/app/dashboard/workflows/template-gallery.tsx +201 -0
- package/src/app/dashboard/workflows/workflow-quick-actions.tsx +121 -0
- package/src/app/dashboard/workflows/workflow-view-switcher.tsx +62 -0
- package/src/app/globals.css +232 -0
- package/src/app/layout.tsx +57 -0
- package/src/app/page.tsx +5 -0
- package/src/components/add-contact-dialog.tsx +74 -0
- package/src/components/add-task-dialog.tsx +153 -0
- package/src/components/animated-stat.tsx +53 -0
- package/src/components/app-sidebar.tsx +130 -0
- package/src/components/charts/area-chart-card.tsx +99 -0
- package/src/components/charts/bar-chart-card.tsx +128 -0
- package/src/components/charts/chart-skeleton.tsx +43 -0
- package/src/components/charts/donut-chart-card.tsx +100 -0
- package/src/components/charts/ranked-table-card.tsx +127 -0
- package/src/components/charts/stat-cards-row.tsx +45 -0
- package/src/components/compose-dialog.tsx +344 -0
- package/src/components/contact-form.tsx +218 -0
- package/src/components/dashboard-greeting.tsx +27 -0
- package/src/components/dashboard-header.tsx +87 -0
- package/src/components/empty-state.tsx +32 -0
- package/src/components/enrich-button.tsx +107 -0
- package/src/components/enrichment-score-badge.tsx +30 -0
- package/src/components/funnel-stage-badge.tsx +19 -0
- package/src/components/funnel-visualization.tsx +66 -0
- package/src/components/identities-section.tsx +219 -0
- package/src/components/pagination-controls.tsx +115 -0
- package/src/components/platform-connection-card.tsx +292 -0
- package/src/components/priority-badge.tsx +17 -0
- package/src/components/step-output-renderer.tsx +63 -0
- package/src/components/tweet-input.tsx +126 -0
- package/src/components/ui/alert-dialog.tsx +196 -0
- package/src/components/ui/avatar.tsx +109 -0
- package/src/components/ui/badge.tsx +48 -0
- package/src/components/ui/button.tsx +64 -0
- package/src/components/ui/card.tsx +92 -0
- package/src/components/ui/chart.tsx +357 -0
- package/src/components/ui/dialog.tsx +158 -0
- package/src/components/ui/dropdown-menu.tsx +257 -0
- package/src/components/ui/input.tsx +21 -0
- package/src/components/ui/label.tsx +24 -0
- package/src/components/ui/progress.tsx +31 -0
- package/src/components/ui/scroll-area.tsx +58 -0
- package/src/components/ui/select.tsx +190 -0
- package/src/components/ui/separator.tsx +28 -0
- package/src/components/ui/sheet.tsx +143 -0
- package/src/components/ui/sidebar.tsx +726 -0
- package/src/components/ui/skeleton.tsx +13 -0
- package/src/components/ui/table.tsx +116 -0
- package/src/components/ui/tabs.tsx +91 -0
- package/src/components/ui/textarea.tsx +18 -0
- package/src/components/ui/tooltip.tsx +57 -0
- package/src/components/workflow-graph-view.tsx +205 -0
- package/src/components/workflow-kanban-view.tsx +69 -0
- package/src/components/workflow-list-view.tsx +201 -0
- package/src/components/workflow-progress-card.tsx +150 -0
- package/src/components/workflow-run-card.tsx +144 -0
- package/src/components/workflow-step-timeline.tsx +173 -0
- package/src/components/workflow-swimlane-view.tsx +87 -0
- package/src/hooks/use-mobile.ts +19 -0
- package/src/hooks/use-workflow-polling.ts +85 -0
- package/src/lib/agents/router.ts +79 -0
- package/src/lib/agents/run-agent-workflow.ts +605 -0
- package/src/lib/agents/tools/browser-scrape.ts +118 -0
- package/src/lib/agents/tools/enrich-contact.ts +128 -0
- package/src/lib/agents/tools/search-web.ts +473 -0
- package/src/lib/agents/tools/update-progress.ts +40 -0
- package/src/lib/agents/tools/url-fetch.ts +152 -0
- package/src/lib/agents/types.ts +79 -0
- package/src/lib/analytics/utils.ts +33 -0
- package/src/lib/auth/claude-auth.ts +134 -0
- package/src/lib/auth/crypto.ts +58 -0
- package/src/lib/browser/anti-detection.ts +79 -0
- package/src/lib/browser/extractors/profile-merger.ts +71 -0
- package/src/lib/browser/extractors/profile-parser.ts +133 -0
- package/src/lib/browser/platforms/x-scraper.ts +269 -0
- package/src/lib/browser/scraper.ts +92 -0
- package/src/lib/browser/session.ts +229 -0
- package/src/lib/browser/types.ts +80 -0
- package/src/lib/db/client.ts +24 -0
- package/src/lib/db/enrichment.ts +90 -0
- package/src/lib/db/migrate-identities.ts +95 -0
- package/src/lib/db/migrate.ts +33 -0
- package/src/lib/db/migrations/0000_tired_thanos.sql +296 -0
- package/src/lib/db/migrations/meta/0000_snapshot.json +2169 -0
- package/src/lib/db/migrations/meta/_journal.json +13 -0
- package/src/lib/db/queries/analytics.ts +449 -0
- package/src/lib/db/queries/contacts.ts +170 -0
- package/src/lib/db/queries/content.ts +215 -0
- package/src/lib/db/queries/dashboard.ts +79 -0
- package/src/lib/db/queries/engagements.ts +35 -0
- package/src/lib/db/queries/identities.ts +51 -0
- package/src/lib/db/queries/platform-accounts.ts +53 -0
- package/src/lib/db/queries/sync.ts +74 -0
- package/src/lib/db/queries/tasks.ts +88 -0
- package/src/lib/db/queries/workflow-templates.ts +213 -0
- package/src/lib/db/queries/workflows.ts +167 -0
- package/src/lib/db/schema.ts +437 -0
- package/src/lib/db/seed-templates.ts +221 -0
- package/src/lib/db/types.ts +78 -0
- package/src/lib/pagination.ts +12 -0
- package/src/lib/platforms/adapter.ts +75 -0
- package/src/lib/platforms/gmail/adapter.ts +112 -0
- package/src/lib/platforms/gmail/auth.ts +137 -0
- package/src/lib/platforms/gmail/client.ts +255 -0
- package/src/lib/platforms/gmail/mappers.ts +125 -0
- package/src/lib/platforms/gmail/oauth-state-store.ts +65 -0
- package/src/lib/platforms/index.ts +22 -0
- package/src/lib/platforms/linkedin/adapter.ts +164 -0
- package/src/lib/platforms/linkedin/auth.ts +124 -0
- package/src/lib/platforms/linkedin/client.ts +183 -0
- package/src/lib/platforms/linkedin/csv-import.ts +283 -0
- package/src/lib/platforms/linkedin/mappers.ts +123 -0
- package/src/lib/platforms/linkedin/oauth-state-store.ts +65 -0
- package/src/lib/platforms/rate-limiter.ts +88 -0
- package/src/lib/platforms/sync-contacts.ts +121 -0
- package/src/lib/platforms/sync-content.ts +225 -0
- package/src/lib/platforms/sync-gmail-contacts.ts +186 -0
- package/src/lib/platforms/sync-gmail-metadata.ts +158 -0
- package/src/lib/platforms/sync-linkedin-contacts.ts +148 -0
- package/src/lib/platforms/sync-x-profiles.ts +280 -0
- package/src/lib/platforms/x/adapter.ts +129 -0
- package/src/lib/platforms/x/auth.ts +165 -0
- package/src/lib/platforms/x/client.ts +390 -0
- package/src/lib/platforms/x/mappers.ts +134 -0
- package/src/lib/platforms/x/pkce-store.ts +67 -0
- package/src/lib/utils.ts +6 -0
- package/src/lib/workflows/format-error.test.ts +177 -0
- package/src/lib/workflows/format-error.ts +207 -0
- package/src/lib/workflows/run-sync-workflow.ts +141 -0
- package/src/lib/workflows/types.ts +71 -0
- package/tsconfig.json +42 -0
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { useRouter } from "next/navigation";
|
|
5
|
+
import { Card, CardContent } from "@/components/ui/card";
|
|
6
|
+
import { Button } from "@/components/ui/button";
|
|
7
|
+
import { Textarea } from "@/components/ui/textarea";
|
|
8
|
+
import { Heart, Repeat2, MessageCircle, Loader2, Send } from "lucide-react";
|
|
9
|
+
import type { Engagement } from "@/lib/db/types";
|
|
10
|
+
|
|
11
|
+
interface EngagementActionsProps {
|
|
12
|
+
tweetId: string;
|
|
13
|
+
contentPostId: string;
|
|
14
|
+
engagementHistory: Engagement[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
type EngageAction = "like" | "unlike" | "retweet" | "unretweet" | "reply";
|
|
18
|
+
|
|
19
|
+
export function EngagementActions({
|
|
20
|
+
tweetId,
|
|
21
|
+
contentPostId,
|
|
22
|
+
engagementHistory,
|
|
23
|
+
}: EngagementActionsProps) {
|
|
24
|
+
const router = useRouter();
|
|
25
|
+
const [loading, setLoading] = useState<EngageAction | null>(null);
|
|
26
|
+
const [error, setError] = useState<string | null>(null);
|
|
27
|
+
const [showReply, setShowReply] = useState(false);
|
|
28
|
+
const [replyText, setReplyText] = useState("");
|
|
29
|
+
|
|
30
|
+
// Determine initial toggle state from engagement history
|
|
31
|
+
const hasLiked = engagementHistory.some(
|
|
32
|
+
(e) => e.engagementType === "like" && e.source === "manual"
|
|
33
|
+
);
|
|
34
|
+
const hasRetweeted = engagementHistory.some(
|
|
35
|
+
(e) => e.engagementType === "retweet" && e.source === "manual"
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
const [liked, setLiked] = useState(hasLiked);
|
|
39
|
+
const [retweeted, setRetweeted] = useState(hasRetweeted);
|
|
40
|
+
|
|
41
|
+
async function handleEngage(action: EngageAction, text?: string) {
|
|
42
|
+
setLoading(action);
|
|
43
|
+
setError(null);
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
const res = await fetch("/api/platforms/x/engage", {
|
|
47
|
+
method: "POST",
|
|
48
|
+
headers: { "Content-Type": "application/json" },
|
|
49
|
+
body: JSON.stringify({ action, tweetId, contentPostId, text }),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const data = await res.json();
|
|
53
|
+
|
|
54
|
+
if (!res.ok) {
|
|
55
|
+
setError(data.error ?? "Action failed");
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Update optimistic state
|
|
60
|
+
if (action === "like") setLiked(true);
|
|
61
|
+
if (action === "unlike") setLiked(false);
|
|
62
|
+
if (action === "retweet") setRetweeted(true);
|
|
63
|
+
if (action === "unretweet") setRetweeted(false);
|
|
64
|
+
if (action === "reply") {
|
|
65
|
+
setReplyText("");
|
|
66
|
+
setShowReply(false);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Refresh to update engagement history
|
|
70
|
+
router.refresh();
|
|
71
|
+
} catch {
|
|
72
|
+
setError("Action failed. Please try again.");
|
|
73
|
+
} finally {
|
|
74
|
+
setLoading(null);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<Card>
|
|
80
|
+
<CardContent className="pt-6 space-y-3">
|
|
81
|
+
<h3 className="text-sm font-medium">Actions</h3>
|
|
82
|
+
|
|
83
|
+
<div className="flex items-center gap-2">
|
|
84
|
+
{/* Like / Unlike */}
|
|
85
|
+
<Button
|
|
86
|
+
variant={liked ? "default" : "outline"}
|
|
87
|
+
size="sm"
|
|
88
|
+
disabled={loading !== null}
|
|
89
|
+
onClick={() => handleEngage(liked ? "unlike" : "like")}
|
|
90
|
+
>
|
|
91
|
+
{loading === "like" || loading === "unlike" ? (
|
|
92
|
+
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
|
|
93
|
+
) : (
|
|
94
|
+
<Heart
|
|
95
|
+
className={`mr-1 h-4 w-4 ${liked ? "fill-current" : ""}`}
|
|
96
|
+
/>
|
|
97
|
+
)}
|
|
98
|
+
{liked ? "Liked" : "Like"}
|
|
99
|
+
</Button>
|
|
100
|
+
|
|
101
|
+
{/* Retweet / Unretweet */}
|
|
102
|
+
<Button
|
|
103
|
+
variant={retweeted ? "default" : "outline"}
|
|
104
|
+
size="sm"
|
|
105
|
+
disabled={loading !== null}
|
|
106
|
+
onClick={() => handleEngage(retweeted ? "unretweet" : "retweet")}
|
|
107
|
+
>
|
|
108
|
+
{loading === "retweet" || loading === "unretweet" ? (
|
|
109
|
+
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
|
|
110
|
+
) : (
|
|
111
|
+
<Repeat2 className="mr-1 h-4 w-4" />
|
|
112
|
+
)}
|
|
113
|
+
{retweeted ? "Retweeted" : "Retweet"}
|
|
114
|
+
</Button>
|
|
115
|
+
|
|
116
|
+
{/* Reply toggle */}
|
|
117
|
+
<Button
|
|
118
|
+
variant={showReply ? "default" : "outline"}
|
|
119
|
+
size="sm"
|
|
120
|
+
disabled={loading !== null}
|
|
121
|
+
onClick={() => setShowReply(!showReply)}
|
|
122
|
+
>
|
|
123
|
+
<MessageCircle className="mr-1 h-4 w-4" />
|
|
124
|
+
Reply
|
|
125
|
+
</Button>
|
|
126
|
+
</div>
|
|
127
|
+
|
|
128
|
+
{/* Reply input */}
|
|
129
|
+
{showReply && (
|
|
130
|
+
<div className="space-y-2">
|
|
131
|
+
<Textarea
|
|
132
|
+
placeholder="Write your reply..."
|
|
133
|
+
value={replyText}
|
|
134
|
+
onChange={(e) => setReplyText(e.target.value)}
|
|
135
|
+
rows={3}
|
|
136
|
+
className="resize-none"
|
|
137
|
+
/>
|
|
138
|
+
<div className="flex items-center justify-between">
|
|
139
|
+
<span className="text-xs text-muted-foreground">
|
|
140
|
+
{replyText.length}/280
|
|
141
|
+
</span>
|
|
142
|
+
<Button
|
|
143
|
+
size="sm"
|
|
144
|
+
disabled={!replyText.trim() || replyText.length > 280 || loading !== null}
|
|
145
|
+
onClick={() => handleEngage("reply", replyText)}
|
|
146
|
+
>
|
|
147
|
+
{loading === "reply" ? (
|
|
148
|
+
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
|
|
149
|
+
) : (
|
|
150
|
+
<Send className="mr-1 h-4 w-4" />
|
|
151
|
+
)}
|
|
152
|
+
Send Reply
|
|
153
|
+
</Button>
|
|
154
|
+
</div>
|
|
155
|
+
</div>
|
|
156
|
+
)}
|
|
157
|
+
|
|
158
|
+
{/* Error */}
|
|
159
|
+
{error && (
|
|
160
|
+
<div className="rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-800 dark:border-red-800 dark:bg-red-950 dark:text-red-200">
|
|
161
|
+
{error}
|
|
162
|
+
</div>
|
|
163
|
+
)}
|
|
164
|
+
</CardContent>
|
|
165
|
+
</Card>
|
|
166
|
+
);
|
|
167
|
+
}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { notFound } from "next/navigation";
|
|
2
|
+
import Link from "next/link";
|
|
3
|
+
import { getContentItem, getThreadItems } from "@/lib/db/queries/content";
|
|
4
|
+
import { listEngagementsByContentPost } from "@/lib/db/queries/engagements";
|
|
5
|
+
import { getPlatformAccountByPlatform } from "@/lib/db/queries/platform-accounts";
|
|
6
|
+
import { Card, CardContent } from "@/components/ui/card";
|
|
7
|
+
import { Badge } from "@/components/ui/badge";
|
|
8
|
+
import { Button } from "@/components/ui/button";
|
|
9
|
+
import { ArrowLeft, ExternalLink, Heart, MessageCircle, Repeat2, Quote } from "lucide-react";
|
|
10
|
+
import { EngagementActions } from "./engagement-actions";
|
|
11
|
+
|
|
12
|
+
const contentTypeLabels: Record<string, string> = {
|
|
13
|
+
post: "Post",
|
|
14
|
+
article: "Article",
|
|
15
|
+
thread: "Thread",
|
|
16
|
+
reply: "Reply",
|
|
17
|
+
image: "Image",
|
|
18
|
+
video: "Video",
|
|
19
|
+
email: "Email",
|
|
20
|
+
dm: "DM",
|
|
21
|
+
newsletter: "Newsletter",
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function formatDate(unix: number | null | undefined): string {
|
|
25
|
+
if (!unix) return "Unknown date";
|
|
26
|
+
return new Date(unix * 1000).toLocaleDateString("en-US", {
|
|
27
|
+
weekday: "short",
|
|
28
|
+
month: "short",
|
|
29
|
+
day: "numeric",
|
|
30
|
+
year: "numeric",
|
|
31
|
+
hour: "numeric",
|
|
32
|
+
minute: "2-digit",
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export default async function ContentDetailPage({
|
|
37
|
+
params,
|
|
38
|
+
}: {
|
|
39
|
+
params: Promise<{ id: string }>;
|
|
40
|
+
}) {
|
|
41
|
+
const { id } = await params;
|
|
42
|
+
const item = getContentItem(id);
|
|
43
|
+
|
|
44
|
+
if (!item) {
|
|
45
|
+
notFound();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const snapshot = item.post?.engagementSnapshot
|
|
49
|
+
? JSON.parse(item.post.engagementSnapshot)
|
|
50
|
+
: null;
|
|
51
|
+
|
|
52
|
+
// Check if X account is connected (for engagement actions)
|
|
53
|
+
const xAccount = getPlatformAccountByPlatform("x");
|
|
54
|
+
const canEngage = !!xAccount && xAccount.status === "active" && !!item.post?.platformPostId;
|
|
55
|
+
|
|
56
|
+
// Get engagement history for this post
|
|
57
|
+
const engagementHistory = item.post
|
|
58
|
+
? listEngagementsByContentPost(item.post.id)
|
|
59
|
+
: [];
|
|
60
|
+
|
|
61
|
+
// Get thread context if this item belongs to a thread
|
|
62
|
+
const threadItems = item.threadId ? getThreadItems(item.threadId) : [];
|
|
63
|
+
const isThread = threadItems.length > 1;
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<div className="space-y-6 max-w-3xl">
|
|
67
|
+
{/* Back navigation */}
|
|
68
|
+
<div className="flex items-center gap-2">
|
|
69
|
+
<Button variant="ghost" size="sm" asChild>
|
|
70
|
+
<Link href="/dashboard/content">
|
|
71
|
+
<ArrowLeft className="mr-1 h-4 w-4" />
|
|
72
|
+
Content
|
|
73
|
+
</Link>
|
|
74
|
+
</Button>
|
|
75
|
+
</div>
|
|
76
|
+
|
|
77
|
+
{/* Main content card */}
|
|
78
|
+
<Card>
|
|
79
|
+
<CardContent className="pt-6 space-y-4">
|
|
80
|
+
{/* Header: type badges + date */}
|
|
81
|
+
<div className="flex items-center justify-between">
|
|
82
|
+
<div className="flex items-center gap-2">
|
|
83
|
+
<Badge variant="secondary">
|
|
84
|
+
{contentTypeLabels[item.contentType] ?? item.contentType}
|
|
85
|
+
</Badge>
|
|
86
|
+
{item.origin && (
|
|
87
|
+
<Badge variant="outline">{item.origin}</Badge>
|
|
88
|
+
)}
|
|
89
|
+
{item.direction && (
|
|
90
|
+
<Badge variant="outline">{item.direction}</Badge>
|
|
91
|
+
)}
|
|
92
|
+
{item.status === "draft" && (
|
|
93
|
+
<Badge variant="outline" className="border-yellow-500 text-yellow-600">
|
|
94
|
+
draft
|
|
95
|
+
</Badge>
|
|
96
|
+
)}
|
|
97
|
+
{isThread && (
|
|
98
|
+
<Badge variant="outline">
|
|
99
|
+
Thread ({threadItems.length} tweets)
|
|
100
|
+
</Badge>
|
|
101
|
+
)}
|
|
102
|
+
</div>
|
|
103
|
+
{item.post?.platformUrl && (
|
|
104
|
+
<Button variant="ghost" size="sm" asChild>
|
|
105
|
+
<a
|
|
106
|
+
href={item.post.platformUrl}
|
|
107
|
+
target="_blank"
|
|
108
|
+
rel="noopener noreferrer"
|
|
109
|
+
>
|
|
110
|
+
<ExternalLink className="mr-1 h-4 w-4" />
|
|
111
|
+
View on X
|
|
112
|
+
</a>
|
|
113
|
+
</Button>
|
|
114
|
+
)}
|
|
115
|
+
</div>
|
|
116
|
+
|
|
117
|
+
{/* Title (if present) */}
|
|
118
|
+
{item.title && (
|
|
119
|
+
<h2 className="text-lg font-semibold">{item.title}</h2>
|
|
120
|
+
)}
|
|
121
|
+
|
|
122
|
+
{/* Tweet body */}
|
|
123
|
+
<div className="whitespace-pre-wrap text-sm leading-relaxed">
|
|
124
|
+
{item.body ?? "No content"}
|
|
125
|
+
</div>
|
|
126
|
+
|
|
127
|
+
{/* Timestamp */}
|
|
128
|
+
<p className="text-xs text-muted-foreground">
|
|
129
|
+
{formatDate(item.post?.publishedAt ?? item.createdAt)}
|
|
130
|
+
</p>
|
|
131
|
+
|
|
132
|
+
{/* Engagement metrics */}
|
|
133
|
+
{snapshot && (
|
|
134
|
+
<div className="flex items-center gap-6 pt-2 border-t text-sm text-muted-foreground">
|
|
135
|
+
<span className="flex items-center gap-1.5" title="Likes">
|
|
136
|
+
<Heart className="h-4 w-4" />
|
|
137
|
+
{snapshot.likes ?? 0}
|
|
138
|
+
</span>
|
|
139
|
+
<span className="flex items-center gap-1.5" title="Replies">
|
|
140
|
+
<MessageCircle className="h-4 w-4" />
|
|
141
|
+
{snapshot.replies ?? 0}
|
|
142
|
+
</span>
|
|
143
|
+
<span className="flex items-center gap-1.5" title="Retweets">
|
|
144
|
+
<Repeat2 className="h-4 w-4" />
|
|
145
|
+
{snapshot.retweets ?? 0}
|
|
146
|
+
</span>
|
|
147
|
+
<span className="flex items-center gap-1.5" title="Quotes">
|
|
148
|
+
<Quote className="h-4 w-4" />
|
|
149
|
+
{snapshot.quotes ?? 0}
|
|
150
|
+
</span>
|
|
151
|
+
</div>
|
|
152
|
+
)}
|
|
153
|
+
</CardContent>
|
|
154
|
+
</Card>
|
|
155
|
+
|
|
156
|
+
{/* Thread context */}
|
|
157
|
+
{isThread && (
|
|
158
|
+
<Card>
|
|
159
|
+
<CardContent className="pt-6">
|
|
160
|
+
<h3 className="text-sm font-medium mb-3">Thread ({threadItems.length} tweets)</h3>
|
|
161
|
+
<div className="space-y-0">
|
|
162
|
+
{threadItems.map((ti, idx) => {
|
|
163
|
+
const isCurrent = ti.id === item.id;
|
|
164
|
+
return (
|
|
165
|
+
<div key={ti.id} className="flex gap-3">
|
|
166
|
+
{/* Vertical connector */}
|
|
167
|
+
<div className="flex flex-col items-center">
|
|
168
|
+
<div
|
|
169
|
+
className={`w-2.5 h-2.5 rounded-full shrink-0 ${
|
|
170
|
+
isCurrent ? "bg-primary" : "bg-muted-foreground/30"
|
|
171
|
+
}`}
|
|
172
|
+
/>
|
|
173
|
+
{idx < threadItems.length - 1 && (
|
|
174
|
+
<div className="w-0.5 flex-1 bg-muted-foreground/20 min-h-4" />
|
|
175
|
+
)}
|
|
176
|
+
</div>
|
|
177
|
+
|
|
178
|
+
{/* Tweet content */}
|
|
179
|
+
{isCurrent ? (
|
|
180
|
+
<div className="pb-3 flex-1">
|
|
181
|
+
<p className="text-sm font-medium text-foreground">
|
|
182
|
+
{ti.body
|
|
183
|
+
? ti.body.length > 100
|
|
184
|
+
? ti.body.slice(0, 100) + "..."
|
|
185
|
+
: ti.body
|
|
186
|
+
: "No content"}
|
|
187
|
+
</p>
|
|
188
|
+
<Badge variant="secondary" className="text-xs mt-1">
|
|
189
|
+
Current
|
|
190
|
+
</Badge>
|
|
191
|
+
</div>
|
|
192
|
+
) : (
|
|
193
|
+
<Link
|
|
194
|
+
href={`/dashboard/content/${ti.id}`}
|
|
195
|
+
className="pb-3 flex-1 group"
|
|
196
|
+
>
|
|
197
|
+
<p className="text-sm text-muted-foreground group-hover:text-foreground transition-colors">
|
|
198
|
+
{ti.body
|
|
199
|
+
? ti.body.length > 100
|
|
200
|
+
? ti.body.slice(0, 100) + "..."
|
|
201
|
+
: ti.body
|
|
202
|
+
: "No content"}
|
|
203
|
+
</p>
|
|
204
|
+
</Link>
|
|
205
|
+
)}
|
|
206
|
+
</div>
|
|
207
|
+
);
|
|
208
|
+
})}
|
|
209
|
+
</div>
|
|
210
|
+
</CardContent>
|
|
211
|
+
</Card>
|
|
212
|
+
)}
|
|
213
|
+
|
|
214
|
+
{/* Engagement action bar */}
|
|
215
|
+
{canEngage && item.post && (
|
|
216
|
+
<EngagementActions
|
|
217
|
+
tweetId={item.post.platformPostId!}
|
|
218
|
+
contentPostId={item.post.id}
|
|
219
|
+
engagementHistory={engagementHistory}
|
|
220
|
+
/>
|
|
221
|
+
)}
|
|
222
|
+
|
|
223
|
+
{/* Engagement history */}
|
|
224
|
+
{engagementHistory.length > 0 && (
|
|
225
|
+
<Card>
|
|
226
|
+
<CardContent className="pt-6">
|
|
227
|
+
<h3 className="text-sm font-medium mb-3">Your Activity</h3>
|
|
228
|
+
<div className="space-y-2">
|
|
229
|
+
{engagementHistory.map((e) => (
|
|
230
|
+
<div
|
|
231
|
+
key={e.id}
|
|
232
|
+
className="flex items-center justify-between text-sm text-muted-foreground py-1 border-b last:border-b-0"
|
|
233
|
+
>
|
|
234
|
+
<div className="flex items-center gap-2">
|
|
235
|
+
<Badge variant="outline" className="text-xs">
|
|
236
|
+
{e.engagementType}
|
|
237
|
+
</Badge>
|
|
238
|
+
{e.content && (
|
|
239
|
+
<span className="truncate max-w-xs">{e.content}</span>
|
|
240
|
+
)}
|
|
241
|
+
</div>
|
|
242
|
+
<span className="text-xs">
|
|
243
|
+
{formatDate(e.createdAt)}
|
|
244
|
+
</span>
|
|
245
|
+
</div>
|
|
246
|
+
))}
|
|
247
|
+
</div>
|
|
248
|
+
</CardContent>
|
|
249
|
+
</Card>
|
|
250
|
+
)}
|
|
251
|
+
</div>
|
|
252
|
+
);
|
|
253
|
+
}
|