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,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
|
+
}
|