@nextsparkjs/plugin-social-media-publisher 0.1.0-beta.1
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/.env.example +76 -0
- package/README.md +423 -0
- package/api/social/connect/callback/route.ts +669 -0
- package/api/social/connect/route.ts +327 -0
- package/api/social/disconnect/route.ts +187 -0
- package/api/social/publish/route.ts +402 -0
- package/docs/01-getting-started/01-introduction.md +471 -0
- package/docs/01-getting-started/02-installation.md +471 -0
- package/docs/01-getting-started/03-configuration.md +515 -0
- package/docs/02-core-features/01-oauth-integration.md +501 -0
- package/docs/02-core-features/02-publishing.md +527 -0
- package/docs/02-core-features/03-token-management.md +661 -0
- package/docs/02-core-features/04-audit-logging.md +646 -0
- package/docs/03-advanced-usage/01-provider-apis.md +764 -0
- package/docs/03-advanced-usage/02-custom-integrations.md +695 -0
- package/docs/03-advanced-usage/03-per-client-architecture.md +575 -0
- package/docs/04-use-cases/01-agency-management.md +661 -0
- package/docs/04-use-cases/02-content-publishing.md +668 -0
- package/docs/04-use-cases/03-analytics-reporting.md +748 -0
- package/entities/audit-logs/audit-logs.config.ts +150 -0
- package/lib/oauth-helper.ts +167 -0
- package/lib/providers/facebook.ts +672 -0
- package/lib/providers/index.ts +21 -0
- package/lib/providers/instagram.ts +791 -0
- package/lib/validation.ts +155 -0
- package/migrations/001_social_media_tables.sql +167 -0
- package/package.json +15 -0
- package/plugin.config.ts +81 -0
- package/tsconfig.json +47 -0
- package/types/social.types.ts +171 -0
|
@@ -0,0 +1,668 @@
|
|
|
1
|
+
# Content Publishing Workflows
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
This guide covers various content publishing workflows using the Social Media Publisher plugin, from simple single-post publishing to advanced batch operations and content management systems.
|
|
6
|
+
|
|
7
|
+
**Covered Workflows:**
|
|
8
|
+
- Single post publishing
|
|
9
|
+
- Batch publishing (cross-posting)
|
|
10
|
+
- Content approval workflows
|
|
11
|
+
- Scheduled publishing
|
|
12
|
+
- Template-based publishing
|
|
13
|
+
- Media management integration
|
|
14
|
+
|
|
15
|
+
## Single Post Publishing
|
|
16
|
+
|
|
17
|
+
### Basic Publishing Flow
|
|
18
|
+
|
|
19
|
+
**Scenario:** Publish one image to one Instagram account
|
|
20
|
+
|
|
21
|
+
**React Component:**
|
|
22
|
+
```typescript
|
|
23
|
+
'use client'
|
|
24
|
+
|
|
25
|
+
import { useState } from 'react'
|
|
26
|
+
import { toast } from 'sonner'
|
|
27
|
+
|
|
28
|
+
export function SinglePostPublisher({
|
|
29
|
+
accountId,
|
|
30
|
+
platform
|
|
31
|
+
}: {
|
|
32
|
+
accountId: string
|
|
33
|
+
platform: 'instagram_business' | 'facebook_page'
|
|
34
|
+
}) {
|
|
35
|
+
const [imageUrl, setImageUrl] = useState('')
|
|
36
|
+
const [caption, setCaption] = useState('')
|
|
37
|
+
const [loading, setLoading] = useState(false)
|
|
38
|
+
|
|
39
|
+
const handlePublish = async () => {
|
|
40
|
+
// Validation
|
|
41
|
+
if (!imageUrl.startsWith('https://')) {
|
|
42
|
+
toast.error('Image URL must use HTTPS')
|
|
43
|
+
return
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (platform === 'instagram_business' && caption.length > 2200) {
|
|
47
|
+
toast.error('Instagram captions must be under 2,200 characters')
|
|
48
|
+
return
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
setLoading(true)
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
const response = await fetch('/api/v1/plugin/social-media-publisher/social/publish', {
|
|
55
|
+
method: 'POST',
|
|
56
|
+
headers: { 'Content-Type': 'application/json' },
|
|
57
|
+
body: JSON.stringify({
|
|
58
|
+
accountId,
|
|
59
|
+
platform,
|
|
60
|
+
imageUrl,
|
|
61
|
+
caption
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
const result = await response.json()
|
|
66
|
+
|
|
67
|
+
if (result.success) {
|
|
68
|
+
toast.success('Posted successfully!')
|
|
69
|
+
// Open post in new tab
|
|
70
|
+
window.open(result.postUrl, '_blank')
|
|
71
|
+
// Reset form
|
|
72
|
+
setImageUrl('')
|
|
73
|
+
setCaption('')
|
|
74
|
+
} else {
|
|
75
|
+
toast.error(result.error || 'Publishing failed')
|
|
76
|
+
}
|
|
77
|
+
} catch (error) {
|
|
78
|
+
toast.error('Network error')
|
|
79
|
+
} finally {
|
|
80
|
+
setLoading(false)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<div className="max-w-2xl mx-auto p-6">
|
|
86
|
+
<h2 className="text-2xl font-bold mb-6">Publish Post</h2>
|
|
87
|
+
|
|
88
|
+
{/* Image URL Input */}
|
|
89
|
+
<div className="mb-4">
|
|
90
|
+
<label className="block text-sm font-medium mb-2">
|
|
91
|
+
Image URL
|
|
92
|
+
</label>
|
|
93
|
+
<input
|
|
94
|
+
type="url"
|
|
95
|
+
placeholder="https://example.com/image.jpg"
|
|
96
|
+
value={imageUrl}
|
|
97
|
+
onChange={(e) => setImageUrl(e.target.value)}
|
|
98
|
+
className="w-full px-4 py-2 border rounded"
|
|
99
|
+
/>
|
|
100
|
+
{imageUrl && (
|
|
101
|
+
<div className="mt-2">
|
|
102
|
+
<img
|
|
103
|
+
src={imageUrl}
|
|
104
|
+
alt="Preview"
|
|
105
|
+
className="max-w-xs rounded"
|
|
106
|
+
onError={() => toast.error('Failed to load image')}
|
|
107
|
+
/>
|
|
108
|
+
</div>
|
|
109
|
+
)}
|
|
110
|
+
</div>
|
|
111
|
+
|
|
112
|
+
{/* Caption Input */}
|
|
113
|
+
<div className="mb-4">
|
|
114
|
+
<label className="block text-sm font-medium mb-2">
|
|
115
|
+
Caption
|
|
116
|
+
<span className="text-gray-500 text-xs ml-2">
|
|
117
|
+
{caption.length}/{platform === 'instagram_business' ? '2,200' : '63,206'}
|
|
118
|
+
</span>
|
|
119
|
+
</label>
|
|
120
|
+
<textarea
|
|
121
|
+
placeholder="Write your caption..."
|
|
122
|
+
value={caption}
|
|
123
|
+
onChange={(e) => setCaption(e.target.value)}
|
|
124
|
+
maxLength={platform === 'instagram_business' ? 2200 : 63206}
|
|
125
|
+
rows={6}
|
|
126
|
+
className="w-full px-4 py-2 border rounded"
|
|
127
|
+
/>
|
|
128
|
+
<p className="text-xs text-gray-500 mt-1">
|
|
129
|
+
Tip: Use emojis 🎉 and hashtags #instagram for better engagement
|
|
130
|
+
</p>
|
|
131
|
+
</div>
|
|
132
|
+
|
|
133
|
+
{/* Publish Button */}
|
|
134
|
+
<button
|
|
135
|
+
onClick={handlePublish}
|
|
136
|
+
disabled={loading || !imageUrl || !caption}
|
|
137
|
+
className="w-full bg-blue-600 text-white py-3 rounded font-medium hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
138
|
+
>
|
|
139
|
+
{loading ? 'Publishing...' : 'Publish Now'}
|
|
140
|
+
</button>
|
|
141
|
+
</div>
|
|
142
|
+
)
|
|
143
|
+
}
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## Batch Publishing (Cross-Posting)
|
|
147
|
+
|
|
148
|
+
### Publish to Multiple Accounts
|
|
149
|
+
|
|
150
|
+
**Scenario:** Post same content to 5 different client accounts
|
|
151
|
+
|
|
152
|
+
**Multi-Select Component:**
|
|
153
|
+
```typescript
|
|
154
|
+
'use client'
|
|
155
|
+
|
|
156
|
+
import { useState, useEffect } from 'react'
|
|
157
|
+
|
|
158
|
+
export function BatchPublisher() {
|
|
159
|
+
const [clients, setClients] = useState([])
|
|
160
|
+
const [selectedAccounts, setSelectedAccounts] = useState<string[]>([])
|
|
161
|
+
const [imageUrl, setImageUrl] = useState('')
|
|
162
|
+
const [caption, setCaption] = useState('')
|
|
163
|
+
const [publishing, setPublishing] = useState(false)
|
|
164
|
+
const [results, setResults] = useState<any[]>([])
|
|
165
|
+
|
|
166
|
+
useEffect(() => {
|
|
167
|
+
// Fetch clients and their accounts
|
|
168
|
+
fetch('/api/v1/custom/clients-with-accounts')
|
|
169
|
+
.then(r => r.json())
|
|
170
|
+
.then(data => setClients(data.clients))
|
|
171
|
+
}, [])
|
|
172
|
+
|
|
173
|
+
const handlePublish = async () => {
|
|
174
|
+
if (selectedAccounts.length === 0) {
|
|
175
|
+
toast.error('Select at least one account')
|
|
176
|
+
return
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
setPublishing(true)
|
|
180
|
+
setResults([])
|
|
181
|
+
|
|
182
|
+
const response = await fetch('/api/v1/custom/cross-post', {
|
|
183
|
+
method: 'POST',
|
|
184
|
+
headers: { 'Content-Type': 'application/json' },
|
|
185
|
+
body: JSON.stringify({
|
|
186
|
+
accountIds: selectedAccounts,
|
|
187
|
+
imageUrl,
|
|
188
|
+
caption
|
|
189
|
+
})
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
const data = await response.json()
|
|
193
|
+
setResults(data.results)
|
|
194
|
+
setPublishing(false)
|
|
195
|
+
|
|
196
|
+
toast.success(`Published to ${data.successful}/${data.total} accounts`)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const toggleAccount = (accountId: string) => {
|
|
200
|
+
setSelectedAccounts(prev =>
|
|
201
|
+
prev.includes(accountId)
|
|
202
|
+
? prev.filter(id => id !== accountId)
|
|
203
|
+
: [...prev, accountId]
|
|
204
|
+
)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return (
|
|
208
|
+
<div className="max-w-4xl mx-auto p-6">
|
|
209
|
+
<h2 className="text-2xl font-bold mb-6">Batch Publishing</h2>
|
|
210
|
+
|
|
211
|
+
<div className="grid grid-cols-2 gap-6">
|
|
212
|
+
{/* Left: Account Selection */}
|
|
213
|
+
<div>
|
|
214
|
+
<h3 className="font-medium mb-4">Select Accounts</h3>
|
|
215
|
+
<div className="border rounded p-4 max-h-96 overflow-y-auto">
|
|
216
|
+
{clients.map((client: any) => (
|
|
217
|
+
<div key={client.id} className="mb-4">
|
|
218
|
+
<p className="font-medium text-sm text-gray-600 mb-2">
|
|
219
|
+
{client.name}
|
|
220
|
+
</p>
|
|
221
|
+
{client.accounts.map((account: any) => (
|
|
222
|
+
<label
|
|
223
|
+
key={account.id}
|
|
224
|
+
className="flex items-center gap-2 p-2 hover:bg-gray-50 rounded cursor-pointer"
|
|
225
|
+
>
|
|
226
|
+
<input
|
|
227
|
+
type="checkbox"
|
|
228
|
+
checked={selectedAccounts.includes(account.id)}
|
|
229
|
+
onChange={() => toggleAccount(account.id)}
|
|
230
|
+
/>
|
|
231
|
+
<span className="text-sm">
|
|
232
|
+
{account.platformAccountName}
|
|
233
|
+
<span className="text-xs text-gray-500 ml-1">
|
|
234
|
+
({account.platform === 'instagram_business' ? 'Instagram' : 'Facebook'})
|
|
235
|
+
</span>
|
|
236
|
+
</span>
|
|
237
|
+
</label>
|
|
238
|
+
))}
|
|
239
|
+
</div>
|
|
240
|
+
))}
|
|
241
|
+
</div>
|
|
242
|
+
<p className="text-sm text-gray-600 mt-2">
|
|
243
|
+
{selectedAccounts.length} account(s) selected
|
|
244
|
+
</p>
|
|
245
|
+
</div>
|
|
246
|
+
|
|
247
|
+
{/* Right: Content */}
|
|
248
|
+
<div>
|
|
249
|
+
<h3 className="font-medium mb-4">Content</h3>
|
|
250
|
+
|
|
251
|
+
<div className="mb-4">
|
|
252
|
+
<input
|
|
253
|
+
type="url"
|
|
254
|
+
placeholder="Image URL (https://...)"
|
|
255
|
+
value={imageUrl}
|
|
256
|
+
onChange={(e) => setImageUrl(e.target.value)}
|
|
257
|
+
className="w-full px-4 py-2 border rounded"
|
|
258
|
+
/>
|
|
259
|
+
{imageUrl && (
|
|
260
|
+
<img
|
|
261
|
+
src={imageUrl}
|
|
262
|
+
alt="Preview"
|
|
263
|
+
className="mt-2 max-w-full h-48 object-cover rounded"
|
|
264
|
+
/>
|
|
265
|
+
)}
|
|
266
|
+
</div>
|
|
267
|
+
|
|
268
|
+
<textarea
|
|
269
|
+
placeholder="Caption (max 2,200 chars for Instagram)"
|
|
270
|
+
value={caption}
|
|
271
|
+
onChange={(e) => setCaption(e.target.value)}
|
|
272
|
+
maxLength={2200}
|
|
273
|
+
rows={8}
|
|
274
|
+
className="w-full px-4 py-2 border rounded mb-4"
|
|
275
|
+
/>
|
|
276
|
+
|
|
277
|
+
<button
|
|
278
|
+
onClick={handlePublish}
|
|
279
|
+
disabled={publishing || selectedAccounts.length === 0 || !imageUrl}
|
|
280
|
+
className="w-full bg-blue-600 text-white py-3 rounded font-medium hover:bg-blue-700 disabled:opacity-50"
|
|
281
|
+
>
|
|
282
|
+
{publishing
|
|
283
|
+
? `Publishing to ${selectedAccounts.length} account(s)...`
|
|
284
|
+
: `Publish to ${selectedAccounts.length} account(s)`
|
|
285
|
+
}
|
|
286
|
+
</button>
|
|
287
|
+
</div>
|
|
288
|
+
</div>
|
|
289
|
+
|
|
290
|
+
{/* Results */}
|
|
291
|
+
{results.length > 0 && (
|
|
292
|
+
<div className="mt-8">
|
|
293
|
+
<h3 className="font-medium mb-4">Results</h3>
|
|
294
|
+
<div className="border rounded divide-y">
|
|
295
|
+
{results.map((result: any, i) => (
|
|
296
|
+
<div key={i} className="p-4 flex justify-between items-center">
|
|
297
|
+
<div>
|
|
298
|
+
<p className="font-medium">{result.accountName}</p>
|
|
299
|
+
<p className="text-sm text-gray-600">
|
|
300
|
+
{result.status === 'published' ? (
|
|
301
|
+
<span className="text-green-600">✓ Published</span>
|
|
302
|
+
) : (
|
|
303
|
+
<span className="text-red-600">✗ Failed: {result.error}</span>
|
|
304
|
+
)}
|
|
305
|
+
</p>
|
|
306
|
+
</div>
|
|
307
|
+
{result.postUrl && (
|
|
308
|
+
<a
|
|
309
|
+
href={result.postUrl}
|
|
310
|
+
target="_blank"
|
|
311
|
+
className="text-blue-600 text-sm"
|
|
312
|
+
>
|
|
313
|
+
View Post →
|
|
314
|
+
</a>
|
|
315
|
+
)}
|
|
316
|
+
</div>
|
|
317
|
+
))}
|
|
318
|
+
</div>
|
|
319
|
+
</div>
|
|
320
|
+
)}
|
|
321
|
+
</div>
|
|
322
|
+
)
|
|
323
|
+
}
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
## Content Approval Workflow
|
|
327
|
+
|
|
328
|
+
### 3-Step Approval Process
|
|
329
|
+
|
|
330
|
+
**Flow:** Creator → Manager → Publish
|
|
331
|
+
|
|
332
|
+
**Database Schema:**
|
|
333
|
+
```sql
|
|
334
|
+
CREATE TABLE "pending_posts" (
|
|
335
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
336
|
+
"accountId" UUID NOT NULL REFERENCES "clients_social_platforms"(id),
|
|
337
|
+
"imageUrl" TEXT NOT NULL,
|
|
338
|
+
caption TEXT,
|
|
339
|
+
status TEXT DEFAULT 'pending_approval', -- 'pending_approval', 'approved', 'rejected', 'published'
|
|
340
|
+
"createdBy" TEXT NOT NULL,
|
|
341
|
+
"approvedBy" TEXT,
|
|
342
|
+
"rejectedBy" TEXT,
|
|
343
|
+
"rejectionReason" TEXT,
|
|
344
|
+
"publishedAt" TIMESTAMPTZ,
|
|
345
|
+
"postId" TEXT,
|
|
346
|
+
"postUrl" TEXT,
|
|
347
|
+
"createdAt" TIMESTAMPTZ DEFAULT now(),
|
|
348
|
+
"updatedAt" TIMESTAMPTZ DEFAULT now()
|
|
349
|
+
);
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
**Submit for Approval:**
|
|
353
|
+
```typescript
|
|
354
|
+
// app/actions/submit-for-approval.ts
|
|
355
|
+
'use server'
|
|
356
|
+
|
|
357
|
+
export async function submitForApproval(data: {
|
|
358
|
+
accountId: string
|
|
359
|
+
imageUrl: string
|
|
360
|
+
caption: string
|
|
361
|
+
}) {
|
|
362
|
+
const session = await auth.api.getSession({ headers: await headers() })
|
|
363
|
+
|
|
364
|
+
const result = await query(`
|
|
365
|
+
INSERT INTO "pending_posts" (
|
|
366
|
+
"accountId", "imageUrl", caption, "createdBy", status
|
|
367
|
+
) VALUES ($1, $2, $3, $4, 'pending_approval')
|
|
368
|
+
RETURNING id
|
|
369
|
+
`, [data.accountId, data.imageUrl, data.caption, session.user.id])
|
|
370
|
+
|
|
371
|
+
// Notify managers
|
|
372
|
+
await notifyManagers(data.accountId, result.rows[0].id)
|
|
373
|
+
|
|
374
|
+
return { success: true, postId: result.rows[0].id }
|
|
375
|
+
}
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
**Approval Interface:**
|
|
379
|
+
```typescript
|
|
380
|
+
// app/dashboard/approvals/page.tsx
|
|
381
|
+
export default async function ApprovalsPage() {
|
|
382
|
+
const pendingPosts = await query(`
|
|
383
|
+
SELECT
|
|
384
|
+
pp.*,
|
|
385
|
+
csp."platformAccountName",
|
|
386
|
+
c.name as "clientName"
|
|
387
|
+
FROM "pending_posts" pp
|
|
388
|
+
JOIN "clients_social_platforms" csp ON csp.id = pp."accountId"
|
|
389
|
+
JOIN "clients" c ON c.id = csp."parentId"
|
|
390
|
+
WHERE pp.status = 'pending_approval'
|
|
391
|
+
ORDER BY pp."createdAt" ASC
|
|
392
|
+
`)
|
|
393
|
+
|
|
394
|
+
return (
|
|
395
|
+
<div>
|
|
396
|
+
<h1>Pending Approvals</h1>
|
|
397
|
+
|
|
398
|
+
{pendingPosts.rows.map(post => (
|
|
399
|
+
<div key={post.id} className="border p-4 mb-4">
|
|
400
|
+
<div className="flex gap-4">
|
|
401
|
+
<img
|
|
402
|
+
src={post.imageUrl}
|
|
403
|
+
alt=""
|
|
404
|
+
className="w-32 h-32 object-cover rounded"
|
|
405
|
+
/>
|
|
406
|
+
<div className="flex-1">
|
|
407
|
+
<p className="font-medium">{post.clientName} - {post.platformAccountName}</p>
|
|
408
|
+
<p className="text-sm text-gray-600 mt-2">{post.caption}</p>
|
|
409
|
+
<p className="text-xs text-gray-500 mt-2">
|
|
410
|
+
Submitted by {post.createdBy} on {new Date(post.createdAt).toLocaleString()}
|
|
411
|
+
</p>
|
|
412
|
+
</div>
|
|
413
|
+
</div>
|
|
414
|
+
|
|
415
|
+
<div className="mt-4 flex gap-2">
|
|
416
|
+
<form action={approvePost}>
|
|
417
|
+
<input type="hidden" name="postId" value={post.id} />
|
|
418
|
+
<button
|
|
419
|
+
type="submit"
|
|
420
|
+
className="bg-green-600 text-white px-4 py-2 rounded"
|
|
421
|
+
>
|
|
422
|
+
Approve & Publish
|
|
423
|
+
</button>
|
|
424
|
+
</form>
|
|
425
|
+
|
|
426
|
+
<form action={rejectPost}>
|
|
427
|
+
<input type="hidden" name="postId" value={post.id} />
|
|
428
|
+
<button
|
|
429
|
+
type="submit"
|
|
430
|
+
className="bg-red-600 text-white px-4 py-2 rounded"
|
|
431
|
+
>
|
|
432
|
+
Reject
|
|
433
|
+
</button>
|
|
434
|
+
</form>
|
|
435
|
+
</div>
|
|
436
|
+
</div>
|
|
437
|
+
))}
|
|
438
|
+
</div>
|
|
439
|
+
)
|
|
440
|
+
}
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
## Scheduled Publishing
|
|
444
|
+
|
|
445
|
+
### Content Calendar
|
|
446
|
+
|
|
447
|
+
**Calendar View:**
|
|
448
|
+
```typescript
|
|
449
|
+
'use client'
|
|
450
|
+
|
|
451
|
+
import { Calendar } from '@/components/ui/calendar'
|
|
452
|
+
import { useState } from 'react'
|
|
453
|
+
|
|
454
|
+
export function ContentCalendar() {
|
|
455
|
+
const [selectedDate, setSelectedDate] = useState(new Date())
|
|
456
|
+
const [scheduledPosts, setScheduledPosts] = useState([])
|
|
457
|
+
|
|
458
|
+
useEffect(() => {
|
|
459
|
+
// Fetch scheduled posts for selected month
|
|
460
|
+
fetch(`/api/v1/custom/scheduled-posts?month=${selectedDate.toISOString()}`)
|
|
461
|
+
.then(r => r.json())
|
|
462
|
+
.then(data => setScheduledPosts(data.posts))
|
|
463
|
+
}, [selectedDate])
|
|
464
|
+
|
|
465
|
+
return (
|
|
466
|
+
<div className="grid grid-cols-2 gap-6">
|
|
467
|
+
{/* Calendar */}
|
|
468
|
+
<div>
|
|
469
|
+
<Calendar
|
|
470
|
+
mode="single"
|
|
471
|
+
selected={selectedDate}
|
|
472
|
+
onSelect={setSelectedDate}
|
|
473
|
+
className="rounded border"
|
|
474
|
+
/>
|
|
475
|
+
</div>
|
|
476
|
+
|
|
477
|
+
{/* Posts for Selected Date */}
|
|
478
|
+
<div>
|
|
479
|
+
<h3 className="font-medium mb-4">
|
|
480
|
+
Scheduled for {selectedDate.toLocaleDateString()}
|
|
481
|
+
</h3>
|
|
482
|
+
|
|
483
|
+
{scheduledPosts
|
|
484
|
+
.filter(post =>
|
|
485
|
+
new Date(post.scheduledFor).toDateString() === selectedDate.toDateString()
|
|
486
|
+
)
|
|
487
|
+
.map(post => (
|
|
488
|
+
<div key={post.id} className="border p-3 mb-2 rounded">
|
|
489
|
+
<p className="text-sm font-medium">{post.accountName}</p>
|
|
490
|
+
<p className="text-xs text-gray-600">
|
|
491
|
+
{new Date(post.scheduledFor).toLocaleTimeString()}
|
|
492
|
+
</p>
|
|
493
|
+
<p className="text-sm mt-1">
|
|
494
|
+
{post.caption.substring(0, 60)}...
|
|
495
|
+
</p>
|
|
496
|
+
</div>
|
|
497
|
+
))
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
<button
|
|
501
|
+
onClick={() => openScheduleModal(selectedDate)}
|
|
502
|
+
className="w-full border-2 border-dashed rounded p-4 text-gray-600 hover:border-blue-500 hover:text-blue-600"
|
|
503
|
+
>
|
|
504
|
+
+ Schedule Post
|
|
505
|
+
</button>
|
|
506
|
+
</div>
|
|
507
|
+
</div>
|
|
508
|
+
)
|
|
509
|
+
}
|
|
510
|
+
```
|
|
511
|
+
|
|
512
|
+
## Template-Based Publishing
|
|
513
|
+
|
|
514
|
+
### Save and Reuse Templates
|
|
515
|
+
|
|
516
|
+
**Create Template:**
|
|
517
|
+
```typescript
|
|
518
|
+
export async function saveTemplate(data: {
|
|
519
|
+
name: string
|
|
520
|
+
imageUrl: string
|
|
521
|
+
caption: string
|
|
522
|
+
tags: string[]
|
|
523
|
+
}) {
|
|
524
|
+
const session = await auth.api.getSession({ headers: await headers() })
|
|
525
|
+
|
|
526
|
+
const result = await query(`
|
|
527
|
+
INSERT INTO "post_templates" (
|
|
528
|
+
name, "imageUrl", caption, tags, "createdBy"
|
|
529
|
+
) VALUES ($1, $2, $3, $4, $5)
|
|
530
|
+
RETURNING id
|
|
531
|
+
`, [data.name, data.imageUrl, data.caption, JSON.stringify(data.tags), session.user.id])
|
|
532
|
+
|
|
533
|
+
return { templateId: result.rows[0].id }
|
|
534
|
+
}
|
|
535
|
+
```
|
|
536
|
+
|
|
537
|
+
**Template Library:**
|
|
538
|
+
```typescript
|
|
539
|
+
export function TemplateLibrary() {
|
|
540
|
+
const [templates, setTemplates] = useState([])
|
|
541
|
+
|
|
542
|
+
const useTemplate = async (template: any, accountId: string) => {
|
|
543
|
+
const response = await fetch(`/api/v1/custom/templates/publish/${template.id}`, {
|
|
544
|
+
method: 'POST',
|
|
545
|
+
headers: { 'Content-Type': 'application/json' },
|
|
546
|
+
body: JSON.stringify({ accountId })
|
|
547
|
+
})
|
|
548
|
+
|
|
549
|
+
const result = await response.json()
|
|
550
|
+
|
|
551
|
+
if (result.success) {
|
|
552
|
+
toast.success('Published from template!')
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
return (
|
|
557
|
+
<div className="grid grid-cols-3 gap-4">
|
|
558
|
+
{templates.map(template => (
|
|
559
|
+
<div key={template.id} className="border rounded p-4">
|
|
560
|
+
<img
|
|
561
|
+
src={template.imageUrl}
|
|
562
|
+
alt={template.name}
|
|
563
|
+
className="w-full h-48 object-cover rounded mb-2"
|
|
564
|
+
/>
|
|
565
|
+
<p className="font-medium">{template.name}</p>
|
|
566
|
+
<p className="text-sm text-gray-600 line-clamp-2 mt-1">
|
|
567
|
+
{template.caption}
|
|
568
|
+
</p>
|
|
569
|
+
<div className="flex gap-1 mt-2">
|
|
570
|
+
{template.tags.map((tag: string) => (
|
|
571
|
+
<span key={tag} className="text-xs bg-gray-100 px-2 py-1 rounded">
|
|
572
|
+
{tag}
|
|
573
|
+
</span>
|
|
574
|
+
))}
|
|
575
|
+
</div>
|
|
576
|
+
<button
|
|
577
|
+
onClick={() => openUseTemplateModal(template)}
|
|
578
|
+
className="w-full mt-3 bg-blue-600 text-white py-2 rounded text-sm"
|
|
579
|
+
>
|
|
580
|
+
Use Template
|
|
581
|
+
</button>
|
|
582
|
+
</div>
|
|
583
|
+
))}
|
|
584
|
+
</div>
|
|
585
|
+
)
|
|
586
|
+
}
|
|
587
|
+
```
|
|
588
|
+
|
|
589
|
+
## Media Management Integration
|
|
590
|
+
|
|
591
|
+
### Upload to CDN Before Publishing
|
|
592
|
+
|
|
593
|
+
```typescript
|
|
594
|
+
export async function uploadAndPublish(
|
|
595
|
+
file: File,
|
|
596
|
+
accountId: string,
|
|
597
|
+
caption: string
|
|
598
|
+
) {
|
|
599
|
+
// 1. Upload to CDN
|
|
600
|
+
const formData = new FormData()
|
|
601
|
+
formData.append('file', file)
|
|
602
|
+
|
|
603
|
+
const uploadResponse = await fetch('/api/upload', {
|
|
604
|
+
method: 'POST',
|
|
605
|
+
body: formData
|
|
606
|
+
})
|
|
607
|
+
|
|
608
|
+
const { url: imageUrl } = await uploadResponse.json()
|
|
609
|
+
|
|
610
|
+
// 2. Publish with CDN URL
|
|
611
|
+
const publishResponse = await fetch('/api/v1/plugin/social-media-publisher/social/publish', {
|
|
612
|
+
method: 'POST',
|
|
613
|
+
headers: { 'Content-Type': 'application/json' },
|
|
614
|
+
body: JSON.stringify({
|
|
615
|
+
accountId,
|
|
616
|
+
platform: 'instagram_business',
|
|
617
|
+
imageUrl, // CDN URL
|
|
618
|
+
caption
|
|
619
|
+
})
|
|
620
|
+
})
|
|
621
|
+
|
|
622
|
+
return await publishResponse.json()
|
|
623
|
+
}
|
|
624
|
+
```
|
|
625
|
+
|
|
626
|
+
## Best Practices
|
|
627
|
+
|
|
628
|
+
### Content Validation
|
|
629
|
+
|
|
630
|
+
```typescript
|
|
631
|
+
function validateContent(
|
|
632
|
+
imageUrl: string,
|
|
633
|
+
caption: string,
|
|
634
|
+
platform: string
|
|
635
|
+
): { valid: boolean; errors: string[] } {
|
|
636
|
+
const errors: string[] = []
|
|
637
|
+
|
|
638
|
+
// Image URL
|
|
639
|
+
if (!imageUrl.startsWith('https://')) {
|
|
640
|
+
errors.push('Image URL must use HTTPS')
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// Caption length
|
|
644
|
+
const maxLength = platform === 'instagram_business' ? 2200 : 63206
|
|
645
|
+
if (caption.length > maxLength) {
|
|
646
|
+
errors.push(`Caption too long (max ${maxLength} chars)`)
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// Instagram hashtag limit
|
|
650
|
+
if (platform === 'instagram_business') {
|
|
651
|
+
const hashtags = caption.match(/#\w+/g) || []
|
|
652
|
+
if (hashtags.length > 30) {
|
|
653
|
+
errors.push('Too many hashtags (max 30 for Instagram)')
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
return {
|
|
658
|
+
valid: errors.length === 0,
|
|
659
|
+
errors
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
```
|
|
663
|
+
|
|
664
|
+
## Next Steps
|
|
665
|
+
|
|
666
|
+
- **[Agency Management](./01-agency-management.md)** - Multi-client workflows
|
|
667
|
+
- **[Analytics Reporting](./03-analytics-reporting.md)** - Track performance
|
|
668
|
+
- **[Custom Integrations](../03-advanced-usage/02-custom-integrations.md)** - Extend publishing
|