/** * 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; }