diff --git a/modules/rsocials/components/campaign-planner.css b/modules/rsocials/components/campaign-planner.css index 6c838bbd..cf6e8a14 100644 --- a/modules/rsocials/components/campaign-planner.css +++ b/modules/rsocials/components/campaign-planner.css @@ -95,6 +95,228 @@ folk-campaign-planner { position: relative; } +/* ── Platform palette (drag source sidebar) ── */ +.cp-pp { + width: 220px; + flex-shrink: 0; + display: flex; + flex-direction: column; + border-right: 1px solid var(--rs-border, #2d2d44); + background: var(--rs-bg-surface-sunken, #14141e); + transition: width 0.15s ease; + overflow: hidden; +} + +.cp-pp--collapsed { + width: 38px; +} + +.cp-pp--collapsed .cp-pp-title, +.cp-pp--collapsed .cp-pp-hint, +.cp-pp--collapsed .cp-pp-list { + display: none; +} + +.cp-pp-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 12px; + border-bottom: 1px solid var(--rs-border, #2d2d44); +} + +.cp-pp-title { + font-size: 12px; + font-weight: 600; + color: var(--rs-text-primary, #e1e1e1); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.cp-pp-toggle { + background: transparent; + border: 1px solid var(--rs-border, #3d3d5c); + color: var(--rs-text-secondary, #a0a0b8); + border-radius: 5px; + width: 22px; + height: 22px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + font-size: 12px; +} + +.cp-pp-toggle:hover { background: var(--rs-bg-surface-raised, #252540); } + +.cp-pp-hint { + padding: 8px 12px; + font-size: 11px; + color: var(--rs-text-muted, #888); + line-height: 1.4; + border-bottom: 1px solid var(--rs-border, #2d2d44); +} + +.cp-pp-list { + flex: 1; + overflow-y: auto; + padding: 8px; + display: flex; + flex-direction: column; + gap: 5px; +} + +.cp-pp-chip { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px; + border-radius: 8px; + background: var(--rs-bg-surface, #1a1a2e); + border: 1px solid var(--rs-border, #2d2d44); + cursor: grab; + user-select: none; + transition: border-color 0.1s, background 0.1s, transform 0.05s; +} + +.cp-pp-chip:hover { + border-color: var(--rs-border-strong, #3d3d5c); + background: var(--rs-bg-surface-raised, #252540); +} + +.cp-pp-chip:active { cursor: grabbing; } + +.cp-pp-chip.dragging { + opacity: 0.5; + transform: scale(0.96); +} + +.cp-pp-chip__icon { + width: 28px; + height: 28px; + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; + font-size: 14px; + flex-shrink: 0; +} + +.cp-pp-chip__meta { + min-width: 0; + flex: 1; +} + +.cp-pp-chip__label { + font-size: 12px; + font-weight: 600; + color: var(--rs-text-primary, #e1e1e1); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.cp-pp-chip__limit { + font-size: 10px; + color: var(--rs-text-muted, #888); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.cp-canvas--drop-target { + outline: 2px dashed #3b82f6; + outline-offset: -4px; + background: color-mix(in srgb, var(--rs-canvas-bg, #0f0f23) 92%, #3b82f6); +} + +/* ── Inline config: platform-specific bits ── */ +.cp-icp-platform-note { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 8px; + margin-bottom: 8px; + border-radius: 6px; + background: color-mix(in srgb, var(--plat, #3b82f6) 10%, transparent); + border-left: 3px solid var(--plat, #3b82f6); + font-size: 11px; + line-height: 1.35; + color: var(--rs-text-secondary, #a0a0b8); +} + +.cp-icp-platform-icon { + width: 22px; + height: 22px; + border-radius: 5px; + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; + font-size: 12px; + flex-shrink: 0; +} + +.cp-icp-platform-text { + flex: 1; + min-width: 0; +} + +.cp-icp-sub { + font-size: 10px; + color: var(--rs-text-muted, #888); + font-weight: 400; + margin-left: 4px; +} + +.cp-icp-charcount.over, +.cp-icp-sub.over { + color: #ef4444; + font-weight: 600; +} + +.cp-inline-config textarea.over, +.cp-inline-config input.over { + border-color: #ef4444 !important; +} + +.cp-icp-warn { + padding: 6px 8px; + border-radius: 6px; + background: rgba(245, 158, 11, 0.12); + border: 1px solid rgba(245, 158, 11, 0.35); + color: #f59e0b; + font-size: 11px; + margin: 4px 0 6px; +} + +.cp-icp-warn.ok { + background: rgba(34, 197, 94, 0.12); + border-color: rgba(34, 197, 94, 0.35); + color: #22c55e; +} + +.cp-icp-check { + display: flex !important; + flex-direction: row !important; + align-items: center; + gap: 6px; + font-size: 11px; + color: var(--rs-text-secondary, #a0a0b8); + cursor: pointer; + margin: 4px 0; +} + +.cp-icp-check input { width: auto !important; margin: 0; } + +@media (max-width: 720px) { + .cp-pp { width: 56px; } + .cp-pp-title, .cp-pp-hint { display: none; } + .cp-pp-chip__meta { display: none; } + .cp-pp-chip { padding: 6px; justify-content: center; } +} + .cp-canvas { flex: 1; position: relative; diff --git a/modules/rsocials/components/folk-campaign-planner.ts b/modules/rsocials/components/folk-campaign-planner.ts index 64e93181..ef6bc0e3 100644 --- a/modules/rsocials/components/folk-campaign-planner.ts +++ b/modules/rsocials/components/folk-campaign-planner.ts @@ -28,6 +28,14 @@ import type { import { SocialsLocalFirstClient } from '../local-first-client'; import { buildDemoCampaignFlow, PLATFORM_ICONS, PLATFORM_COLORS } from '../campaign-data'; import { startPresenceHeartbeat } from '../../../shared/collab-presence'; +import { + PLATFORM_SPECS, + ALL_PLATFORM_IDS, + getPlatformSpec, + charLimitFor, + type PostizPlatformId, + type PlatformSpec, +} from '../lib/platform-specs'; // ── Port definitions ── @@ -718,6 +726,45 @@ class FolkCampaignPlanner extends HTMLElement { this.scheduleSave(); } + /** + * Create a platform-tagged post node from a Postiz spec. Pre-populates + * platform, postType, title, and platformSettings so the inline editor + * surfaces the right constraints immediately. + */ + private addPostForPlatform(platformId: PostizPlatformId, x: number, y: number) { + const spec = getPlatformSpec(platformId); + if (!spec) { + this.addNode('post', x, y); + return; + } + const id = `post-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; + const defaults: Record = {}; + for (const f of spec.extraFields || []) { + if (f.type === 'select' && f.options?.length) defaults[f.key] = f.options[0]; + else if (f.type === 'checkbox') defaults[f.key] = false; + else defaults[f.key] = ''; + } + const data: PostNodeData = { + label: `${spec.label} Post`, + platform: platformId, + postType: spec.defaultPostType, + content: '', + scheduledAt: '', + status: 'draft', + hashtags: [], + title: spec.titleRequired || spec.titleLimit ? '' : undefined, + mediaUrls: spec.mediaRequired ? [] : undefined, + platformSettings: Object.keys(defaults).length ? defaults : undefined, + }; + const node: CampaignPlannerNode = { id, type: 'post', position: { x, y }, data }; + this.nodes.push(node); + this.drawCanvasContent(); + this.selectedNodeId = id; + this.updateSelectionHighlight(); + this.scheduleSave(); + setTimeout(() => this.enterInlineEdit(id), 50); + } + private deleteNode(id: string) { this.nodes = this.nodes.filter(n => n.id !== id); this.edges = this.edges.filter(e => e.from !== id && e.to !== id); @@ -814,19 +861,68 @@ class FolkCampaignPlanner extends HTMLElement { : `\u23f3 Queued in Postiz (${esc(d.postizPostId || '')})`} ` : ''; - const canSend = !hasPostiz && !!d.content?.trim(); + const spec = getPlatformSpec(d.platform); + const charCount = (d.content || '').length; + const charMax = spec?.charLimit ?? 2200; + const overLimit = charCount > charMax; + const hashtagCount = (d.hashtags || []).length; + const hashtagMax = spec?.hashtagLimit ?? 30; + const tagOver = hashtagMax > 0 && hashtagCount > hashtagMax; + const needsMedia = !!spec?.mediaRequired; + const hasMedia = !!(d.mediaUrls && d.mediaUrls.length > 0); + const canSend = !hasPostiz && !!d.content?.trim() + && !overLimit && (!needsMedia || hasMedia); + + const titleField = (spec?.titleLimit || spec?.titleRequired) ? ` + + + ` : ''; + + const postTypeOptions = (spec?.postTypes || ['text', 'thread', 'image', 'carousel', 'video', 'short']) + .map(t => ``).join(''); + + const extraFields = (spec?.extraFields || []).map(f => { + const val = (d.platformSettings?.[f.key] ?? '') as any; + if (f.type === 'select') { + return ` + `; + } + if (f.type === 'checkbox') { + return ``; + } + if (f.type === 'textarea') { + return ` + `; + } + return ` + `; + }).join(''); + body = `
- - + ${spec ? `
+ ${spec.icon} + ${esc(spec.note)} +
` : ''} + ${titleField} + + - + + ${needsMedia ? `
${hasMedia ? '\u2713' : '\u26a0'} ${esc(spec!.label)} requires ${spec!.mediaTypes.join('/')}. Up to ${spec!.maxMedia}.
` : ''} @@ -835,8 +931,9 @@ class FolkCampaignPlanner extends HTMLElement { - - + + + ${extraFields} ${postizBadge} +
+
Each drop creates a post pre-tagged with the platform's limits & fields.
+
${chips}
+ `; + } + private render() { const schedulerUrl = 'https://demo.rsocials.online'; @@ -1577,6 +1746,7 @@ class FolkCampaignPlanner extends HTMLElement { ` : ''}
+ ${this.renderPlatformPalette()}
@@ -1700,7 +1870,7 @@ class FolkCampaignPlanner extends HTMLElement { : '#f59e0b'; const preview = (d.content || '').split('\n')[0].substring(0, 50); const charCount = (d.content || '').length; - const charMax = platform === 'x' ? 280 : 2200; + const charMax = charLimitFor(platform); const charPct = Math.min(1, charCount / charMax) * 100; const dateStr = d.scheduledAt ? new Date(d.scheduledAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }) : 'Unscheduled'; const staleBadge = node.stale ? `\u26a0 stale` : ''; @@ -2249,10 +2419,61 @@ class FolkCampaignPlanner extends HTMLElement { // ── Event listeners ── + private attachPaletteListeners() { + const palette = this.shadow.getElementById('cp-platform-palette'); + const canvas = this.shadow.getElementById('cp-canvas'); + const svg = this.shadow.getElementById('cp-svg') as SVGSVGElement | null; + if (!palette || !canvas || !svg) return; + + this.shadow.getElementById('cp-pp-toggle')?.addEventListener('click', () => { + this.paletteCollapsed = !this.paletteCollapsed; + palette.classList.toggle('cp-pp--collapsed', this.paletteCollapsed); + const btn = this.shadow.getElementById('cp-pp-toggle'); + if (btn) btn.textContent = this.paletteCollapsed ? '\u00bb' : '\u00ab'; + }); + + palette.querySelectorAll('.cp-pp-chip').forEach(chip => { + chip.addEventListener('dragstart', (e: DragEvent) => { + const platform = chip.getAttribute('data-platform') || ''; + if (!e.dataTransfer) return; + e.dataTransfer.effectAllowed = 'copy'; + e.dataTransfer.setData('application/x-rsocials-platform', platform); + e.dataTransfer.setData('text/plain', platform); + chip.classList.add('dragging'); + }); + chip.addEventListener('dragend', () => chip.classList.remove('dragging')); + }); + + canvas.addEventListener('dragover', (e: DragEvent) => { + const types = e.dataTransfer?.types; + if (!types || !Array.from(types).some(t => + t === 'application/x-rsocials-platform' || t === 'text/plain' + )) return; + e.preventDefault(); + if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'; + canvas.classList.add('cp-canvas--drop-target'); + }); + canvas.addEventListener('dragleave', (e: DragEvent) => { + if (e.target === canvas) canvas.classList.remove('cp-canvas--drop-target'); + }); + canvas.addEventListener('drop', (e: DragEvent) => { + const platform = (e.dataTransfer?.getData('application/x-rsocials-platform') + || e.dataTransfer?.getData('text/plain') || '').trim(); + canvas.classList.remove('cp-canvas--drop-target'); + if (!platform || !(ALL_PLATFORM_IDS as string[]).includes(platform)) return; + e.preventDefault(); + const rect = svg.getBoundingClientRect(); + const canvasX = (e.clientX - rect.left - this.canvasPanX) / this.canvasZoom - 120; + const canvasY = (e.clientY - rect.top - this.canvasPanY) / this.canvasZoom - 60; + this.addPostForPlatform(platform as PostizPlatformId, canvasX, canvasY); + }); + } + private attachListeners() { const svg = this.shadow.getElementById('cp-svg') as SVGSVGElement | null; const canvas = this.shadow.getElementById('cp-canvas'); if (!svg || !canvas) return; + this.attachPaletteListeners(); // View switcher buttons this.shadow.querySelectorAll('.cp-view-btn').forEach(btn => { diff --git a/modules/rsocials/components/folk-campaigns-dashboard.ts b/modules/rsocials/components/folk-campaigns-dashboard.ts index 49f0e6ef..f1206406 100644 --- a/modules/rsocials/components/folk-campaigns-dashboard.ts +++ b/modules/rsocials/components/folk-campaigns-dashboard.ts @@ -11,6 +11,7 @@ import { TourEngine } from '../../../shared/tour-engine'; import type { TourStep } from '../../../shared/tour-engine'; +import { getAccessToken } from '../../../shared/components/rstack-identity'; import type { CampaignFlow, CampaignPlannerNode, @@ -153,12 +154,24 @@ class FolkCampaignsDashboard extends HTMLElement { this.loadFlows(); } + private authHeaders(extra: Record = {}): Record { + const token = getAccessToken(); + return { + ...extra, + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }; + } + private async loadFlows() { try { - const res = await fetch(`${this.basePath}api/campaign/flows`); + const res = await fetch(`${this.basePath}api/campaign/flows`, { + headers: this.authHeaders(), + }); if (res.ok) { const data = await res.json(); this.flows = data.results || []; + } else { + console.warn('[CampaignsDashboard] loadFlows failed:', res.status); } } catch { console.warn('[CampaignsDashboard] Failed to load flows'); @@ -173,18 +186,29 @@ class FolkCampaignsDashboard extends HTMLElement { startTour() { this._tour.start(); } private async createFlow() { + if (!getAccessToken()) { + alert('Sign in to create a campaign.'); + return; + } try { const res = await fetch(`${this.basePath}api/campaign/flows`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: this.authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ name: 'New Campaign' }), }); - if (res.ok) { - const flow = await res.json(); - this.navigateToFlow(flow.id); + if (!res.ok) { + const msg = await res.text().catch(() => ''); + console.error('[CampaignsDashboard] createFlow failed:', res.status, msg); + alert(res.status === 401 || res.status === 403 + ? 'You need write access to this space to create a campaign.' + : `Failed to create campaign (${res.status})`); + return; } - } catch { - console.error('[CampaignsDashboard] Failed to create flow'); + const flow = await res.json(); + this.navigateToFlow(flow.id); + } catch (e) { + console.error('[CampaignsDashboard] Failed to create flow', e); + alert('Failed to create campaign — check your connection.'); } } diff --git a/modules/rsocials/lib/platform-specs.ts b/modules/rsocials/lib/platform-specs.ts new file mode 100644 index 00000000..e396a3ad --- /dev/null +++ b/modules/rsocials/lib/platform-specs.ts @@ -0,0 +1,338 @@ +/** + * Postiz platform specifications. + * + * Character limits, required fields, media rules and postType defaults per + * Postiz provider identifier. Drives the planner palette and inline editor: + * dropping a platform onto the canvas creates a `post` node pre-tagged with + * that platform's constraints. + * + * Limits reflect Postiz 5.x provider handlers as of 2026-04. Numbers match + * what Postiz's `settings.__type` handlers enforce server-side. + */ + +export type PostizPlatformId = + | 'x' + | 'linkedin' + | 'facebook' + | 'instagram' + | 'threads' + | 'bluesky' + | 'mastodon' + | 'youtube' + | 'tiktok' + | 'reddit' + | 'pinterest' + | 'discord' + | 'slack'; + +export type PostizMediaKind = 'image' | 'video' | 'gif'; +export type PostizPostType = + | 'text' + | 'thread' + | 'image' + | 'carousel' + | 'video' + | 'short' + | 'story' + | 'reel' + | 'link'; + +export interface PlatformSpec { + id: PostizPlatformId; + label: string; + icon: string; + color: string; + /** Main content character limit (per segment for threads). */ + charLimit: number; + /** Optional title/subject limit — rendered as a separate input when set. */ + titleLimit?: number; + titleRequired?: boolean; + /** Supports a native multi-segment thread via Postiz `value[]`. */ + supportsThreads: boolean; + /** Media types the platform accepts (drives the inline editor hint). */ + mediaTypes: PostizMediaKind[]; + /** Maximum media items per post. 0 = media not allowed. */ + maxMedia: number; + /** Media is required (cannot post text-only). */ + mediaRequired?: boolean; + /** Default postType when dropped on the canvas. */ + defaultPostType: PostizPostType; + /** Extra postTypes selectable for this platform. */ + postTypes: PostizPostType[]; + /** Hashtag count limit (soft warning when exceeded). 0 = no hashtags. */ + hashtagLimit: number; + /** Human-readable note displayed under the content editor. */ + note: string; + /** Extra per-platform input fields rendered in the inline editor. */ + extraFields?: PlatformExtraField[]; +} + +export interface PlatformExtraField { + key: string; + label: string; + type: 'text' | 'select' | 'textarea' | 'checkbox'; + placeholder?: string; + options?: string[]; + required?: boolean; + maxLength?: number; +} + +export const PLATFORM_SPECS: Record = { + x: { + id: 'x', + label: 'X (Twitter)', + icon: '𝕏', + color: '#000000', + charLimit: 280, + supportsThreads: true, + mediaTypes: ['image', 'video', 'gif'], + maxMedia: 4, + defaultPostType: 'text', + postTypes: ['text', 'thread', 'image', 'video'], + hashtagLimit: 3, + note: '280 chars/tweet. Threads supported. Up to 4 media per tweet.', + extraFields: [ + { + key: 'who_can_reply_post', label: 'Who can reply', + type: 'select', options: ['everyone', 'following', 'mentionedUsers'], + }, + ], + }, + linkedin: { + id: 'linkedin', + label: 'LinkedIn', + icon: 'in', + color: '#0A66C2', + charLimit: 3000, + supportsThreads: false, + mediaTypes: ['image', 'video'], + maxMedia: 9, + defaultPostType: 'text', + postTypes: ['text', 'image', 'carousel', 'video'], + hashtagLimit: 5, + note: '3000 chars. Up to 9 images or 1 video. 3–5 hashtags recommended.', + extraFields: [ + { key: 'post_to_company', label: 'Post as company page', type: 'checkbox' }, + ], + }, + facebook: { + id: 'facebook', + label: 'Facebook', + icon: 'f', + color: '#1877F2', + charLimit: 63206, + supportsThreads: false, + mediaTypes: ['image', 'video'], + maxMedia: 10, + defaultPostType: 'text', + postTypes: ['text', 'image', 'video', 'link'], + hashtagLimit: 6, + note: 'Effectively unlimited text. First 250 chars show in preview.', + }, + instagram: { + id: 'instagram', + label: 'Instagram', + icon: '📷', + color: '#E4405F', + charLimit: 2200, + supportsThreads: false, + mediaTypes: ['image', 'video'], + maxMedia: 10, + mediaRequired: true, + defaultPostType: 'image', + postTypes: ['image', 'carousel', 'reel', 'story'], + hashtagLimit: 30, + note: '2200-char caption. Media required. Up to 10 items in a carousel.', + extraFields: [ + { + key: 'post_type', label: 'Post type', + type: 'select', options: ['post', 'reel', 'story'], + }, + ], + }, + threads: { + id: 'threads', + label: 'Threads', + icon: '@', + color: '#000000', + charLimit: 500, + supportsThreads: true, + mediaTypes: ['image', 'video'], + maxMedia: 10, + defaultPostType: 'text', + postTypes: ['text', 'thread', 'image'], + hashtagLimit: 1, + note: '500 chars/post. Threads supported. One hashtag per post.', + }, + bluesky: { + id: 'bluesky', + label: 'Bluesky', + icon: '🦋', + color: '#0085FF', + charLimit: 300, + supportsThreads: true, + mediaTypes: ['image', 'gif'], + maxMedia: 4, + defaultPostType: 'text', + postTypes: ['text', 'thread', 'image'], + hashtagLimit: 3, + note: '300 chars/post. Threads supported. Up to 4 images.', + }, + mastodon: { + id: 'mastodon', + label: 'Mastodon', + icon: '🐘', + color: '#6364FF', + charLimit: 500, + supportsThreads: true, + mediaTypes: ['image', 'video', 'gif'], + maxMedia: 4, + defaultPostType: 'text', + postTypes: ['text', 'thread', 'image'], + hashtagLimit: 10, + note: '500 chars/toot (instance-dependent). Threads via replies.', + extraFields: [ + { + key: 'visibility', label: 'Visibility', + type: 'select', options: ['public', 'unlisted', 'private', 'direct'], + }, + { key: 'spoiler_text', label: 'Content warning', type: 'text', placeholder: 'Optional CW' }, + ], + }, + youtube: { + id: 'youtube', + label: 'YouTube', + icon: '▶️', + color: '#FF0000', + charLimit: 5000, + titleLimit: 100, + titleRequired: true, + supportsThreads: false, + mediaTypes: ['video'], + maxMedia: 1, + mediaRequired: true, + defaultPostType: 'video', + postTypes: ['video', 'short'], + hashtagLimit: 15, + note: 'Title ≤100 chars. Description ≤5000. Video required.', + extraFields: [ + { + key: 'type', label: 'Visibility', + type: 'select', options: ['public', 'unlisted', 'private'], + }, + { + key: 'category', label: 'Category ID', + type: 'select', + options: ['1', '2', '10', '15', '17', '19', '20', '22', '23', '24', '25', '26', '27', '28'], + }, + ], + }, + tiktok: { + id: 'tiktok', + label: 'TikTok', + icon: '🎵', + color: '#000000', + charLimit: 2200, + titleLimit: 150, + supportsThreads: false, + mediaTypes: ['video'], + maxMedia: 1, + mediaRequired: true, + defaultPostType: 'video', + postTypes: ['video'], + hashtagLimit: 10, + note: 'Caption ≤2200 chars. Video required. Short-form vertical.', + extraFields: [ + { + key: 'privacy_level', label: 'Privacy', + type: 'select', options: ['PUBLIC_TO_EVERYONE', 'MUTUAL_FOLLOW_FRIENDS', 'SELF_ONLY'], + }, + { key: 'disable_duet', label: 'Disable duets', type: 'checkbox' }, + { key: 'disable_comment', label: 'Disable comments', type: 'checkbox' }, + ], + }, + reddit: { + id: 'reddit', + label: 'Reddit', + icon: '🔶', + color: '#FF4500', + charLimit: 40000, + titleLimit: 300, + titleRequired: true, + supportsThreads: false, + mediaTypes: ['image', 'video'], + maxMedia: 1, + defaultPostType: 'text', + postTypes: ['text', 'link', 'image', 'video'], + hashtagLimit: 0, + note: 'Title ≤300 chars. Hashtags unused. Subreddit required.', + extraFields: [ + { key: 'subreddit', label: 'Subreddit', type: 'text', required: true, placeholder: 'r/example' }, + { + key: 'flair', label: 'Flair (optional)', + type: 'text', placeholder: 'Flair text', + }, + ], + }, + pinterest: { + id: 'pinterest', + label: 'Pinterest', + icon: '📌', + color: '#E60023', + charLimit: 500, + titleLimit: 100, + supportsThreads: false, + mediaTypes: ['image', 'video'], + maxMedia: 1, + mediaRequired: true, + defaultPostType: 'image', + postTypes: ['image', 'video'], + hashtagLimit: 20, + note: 'Pin description ≤500. Title ≤100. Image or video required.', + extraFields: [ + { key: 'link', label: 'Destination URL', type: 'text', placeholder: 'https://' }, + { key: 'board', label: 'Board', type: 'text', placeholder: 'Board name' }, + ], + }, + discord: { + id: 'discord', + label: 'Discord', + icon: '🎮', + color: '#5865F2', + charLimit: 2000, + supportsThreads: false, + mediaTypes: ['image', 'video', 'gif'], + maxMedia: 10, + defaultPostType: 'text', + postTypes: ['text', 'image'], + hashtagLimit: 0, + note: '2000 chars per message. Posts into a configured channel.', + }, + slack: { + id: 'slack', + label: 'Slack', + icon: '#', + color: '#4A154B', + charLimit: 40000, + supportsThreads: false, + mediaTypes: ['image'], + maxMedia: 10, + defaultPostType: 'text', + postTypes: ['text'], + hashtagLimit: 0, + note: 'Long-form message into a configured channel.', + }, +}; + +export const ALL_PLATFORM_IDS: PostizPlatformId[] = [ + 'x', 'linkedin', 'facebook', 'instagram', 'threads', 'bluesky', + 'mastodon', 'youtube', 'tiktok', 'reddit', 'pinterest', 'discord', 'slack', +]; + +export function getPlatformSpec(platform: string): PlatformSpec | null { + return (PLATFORM_SPECS as Record)[platform] || null; +} + +export function charLimitFor(platform: string): number { + return getPlatformSpec(platform)?.charLimit ?? 2200; +} diff --git a/modules/rsocials/mod.ts b/modules/rsocials/mod.ts index 84d7093a..b1127d12 100644 --- a/modules/rsocials/mod.ts +++ b/modules/rsocials/mod.ts @@ -2798,8 +2798,8 @@ routes.get("/campaign-flow", (c) => { modules: getModuleInfoList(), theme: "dark", body: ``, - styles: ``, - scripts: ``, + styles: ``, + scripts: ``, })); }); diff --git a/modules/rsocials/schemas.ts b/modules/rsocials/schemas.ts index d1f71ed8..5961512d 100644 --- a/modules/rsocials/schemas.ts +++ b/modules/rsocials/schemas.ts @@ -66,6 +66,12 @@ export interface PostNodeData { scheduledAt: string; status: 'draft' | 'scheduled' | 'published'; hashtags: string[]; + /** Optional platform-required title (YouTube, TikTok, Reddit, Pinterest). */ + title?: string; + /** Optional media attachments — URLs to uploaded files. */ + mediaUrls?: string[]; + /** Per-platform Postiz settings overrides (merged into settings.__type). */ + platformSettings?: Record; postizPostId?: string; postizIntegrationId?: string; postizStatus?: 'queued' | 'published' | 'failed';