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.
Files changed (208) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +175 -0
  3. package/components.json +20 -0
  4. package/dist/cli.js +992 -0
  5. package/drizzle.config.ts +14 -0
  6. package/next.config.mjs +7 -0
  7. package/package.json +91 -0
  8. package/postcss.config.mjs +7 -0
  9. package/public/android-chrome-192x192.png +0 -0
  10. package/public/android-chrome-512x512.png +0 -0
  11. package/public/apple-touch-icon.png +0 -0
  12. package/public/assets/openvolo-logo-black.png +0 -0
  13. package/public/assets/openvolo-logo-name.png +0 -0
  14. package/public/assets/openvolo-logo-transparent.png +0 -0
  15. package/public/favicon-16x16.png +0 -0
  16. package/public/favicon-32x32.png +0 -0
  17. package/public/favicon.ico +0 -0
  18. package/public/site.webmanifest +19 -0
  19. package/src/app/api/analytics/agents/route.ts +30 -0
  20. package/src/app/api/analytics/content/route.ts +24 -0
  21. package/src/app/api/analytics/engagement/route.ts +24 -0
  22. package/src/app/api/analytics/overview/route.ts +22 -0
  23. package/src/app/api/analytics/sync-health/route.ts +22 -0
  24. package/src/app/api/contacts/[id]/identities/[identityId]/route.ts +24 -0
  25. package/src/app/api/contacts/[id]/identities/route.ts +61 -0
  26. package/src/app/api/contacts/[id]/route.ts +72 -0
  27. package/src/app/api/contacts/route.ts +91 -0
  28. package/src/app/api/content/[id]/route.ts +61 -0
  29. package/src/app/api/content/route.ts +48 -0
  30. package/src/app/api/platforms/gmail/auth/route.ts +50 -0
  31. package/src/app/api/platforms/gmail/callback/route.ts +126 -0
  32. package/src/app/api/platforms/gmail/route.ts +60 -0
  33. package/src/app/api/platforms/gmail/sync/route.ts +96 -0
  34. package/src/app/api/platforms/linkedin/auth/route.ts +40 -0
  35. package/src/app/api/platforms/linkedin/callback/route.ts +128 -0
  36. package/src/app/api/platforms/linkedin/import/route.ts +40 -0
  37. package/src/app/api/platforms/linkedin/route.ts +60 -0
  38. package/src/app/api/platforms/linkedin/sync/route.ts +85 -0
  39. package/src/app/api/platforms/x/auth/route.ts +52 -0
  40. package/src/app/api/platforms/x/browser-session/route.ts +79 -0
  41. package/src/app/api/platforms/x/callback/route.ts +130 -0
  42. package/src/app/api/platforms/x/compose/route.ts +247 -0
  43. package/src/app/api/platforms/x/engage/route.ts +113 -0
  44. package/src/app/api/platforms/x/enrich/route.ts +79 -0
  45. package/src/app/api/platforms/x/route.ts +63 -0
  46. package/src/app/api/platforms/x/sync/route.ts +142 -0
  47. package/src/app/api/settings/route.ts +43 -0
  48. package/src/app/api/settings/search-api/route.ts +180 -0
  49. package/src/app/api/tasks/[id]/route.ts +60 -0
  50. package/src/app/api/tasks/route.ts +39 -0
  51. package/src/app/api/workflows/[id]/progress/route.ts +45 -0
  52. package/src/app/api/workflows/[id]/route.ts +20 -0
  53. package/src/app/api/workflows/route.ts +30 -0
  54. package/src/app/api/workflows/run-agent/route.ts +44 -0
  55. package/src/app/api/workflows/templates/[id]/activate/route.ts +64 -0
  56. package/src/app/api/workflows/templates/[id]/route.ts +75 -0
  57. package/src/app/api/workflows/templates/route.ts +60 -0
  58. package/src/app/dashboard/analytics/analytics-dashboard.tsx +535 -0
  59. package/src/app/dashboard/analytics/page.tsx +15 -0
  60. package/src/app/dashboard/contacts/[id]/contact-detail-client.tsx +334 -0
  61. package/src/app/dashboard/contacts/[id]/page.tsx +21 -0
  62. package/src/app/dashboard/contacts/contact-list-client.tsx +213 -0
  63. package/src/app/dashboard/contacts/page.tsx +38 -0
  64. package/src/app/dashboard/content/[id]/engagement-actions.tsx +167 -0
  65. package/src/app/dashboard/content/[id]/page.tsx +253 -0
  66. package/src/app/dashboard/content/content-list-client.tsx +428 -0
  67. package/src/app/dashboard/content/page.tsx +39 -0
  68. package/src/app/dashboard/help/page.tsx +1247 -0
  69. package/src/app/dashboard/layout.tsx +19 -0
  70. package/src/app/dashboard/page.tsx +187 -0
  71. package/src/app/dashboard/settings/page.tsx +1664 -0
  72. package/src/app/dashboard/workflows/[id]/page.tsx +90 -0
  73. package/src/app/dashboard/workflows/[id]/workflow-detail-steps.tsx +55 -0
  74. package/src/app/dashboard/workflows/[id]/workflow-run-live.tsx +195 -0
  75. package/src/app/dashboard/workflows/activate-dialog.tsx +251 -0
  76. package/src/app/dashboard/workflows/page.tsx +41 -0
  77. package/src/app/dashboard/workflows/template-gallery.tsx +201 -0
  78. package/src/app/dashboard/workflows/workflow-quick-actions.tsx +121 -0
  79. package/src/app/dashboard/workflows/workflow-view-switcher.tsx +62 -0
  80. package/src/app/globals.css +232 -0
  81. package/src/app/layout.tsx +57 -0
  82. package/src/app/page.tsx +5 -0
  83. package/src/components/add-contact-dialog.tsx +74 -0
  84. package/src/components/add-task-dialog.tsx +153 -0
  85. package/src/components/animated-stat.tsx +53 -0
  86. package/src/components/app-sidebar.tsx +130 -0
  87. package/src/components/charts/area-chart-card.tsx +99 -0
  88. package/src/components/charts/bar-chart-card.tsx +128 -0
  89. package/src/components/charts/chart-skeleton.tsx +43 -0
  90. package/src/components/charts/donut-chart-card.tsx +100 -0
  91. package/src/components/charts/ranked-table-card.tsx +127 -0
  92. package/src/components/charts/stat-cards-row.tsx +45 -0
  93. package/src/components/compose-dialog.tsx +344 -0
  94. package/src/components/contact-form.tsx +218 -0
  95. package/src/components/dashboard-greeting.tsx +27 -0
  96. package/src/components/dashboard-header.tsx +87 -0
  97. package/src/components/empty-state.tsx +32 -0
  98. package/src/components/enrich-button.tsx +107 -0
  99. package/src/components/enrichment-score-badge.tsx +30 -0
  100. package/src/components/funnel-stage-badge.tsx +19 -0
  101. package/src/components/funnel-visualization.tsx +66 -0
  102. package/src/components/identities-section.tsx +219 -0
  103. package/src/components/pagination-controls.tsx +115 -0
  104. package/src/components/platform-connection-card.tsx +292 -0
  105. package/src/components/priority-badge.tsx +17 -0
  106. package/src/components/step-output-renderer.tsx +63 -0
  107. package/src/components/tweet-input.tsx +126 -0
  108. package/src/components/ui/alert-dialog.tsx +196 -0
  109. package/src/components/ui/avatar.tsx +109 -0
  110. package/src/components/ui/badge.tsx +48 -0
  111. package/src/components/ui/button.tsx +64 -0
  112. package/src/components/ui/card.tsx +92 -0
  113. package/src/components/ui/chart.tsx +357 -0
  114. package/src/components/ui/dialog.tsx +158 -0
  115. package/src/components/ui/dropdown-menu.tsx +257 -0
  116. package/src/components/ui/input.tsx +21 -0
  117. package/src/components/ui/label.tsx +24 -0
  118. package/src/components/ui/progress.tsx +31 -0
  119. package/src/components/ui/scroll-area.tsx +58 -0
  120. package/src/components/ui/select.tsx +190 -0
  121. package/src/components/ui/separator.tsx +28 -0
  122. package/src/components/ui/sheet.tsx +143 -0
  123. package/src/components/ui/sidebar.tsx +726 -0
  124. package/src/components/ui/skeleton.tsx +13 -0
  125. package/src/components/ui/table.tsx +116 -0
  126. package/src/components/ui/tabs.tsx +91 -0
  127. package/src/components/ui/textarea.tsx +18 -0
  128. package/src/components/ui/tooltip.tsx +57 -0
  129. package/src/components/workflow-graph-view.tsx +205 -0
  130. package/src/components/workflow-kanban-view.tsx +69 -0
  131. package/src/components/workflow-list-view.tsx +201 -0
  132. package/src/components/workflow-progress-card.tsx +150 -0
  133. package/src/components/workflow-run-card.tsx +144 -0
  134. package/src/components/workflow-step-timeline.tsx +173 -0
  135. package/src/components/workflow-swimlane-view.tsx +87 -0
  136. package/src/hooks/use-mobile.ts +19 -0
  137. package/src/hooks/use-workflow-polling.ts +85 -0
  138. package/src/lib/agents/router.ts +79 -0
  139. package/src/lib/agents/run-agent-workflow.ts +605 -0
  140. package/src/lib/agents/tools/browser-scrape.ts +118 -0
  141. package/src/lib/agents/tools/enrich-contact.ts +128 -0
  142. package/src/lib/agents/tools/search-web.ts +473 -0
  143. package/src/lib/agents/tools/update-progress.ts +40 -0
  144. package/src/lib/agents/tools/url-fetch.ts +152 -0
  145. package/src/lib/agents/types.ts +79 -0
  146. package/src/lib/analytics/utils.ts +33 -0
  147. package/src/lib/auth/claude-auth.ts +134 -0
  148. package/src/lib/auth/crypto.ts +58 -0
  149. package/src/lib/browser/anti-detection.ts +79 -0
  150. package/src/lib/browser/extractors/profile-merger.ts +71 -0
  151. package/src/lib/browser/extractors/profile-parser.ts +133 -0
  152. package/src/lib/browser/platforms/x-scraper.ts +269 -0
  153. package/src/lib/browser/scraper.ts +92 -0
  154. package/src/lib/browser/session.ts +229 -0
  155. package/src/lib/browser/types.ts +80 -0
  156. package/src/lib/db/client.ts +24 -0
  157. package/src/lib/db/enrichment.ts +90 -0
  158. package/src/lib/db/migrate-identities.ts +95 -0
  159. package/src/lib/db/migrate.ts +33 -0
  160. package/src/lib/db/migrations/0000_tired_thanos.sql +296 -0
  161. package/src/lib/db/migrations/meta/0000_snapshot.json +2169 -0
  162. package/src/lib/db/migrations/meta/_journal.json +13 -0
  163. package/src/lib/db/queries/analytics.ts +449 -0
  164. package/src/lib/db/queries/contacts.ts +170 -0
  165. package/src/lib/db/queries/content.ts +215 -0
  166. package/src/lib/db/queries/dashboard.ts +79 -0
  167. package/src/lib/db/queries/engagements.ts +35 -0
  168. package/src/lib/db/queries/identities.ts +51 -0
  169. package/src/lib/db/queries/platform-accounts.ts +53 -0
  170. package/src/lib/db/queries/sync.ts +74 -0
  171. package/src/lib/db/queries/tasks.ts +88 -0
  172. package/src/lib/db/queries/workflow-templates.ts +213 -0
  173. package/src/lib/db/queries/workflows.ts +167 -0
  174. package/src/lib/db/schema.ts +437 -0
  175. package/src/lib/db/seed-templates.ts +221 -0
  176. package/src/lib/db/types.ts +78 -0
  177. package/src/lib/pagination.ts +12 -0
  178. package/src/lib/platforms/adapter.ts +75 -0
  179. package/src/lib/platforms/gmail/adapter.ts +112 -0
  180. package/src/lib/platforms/gmail/auth.ts +137 -0
  181. package/src/lib/platforms/gmail/client.ts +255 -0
  182. package/src/lib/platforms/gmail/mappers.ts +125 -0
  183. package/src/lib/platforms/gmail/oauth-state-store.ts +65 -0
  184. package/src/lib/platforms/index.ts +22 -0
  185. package/src/lib/platforms/linkedin/adapter.ts +164 -0
  186. package/src/lib/platforms/linkedin/auth.ts +124 -0
  187. package/src/lib/platforms/linkedin/client.ts +183 -0
  188. package/src/lib/platforms/linkedin/csv-import.ts +283 -0
  189. package/src/lib/platforms/linkedin/mappers.ts +123 -0
  190. package/src/lib/platforms/linkedin/oauth-state-store.ts +65 -0
  191. package/src/lib/platforms/rate-limiter.ts +88 -0
  192. package/src/lib/platforms/sync-contacts.ts +121 -0
  193. package/src/lib/platforms/sync-content.ts +225 -0
  194. package/src/lib/platforms/sync-gmail-contacts.ts +186 -0
  195. package/src/lib/platforms/sync-gmail-metadata.ts +158 -0
  196. package/src/lib/platforms/sync-linkedin-contacts.ts +148 -0
  197. package/src/lib/platforms/sync-x-profiles.ts +280 -0
  198. package/src/lib/platforms/x/adapter.ts +129 -0
  199. package/src/lib/platforms/x/auth.ts +165 -0
  200. package/src/lib/platforms/x/client.ts +390 -0
  201. package/src/lib/platforms/x/mappers.ts +134 -0
  202. package/src/lib/platforms/x/pkce-store.ts +67 -0
  203. package/src/lib/utils.ts +6 -0
  204. package/src/lib/workflows/format-error.test.ts +177 -0
  205. package/src/lib/workflows/format-error.ts +207 -0
  206. package/src/lib/workflows/run-sync-workflow.ts +141 -0
  207. package/src/lib/workflows/types.ts +71 -0
  208. package/tsconfig.json +42 -0
@@ -0,0 +1,219 @@
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 { Badge } from "@/components/ui/badge";
8
+ import { Input } from "@/components/ui/input";
9
+ import { Label } from "@/components/ui/label";
10
+ import {
11
+ Select,
12
+ SelectContent,
13
+ SelectItem,
14
+ SelectTrigger,
15
+ SelectValue,
16
+ } from "@/components/ui/select";
17
+ import {
18
+ Dialog,
19
+ DialogContent,
20
+ DialogHeader,
21
+ DialogTitle,
22
+ DialogTrigger,
23
+ } from "@/components/ui/dialog";
24
+ import { Plus, Trash2, ExternalLink } from "lucide-react";
25
+ import type { ContactIdentity } from "@/lib/db/types";
26
+
27
+ const platformLabels: Record<string, string> = {
28
+ x: "X / Twitter",
29
+ linkedin: "LinkedIn",
30
+ gmail: "Gmail",
31
+ substack: "Substack",
32
+ };
33
+
34
+ interface IdentitiesSectionProps {
35
+ contactId: string;
36
+ identities: ContactIdentity[];
37
+ }
38
+
39
+ export function IdentitiesSection({ contactId, identities }: IdentitiesSectionProps) {
40
+ const router = useRouter();
41
+ const [open, setOpen] = useState(false);
42
+ const [adding, setAdding] = useState(false);
43
+ const [form, setForm] = useState({
44
+ platform: "x" as "x" | "linkedin" | "gmail" | "substack",
45
+ platformUserId: "",
46
+ platformHandle: "",
47
+ platformUrl: "",
48
+ });
49
+
50
+ async function handleAdd() {
51
+ if (!form.platformUserId) return;
52
+ setAdding(true);
53
+ try {
54
+ const res = await fetch(`/api/contacts/${contactId}/identities`, {
55
+ method: "POST",
56
+ headers: { "Content-Type": "application/json" },
57
+ body: JSON.stringify(form),
58
+ });
59
+ if (res.ok) {
60
+ setOpen(false);
61
+ setForm({ platform: "x", platformUserId: "", platformHandle: "", platformUrl: "" });
62
+ router.refresh();
63
+ }
64
+ } finally {
65
+ setAdding(false);
66
+ }
67
+ }
68
+
69
+ async function handleDelete(identityId: string) {
70
+ await fetch(`/api/contacts/${contactId}/identities/${identityId}`, {
71
+ method: "DELETE",
72
+ });
73
+ router.refresh();
74
+ }
75
+
76
+ return (
77
+ <div className="space-y-3">
78
+ <div className="flex justify-between items-center">
79
+ <h3 className="text-lg font-semibold">Platform Identities</h3>
80
+ <Dialog open={open} onOpenChange={setOpen}>
81
+ <DialogTrigger asChild>
82
+ <Button size="sm" variant="outline">
83
+ <Plus className="mr-2 h-4 w-4" />
84
+ Add Identity
85
+ </Button>
86
+ </DialogTrigger>
87
+ <DialogContent>
88
+ <DialogHeader>
89
+ <DialogTitle>Add Platform Identity</DialogTitle>
90
+ </DialogHeader>
91
+ <div className="grid gap-4 py-4">
92
+ <div className="grid gap-2">
93
+ <Label>Platform</Label>
94
+ <Select
95
+ value={form.platform}
96
+ onValueChange={(v) =>
97
+ setForm({ ...form, platform: v as typeof form.platform })
98
+ }
99
+ >
100
+ <SelectTrigger>
101
+ <SelectValue />
102
+ </SelectTrigger>
103
+ <SelectContent>
104
+ {Object.entries(platformLabels).map(([k, v]) => (
105
+ <SelectItem key={k} value={k}>
106
+ {v}
107
+ </SelectItem>
108
+ ))}
109
+ </SelectContent>
110
+ </Select>
111
+ </div>
112
+ <div className="grid gap-2">
113
+ <Label>User ID *</Label>
114
+ <Input
115
+ value={form.platformUserId}
116
+ onChange={(e) =>
117
+ setForm({ ...form, platformUserId: e.target.value })
118
+ }
119
+ placeholder="Platform user ID"
120
+ />
121
+ </div>
122
+ <div className="grid gap-2">
123
+ <Label>Handle</Label>
124
+ <Input
125
+ value={form.platformHandle}
126
+ onChange={(e) =>
127
+ setForm({ ...form, platformHandle: e.target.value })
128
+ }
129
+ placeholder="@handle"
130
+ />
131
+ </div>
132
+ <div className="grid gap-2">
133
+ <Label>Profile URL</Label>
134
+ <Input
135
+ value={form.platformUrl}
136
+ onChange={(e) =>
137
+ setForm({ ...form, platformUrl: e.target.value })
138
+ }
139
+ placeholder="https://..."
140
+ />
141
+ </div>
142
+ <Button onClick={handleAdd} disabled={adding || !form.platformUserId}>
143
+ {adding ? "Adding..." : "Add Identity"}
144
+ </Button>
145
+ </div>
146
+ </DialogContent>
147
+ </Dialog>
148
+ </div>
149
+
150
+ {identities.length === 0 ? (
151
+ <Card>
152
+ <CardContent className="pt-6">
153
+ <p className="text-sm text-muted-foreground text-center">
154
+ No platform identities linked yet.
155
+ </p>
156
+ </CardContent>
157
+ </Card>
158
+ ) : (
159
+ <div className="space-y-2">
160
+ {identities.map((identity) => (
161
+ <Card key={identity.id}>
162
+ <CardContent className="flex items-center justify-between py-3">
163
+ <div className="flex items-center gap-3 min-w-0">
164
+ <div className="min-w-0">
165
+ <div className="flex items-center gap-2">
166
+ <Badge variant="secondary">
167
+ {platformLabels[identity.platform] ?? identity.platform}
168
+ </Badge>
169
+ {identity.isPrimary === 1 && (
170
+ <Badge variant="outline" className="text-xs">
171
+ Primary
172
+ </Badge>
173
+ )}
174
+ <Badge
175
+ variant="outline"
176
+ className={
177
+ identity.isActive
178
+ ? "bg-chart-4/15 text-chart-4 border-chart-4/25"
179
+ : "bg-muted/15 text-muted-foreground border-muted"
180
+ }
181
+ >
182
+ {identity.isActive ? "Active" : "Inactive"}
183
+ </Badge>
184
+ </div>
185
+ <p className="text-sm mt-1 truncate">
186
+ {identity.platformHandle
187
+ ? `@${identity.platformHandle}`
188
+ : identity.platformUserId}
189
+ </p>
190
+ </div>
191
+ </div>
192
+ <div className="flex items-center gap-2">
193
+ {identity.platformUrl && (
194
+ <a
195
+ href={identity.platformUrl}
196
+ target="_blank"
197
+ rel="noopener noreferrer"
198
+ >
199
+ <Button variant="ghost" size="icon">
200
+ <ExternalLink className="h-4 w-4" />
201
+ </Button>
202
+ </a>
203
+ )}
204
+ <Button
205
+ variant="ghost"
206
+ size="icon"
207
+ onClick={() => handleDelete(identity.id)}
208
+ >
209
+ <Trash2 className="h-4 w-4 text-muted-foreground" />
210
+ </Button>
211
+ </div>
212
+ </CardContent>
213
+ </Card>
214
+ ))}
215
+ </div>
216
+ )}
217
+ </div>
218
+ );
219
+ }
@@ -0,0 +1,115 @@
1
+ "use client";
2
+
3
+ import Link from "next/link";
4
+ import { Button } from "@/components/ui/button";
5
+
6
+ interface PaginationControlsProps {
7
+ page: number;
8
+ pageSize: number;
9
+ total: number;
10
+ createPageUrl: (page: number) => string;
11
+ }
12
+
13
+ export function PaginationControls({
14
+ page,
15
+ pageSize,
16
+ total,
17
+ createPageUrl,
18
+ }: PaginationControlsProps) {
19
+ const totalPages = Math.ceil(total / pageSize);
20
+ if (totalPages <= 1) return null;
21
+
22
+ const start = (page - 1) * pageSize + 1;
23
+ const end = Math.min(page * pageSize, total);
24
+
25
+ // Build page number window
26
+ const pages = buildPageWindow(page, totalPages);
27
+
28
+ return (
29
+ <div className="flex items-center justify-between pt-4">
30
+ <p className="text-sm text-muted-foreground">
31
+ Showing {start}-{end} of {total}
32
+ </p>
33
+ <div className="flex items-center gap-1">
34
+ <Button
35
+ variant="outline"
36
+ size="sm"
37
+ disabled={page <= 1}
38
+ asChild={page > 1}
39
+ >
40
+ {page > 1 ? (
41
+ <Link href={createPageUrl(page - 1)}>Prev</Link>
42
+ ) : (
43
+ <span>Prev</span>
44
+ )}
45
+ </Button>
46
+
47
+ {pages.map((p, i) =>
48
+ p === "ellipsis" ? (
49
+ <span key={`e${i}`} className="px-2 text-sm text-muted-foreground">
50
+ ...
51
+ </span>
52
+ ) : (
53
+ <Button
54
+ key={p}
55
+ variant={p === page ? "default" : "outline"}
56
+ size="sm"
57
+ className="min-w-[36px]"
58
+ asChild={p !== page}
59
+ >
60
+ {p === page ? (
61
+ <span>{p}</span>
62
+ ) : (
63
+ <Link href={createPageUrl(p)}>{p}</Link>
64
+ )}
65
+ </Button>
66
+ )
67
+ )}
68
+
69
+ <Button
70
+ variant="outline"
71
+ size="sm"
72
+ disabled={page >= totalPages}
73
+ asChild={page < totalPages}
74
+ >
75
+ {page < totalPages ? (
76
+ <Link href={createPageUrl(page + 1)}>Next</Link>
77
+ ) : (
78
+ <span>Next</span>
79
+ )}
80
+ </Button>
81
+ </div>
82
+ </div>
83
+ );
84
+ }
85
+
86
+ /** Build a windowed array of page numbers with ellipsis markers. */
87
+ function buildPageWindow(
88
+ current: number,
89
+ total: number
90
+ ): (number | "ellipsis")[] {
91
+ if (total <= 7) {
92
+ return Array.from({ length: total }, (_, i) => i + 1);
93
+ }
94
+
95
+ const pages: (number | "ellipsis")[] = [1];
96
+
97
+ if (current > 3) {
98
+ pages.push("ellipsis");
99
+ }
100
+
101
+ const start = Math.max(2, current - 1);
102
+ const end = Math.min(total - 1, current + 1);
103
+
104
+ for (let i = start; i <= end; i++) {
105
+ pages.push(i);
106
+ }
107
+
108
+ if (current < total - 2) {
109
+ pages.push("ellipsis");
110
+ }
111
+
112
+ pages.push(total);
113
+
114
+ return pages;
115
+ }
@@ -0,0 +1,292 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { Button } from "@/components/ui/button";
5
+ import { Badge } from "@/components/ui/badge";
6
+ import {
7
+ CheckCircle,
8
+ XCircle,
9
+ AlertTriangle,
10
+ Loader2,
11
+ RefreshCw,
12
+ Unplug,
13
+ ArrowUpCircle,
14
+ ChevronDown,
15
+ ChevronRight,
16
+ Shield,
17
+ } from "lucide-react";
18
+
19
+ type ConnectionStatus = "disconnected" | "connected" | "needs_reauth";
20
+
21
+ interface PlatformConnectionCardProps {
22
+ platform: string;
23
+ displayName: string;
24
+ accountHandle?: string;
25
+ lastSyncedAt?: number | null;
26
+ status: ConnectionStatus;
27
+ syncCapable?: boolean;
28
+ grantedScopes?: string;
29
+ onConnect: () => void;
30
+ onDisconnect: () => void;
31
+ onSync: () => void;
32
+ onEnableSync?: () => void;
33
+ connecting?: boolean;
34
+ syncing?: boolean;
35
+ disconnecting?: boolean;
36
+ }
37
+
38
+ /** Scope metadata: friendly label and capability group. */
39
+ const SCOPE_META: Record<string, { label: string; group: string }> = {
40
+ // LinkedIn scopes
41
+ "openid": { label: "OpenID Connect", group: "Auth" },
42
+ "profile": { label: "Read Profile", group: "Profile" },
43
+ "email": { label: "Read Email", group: "Profile" },
44
+ "w_member_social": { label: "Create Posts", group: "Content" },
45
+ // Google scopes (full URLs — displayed as friendly labels)
46
+ "https://www.googleapis.com/auth/contacts.readonly": { label: "Read Contacts", group: "Contacts" },
47
+ "https://www.googleapis.com/auth/gmail.readonly": { label: "Read Email", group: "Email" },
48
+ // X scopes
49
+ "tweet.read": { label: "Read Tweets", group: "Tweets" },
50
+ "tweet.write": { label: "Write Tweets", group: "Tweets" },
51
+ "tweet.moderate.write": { label: "Moderate Replies", group: "Tweets" },
52
+ "users.read": { label: "Read Profiles", group: "Users" },
53
+ "like.read": { label: "Read Likes", group: "Engagement" },
54
+ "like.write": { label: "Like Tweets", group: "Engagement" },
55
+ "bookmark.read": { label: "Read Bookmarks", group: "Engagement" },
56
+ "bookmark.write": { label: "Save Bookmarks", group: "Engagement" },
57
+ "dm.read": { label: "Read DMs", group: "DMs" },
58
+ "dm.write": { label: "Send DMs", group: "DMs" },
59
+ "follows.read": { label: "Read Follows", group: "Contacts" },
60
+ "follows.write": { label: "Follow/Unfollow", group: "Contacts" },
61
+ "list.read": { label: "Read Lists", group: "Lists" },
62
+ "list.write": { label: "Manage Lists", group: "Lists" },
63
+ "mute.read": { label: "Read Mutes", group: "Moderation" },
64
+ "mute.write": { label: "Mute Accounts", group: "Moderation" },
65
+ "block.read": { label: "Read Blocks", group: "Moderation" },
66
+ "block.write": { label: "Block Accounts", group: "Moderation" },
67
+ "space.read": { label: "Read Spaces", group: "Other" },
68
+ "offline.access": { label: "Offline Access", group: "Other" },
69
+ };
70
+
71
+ /** Group order for display. */
72
+ const GROUP_ORDER = ["Auth", "Profile", "Content", "Tweets", "Users", "Engagement", "Email", "DMs", "Contacts", "Lists", "Moderation", "Other"];
73
+
74
+ /** Scopes that require Basic+ tier. */
75
+ const BASIC_PLUS_SCOPES = new Set([
76
+ "follows.read", "follows.write",
77
+ "list.read", "list.write",
78
+ "mute.read", "mute.write",
79
+ "block.read", "block.write",
80
+ "space.read",
81
+ ]);
82
+
83
+ function groupScopes(scopeString: string): { group: string; scopes: { scope: string; label: string; basicPlus: boolean }[] }[] {
84
+ const scopes = scopeString.split(" ").filter(Boolean);
85
+ const grouped = new Map<string, { scope: string; label: string; basicPlus: boolean }[]>();
86
+
87
+ for (const scope of scopes) {
88
+ const meta = SCOPE_META[scope];
89
+ const group = meta?.group ?? "Other";
90
+ const label = meta?.label ?? scope;
91
+
92
+ if (!grouped.has(group)) grouped.set(group, []);
93
+ grouped.get(group)!.push({ scope, label, basicPlus: BASIC_PLUS_SCOPES.has(scope) });
94
+ }
95
+
96
+ return GROUP_ORDER
97
+ .filter((g) => grouped.has(g))
98
+ .map((group) => ({ group, scopes: grouped.get(group)! }));
99
+ }
100
+
101
+ function formatRelativeTime(unixSeconds: number): string {
102
+ const now = Math.floor(Date.now() / 1000);
103
+ const diff = now - unixSeconds;
104
+
105
+ if (diff < 60) return "just now";
106
+ if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
107
+ if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
108
+ return `${Math.floor(diff / 86400)}d ago`;
109
+ }
110
+
111
+ export function PlatformConnectionCard({
112
+ displayName,
113
+ accountHandle,
114
+ lastSyncedAt,
115
+ status,
116
+ syncCapable,
117
+ grantedScopes,
118
+ onConnect,
119
+ onDisconnect,
120
+ onSync,
121
+ onEnableSync,
122
+ connecting,
123
+ syncing,
124
+ disconnecting,
125
+ }: PlatformConnectionCardProps) {
126
+ const [scopesOpen, setScopesOpen] = useState(false);
127
+ const showScopes = status === "connected" && grantedScopes;
128
+
129
+ return (
130
+ <div className="rounded-lg border">
131
+ <div className="flex items-center justify-between p-4">
132
+ <div className="space-y-1">
133
+ <div className="flex items-center gap-2">
134
+ <p className="font-medium">{displayName}</p>
135
+ {status === "connected" && (
136
+ <Badge variant="default" className="bg-green-600">
137
+ <CheckCircle className="mr-1 h-3 w-3" />
138
+ Connected
139
+ </Badge>
140
+ )}
141
+ {status === "needs_reauth" && (
142
+ <Badge variant="destructive">
143
+ <AlertTriangle className="mr-1 h-3 w-3" />
144
+ Needs Re-auth
145
+ </Badge>
146
+ )}
147
+ {status === "disconnected" && (
148
+ <Badge variant="secondary">
149
+ <XCircle className="mr-1 h-3 w-3" />
150
+ Not connected
151
+ </Badge>
152
+ )}
153
+ </div>
154
+
155
+ {status === "connected" && accountHandle && (
156
+ <p className="text-sm text-muted-foreground">
157
+ Signed in as <span className="font-mono">{accountHandle}</span>
158
+ </p>
159
+ )}
160
+ {status === "connected" && lastSyncedAt && (
161
+ <p className="text-xs text-muted-foreground">
162
+ Last synced {formatRelativeTime(lastSyncedAt)}
163
+ </p>
164
+ )}
165
+ {status === "connected" && !syncCapable && (
166
+ <p className="text-xs text-muted-foreground">
167
+ Contact sync requires X API Basic tier ($200/mo)
168
+ </p>
169
+ )}
170
+ {status === "disconnected" && (
171
+ <p className="text-sm text-muted-foreground">
172
+ Connect via OAuth 2.0 to post and import contacts
173
+ </p>
174
+ )}
175
+ {status === "needs_reauth" && (
176
+ <p className="text-sm text-muted-foreground">
177
+ Your session expired. Reconnect to continue syncing.
178
+ </p>
179
+ )}
180
+ </div>
181
+
182
+ <div className="flex items-center gap-2">
183
+ {status === "disconnected" && (
184
+ <Button onClick={onConnect} disabled={connecting}>
185
+ {connecting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
186
+ Connect
187
+ </Button>
188
+ )}
189
+
190
+ {status === "needs_reauth" && (
191
+ <Button onClick={onConnect} variant="outline" disabled={connecting}>
192
+ {connecting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
193
+ Reconnect
194
+ </Button>
195
+ )}
196
+
197
+ {status === "connected" && (
198
+ <>
199
+ {syncCapable ? (
200
+ <Button
201
+ onClick={onSync}
202
+ variant="outline"
203
+ size="sm"
204
+ disabled={syncing}
205
+ >
206
+ {syncing ? (
207
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
208
+ ) : (
209
+ <RefreshCw className="mr-2 h-4 w-4" />
210
+ )}
211
+ {syncing ? "Syncing..." : "Sync Now"}
212
+ </Button>
213
+ ) : onEnableSync ? (
214
+ <Button
215
+ onClick={onEnableSync}
216
+ variant="outline"
217
+ size="sm"
218
+ disabled={connecting}
219
+ >
220
+ {connecting ? (
221
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
222
+ ) : (
223
+ <ArrowUpCircle className="mr-2 h-4 w-4" />
224
+ )}
225
+ Enable Contact Sync
226
+ </Button>
227
+ ) : null}
228
+ <Button
229
+ onClick={onDisconnect}
230
+ variant="ghost"
231
+ size="sm"
232
+ disabled={disconnecting}
233
+ className="text-destructive hover:text-destructive"
234
+ >
235
+ {disconnecting ? (
236
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
237
+ ) : (
238
+ <Unplug className="mr-2 h-4 w-4" />
239
+ )}
240
+ Disconnect
241
+ </Button>
242
+ </>
243
+ )}
244
+ </div>
245
+ </div>
246
+
247
+ {showScopes && (
248
+ <div className="border-t">
249
+ <button
250
+ type="button"
251
+ onClick={() => setScopesOpen(!scopesOpen)}
252
+ className="flex w-full items-center gap-2 px-4 py-2 text-xs text-muted-foreground hover:bg-muted/50 transition-colors"
253
+ >
254
+ {scopesOpen ? (
255
+ <ChevronDown className="h-3 w-3" />
256
+ ) : (
257
+ <ChevronRight className="h-3 w-3" />
258
+ )}
259
+ <Shield className="h-3 w-3" />
260
+ <span>Granted Permissions ({grantedScopes.split(" ").filter(Boolean).length})</span>
261
+ </button>
262
+
263
+ {scopesOpen && (
264
+ <div className="px-4 pb-3 space-y-2">
265
+ {groupScopes(grantedScopes).map(({ group, scopes }) => (
266
+ <div key={group} className="flex items-start gap-2">
267
+ <span className="text-xs font-medium text-muted-foreground w-24 shrink-0 pt-0.5">
268
+ {group}
269
+ </span>
270
+ <div className="flex flex-wrap gap-1">
271
+ {scopes.map(({ scope, label, basicPlus }) => (
272
+ <Badge
273
+ key={scope}
274
+ variant="outline"
275
+ className="text-[10px] px-1.5 py-0 font-normal"
276
+ >
277
+ {label}
278
+ {basicPlus && (
279
+ <span className="ml-1 text-[9px] text-muted-foreground/60">Basic+</span>
280
+ )}
281
+ </Badge>
282
+ ))}
283
+ </div>
284
+ </div>
285
+ ))}
286
+ </div>
287
+ )}
288
+ </div>
289
+ )}
290
+ </div>
291
+ );
292
+ }
@@ -0,0 +1,17 @@
1
+ import { Badge } from "@/components/ui/badge";
2
+ import { cn } from "@/lib/utils";
3
+
4
+ const priorityColors: Record<string, string> = {
5
+ low: "bg-chart-1/15 text-chart-1 border-chart-1/25",
6
+ medium: "bg-chart-2/15 text-chart-2 border-chart-2/25",
7
+ high: "bg-chart-4/15 text-chart-4 border-chart-4/25",
8
+ urgent: "bg-destructive/15 text-destructive border-destructive/25",
9
+ };
10
+
11
+ export function PriorityBadge({ priority }: { priority: string }) {
12
+ return (
13
+ <Badge variant="outline" className={cn("font-medium", priorityColors[priority] ?? "bg-muted/15 text-muted-foreground border-muted")}>
14
+ {priority}
15
+ </Badge>
16
+ );
17
+ }