From 779f9ec605c667aa95f2f763f06c9b6e6188cb74 Mon Sep 17 00:00:00 2001
From: Grigory Frolov <2168057+gynsus@users.noreply.github.com>
Date: Mon, 17 Aug 2026 11:32:55 +0300
Subject: [PATCH 1/5] feat: YouTube Shorts description meta + first comment
after publish
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Two per-platform meta fields, wired through web/API/MCP validation like
Pinterest's board_id:
- description (YouTube, ≤5000): full video description with clickable
links; the publisher falls back to the post content when absent, so the
pre-meta behavior is unchanged.
- first_comment (YouTube + Instagram, ≤2200): posted by the account right
after a successful publish — the classic home for links Instagram
captions can't render and YouTube descriptions bury. Runs after
markAsPublished and never fails the post; failures are logged.
Instagram connects now request the manage-comments scope (existing
accounts keep publishing; the first comment logs a permission error until
reconnected). YouTube already held youtube.force-ssl.
---
.../Controllers/Auth/InstagramController.php | 2 +
.../Auth/InstagramFacebookController.php | 2 +
app/Jobs/PublishToSocialPlatform.php | 8 ++
app/Mcp/Tools/Post/CreatePostTool.php | 2 +-
app/Mcp/Tools/Post/UpdatePostTool.php | 2 +-
app/Services/Social/FirstCommentPoster.php | 102 +++++++++++++++
app/Services/Social/YouTubePublisher.php | 14 +-
app/Support/PostPlatformMetaRules.php | 14 ++
lang/ar/posts.php | 15 +++
lang/de/posts.php | 15 +++
lang/el/posts.php | 15 +++
lang/en/posts.php | 15 +++
lang/es/posts.php | 15 +++
lang/fr/posts.php | 15 +++
lang/it/posts.php | 15 +++
lang/ja/posts.php | 15 +++
lang/ko/posts.php | 15 +++
lang/nl/posts.php | 15 +++
lang/pl/posts.php | 15 +++
lang/pt-BR/posts.php | 15 +++
lang/ru/posts.php | 15 +++
lang/tr/posts.php | 15 +++
lang/uk/posts.php | 15 +++
lang/zh/posts.php | 15 +++
.../js/components/ChannelConfigurator.vue | 10 ++
.../posts/editor/InstagramSettings.vue | 25 ++++
.../posts/editor/YouTubeSettings.vue | 121 ++++++++++++++++++
tests/Feature/Api/PostApiPlatformMetaTest.php | 39 ++++++
.../Feature/Mcp/PostPlatformMetaToolTest.php | 24 ++++
.../Social/FirstCommentPosterTest.php | 109 ++++++++++++++++
.../Services/Social/YouTubePublisherTest.php | 20 +++
tests/Unit/PostPlatformMetaRulesTest.php | 10 +-
32 files changed, 739 insertions(+), 5 deletions(-)
create mode 100644 app/Services/Social/FirstCommentPoster.php
create mode 100644 resources/js/components/posts/editor/YouTubeSettings.vue
create mode 100644 tests/Feature/Services/Social/FirstCommentPosterTest.php
diff --git a/app/Http/Controllers/Auth/InstagramController.php b/app/Http/Controllers/Auth/InstagramController.php
index 5a6fdbea7..dfc00e71e 100644
--- a/app/Http/Controllers/Auth/InstagramController.php
+++ b/app/Http/Controllers/Auth/InstagramController.php
@@ -25,6 +25,8 @@ class InstagramController extends SocialController
'instagram_business_basic',
'instagram_business_content_publish',
'instagram_business_manage_insights',
+ // first comment after publish (FirstCommentPoster)
+ 'instagram_business_manage_comments',
];
public function connect(Request $request): Response
diff --git a/app/Http/Controllers/Auth/InstagramFacebookController.php b/app/Http/Controllers/Auth/InstagramFacebookController.php
index d20d32712..e2e2a834a 100644
--- a/app/Http/Controllers/Auth/InstagramFacebookController.php
+++ b/app/Http/Controllers/Auth/InstagramFacebookController.php
@@ -49,6 +49,8 @@ class InstagramFacebookController extends MetaController
'instagram_basic',
'instagram_content_publish',
'instagram_manage_insights',
+ // first comment after publish (FirstCommentPoster)
+ 'instagram_manage_comments',
];
public function connect(Request $request): Response
diff --git a/app/Jobs/PublishToSocialPlatform.php b/app/Jobs/PublishToSocialPlatform.php
index 106a9f92e..a5f08f3df 100644
--- a/app/Jobs/PublishToSocialPlatform.php
+++ b/app/Jobs/PublishToSocialPlatform.php
@@ -30,6 +30,7 @@
use App\Services\Social\Telegram\TelegramPublisher;
use App\Services\Social\ThreadsPublisher;
use App\Services\Social\TikTokPublisher;
+use App\Services\Social\FirstCommentPoster;
use App\Services\Social\XPublisher;
use App\Services\Social\YouTubePublisher;
use App\Support\Social\TikTokPhotoDerivativeCleaner;
@@ -125,6 +126,13 @@ public function handle(): void
$publisher = $this->getPublisher();
$result = $publisher->publish($this->postPlatform);
$this->postPlatform->markAsPublished(data_get($result, 'id'), data_get($result, 'url'));
+
+ // The post is live; a failed first comment must never fail it —
+ // FirstCommentPoster logs and swallows its own errors.
+ if (($externalId = (string) data_get($result, 'id')) !== '') {
+ app(FirstCommentPoster::class)->post($this->postPlatform, $externalId);
+ }
+
break;
} catch (PlatformUnavailableException $e) {
$this->rescheduleForRetry($e);
diff --git a/app/Mcp/Tools/Post/CreatePostTool.php b/app/Mcp/Tools/Post/CreatePostTool.php
index a90e40305..372c6f21d 100644
--- a/app/Mcp/Tools/Post/CreatePostTool.php
+++ b/app/Mcp/Tools/Post/CreatePostTool.php
@@ -79,7 +79,7 @@ public function schema(JsonSchema $schema): array
->items($schema->object(fn ($p) => [
'social_account_id' => $p->string()->required()->description('UUID of the connected social account.'),
'content_type' => $p->string()->required()->description('Format for this platform (e.g. linkedin_post, x_post, instagram_feed).'),
- 'meta' => $p->object()->description('Per-platform metadata. Instagram/Facebook: aspect_ratio (1:1|4:5|16:9|original). TikTok: privacy_level (required to publish) + flags (allow_comments, allow_duet, allow_stitch, disclose, brand_content_toggle, brand_organic_toggle, is_aigc, auto_add_music). Pinterest: board_id (required to publish — call ListPinterestBoardsTool first), title (≤100), link (destination URL). Pin description comes from the post content. Discord: channel_id (required to publish — call ListDiscordChannelsTool first), mentions ([{token,label}]), embeds ([{title,description,url,image,color}]).'),
+ 'meta' => $p->object()->description('Per-platform metadata. Instagram/Facebook: aspect_ratio (1:1|4:5|16:9|original). TikTok: privacy_level (required to publish) + flags (allow_comments, allow_duet, allow_stitch, disclose, brand_content_toggle, brand_organic_toggle, is_aigc, auto_add_music). Pinterest: board_id (required to publish — call ListPinterestBoardsTool first), title (≤100), link (destination URL). Pin description comes from the post content. Discord: channel_id (required to publish — call ListDiscordChannelsTool first), mentions ([{token,label}]), embeds ([{title,description,url,image,color}]). YouTube: description (≤5000, clickable links; falls back to post content). YouTube/Instagram: first_comment (≤2200, posted by the account right after publish).'),
]))
->description('Platforms to publish on. Accounts not listed remain available but disabled.'),
];
diff --git a/app/Mcp/Tools/Post/UpdatePostTool.php b/app/Mcp/Tools/Post/UpdatePostTool.php
index 3f0ae0e62..aa63e02dd 100644
--- a/app/Mcp/Tools/Post/UpdatePostTool.php
+++ b/app/Mcp/Tools/Post/UpdatePostTool.php
@@ -119,7 +119,7 @@ public function schema(JsonSchema $schema): array
->items($schema->object(fn ($p) => [
'id' => $p->string()->required()->description('UUID of the post_platform row (from get-post-tool / list-posts-tool).'),
'content_type' => $p->string()->description('New content_type for this platform.'),
- 'meta' => $p->object()->description('Per-platform metadata override. Instagram/Facebook: aspect_ratio. TikTok: privacy_level (required to publish) + flags. Pinterest: board_id (required to publish — call ListPinterestBoardsTool first), title (≤100), link (destination URL). Pin description comes from the post content. Discord: channel_id (required to publish — call ListDiscordChannelsTool first), mentions, embeds. Merged with existing meta.'),
+ 'meta' => $p->object()->description('Per-platform metadata override. Instagram/Facebook: aspect_ratio. TikTok: privacy_level (required to publish) + flags. Pinterest: board_id (required to publish — call ListPinterestBoardsTool first), title (≤100), link (destination URL). Pin description comes from the post content. Discord: channel_id (required to publish — call ListDiscordChannelsTool first), mentions, embeds. YouTube: description (≤5000, clickable links; falls back to post content). YouTube/Instagram: first_comment (≤2200, posted by the account right after publish). Merged with existing meta.'),
]))
->description('Platforms to enable for publishing. Any platform NOT listed will be disabled. Pass an empty array to disable all.'),
];
diff --git a/app/Services/Social/FirstCommentPoster.php b/app/Services/Social/FirstCommentPoster.php
new file mode 100644
index 000000000..b0a7b32a4
--- /dev/null
+++ b/app/Services/Social/FirstCommentPoster.php
@@ -0,0 +1,102 @@
+meta, 'first_comment'));
+
+ if ($comment === '') {
+ return;
+ }
+
+ try {
+ match ($postPlatform->platform) {
+ Platform::YouTube => $this->postYouTubeComment($postPlatform, $externalId, $comment),
+ Platform::Instagram, Platform::InstagramFacebook => $this->postInstagramComment($postPlatform, $externalId, $comment),
+ default => Log::warning('First comment is not supported for this platform', [
+ 'platform' => $postPlatform->platform->value,
+ 'post_platform_id' => $postPlatform->id,
+ ]),
+ };
+ } catch (\Throwable $e) {
+ Log::warning('First comment failed (post itself is published)', [
+ 'platform' => $postPlatform->platform->value,
+ 'post_platform_id' => $postPlatform->id,
+ 'error' => $e->getMessage(),
+ ]);
+ }
+ }
+
+ /**
+ * commentThreads.insert needs the youtube.force-ssl scope — requested at
+ * connect time since the platform was added, so every account has it.
+ */
+ private function postYouTubeComment(PostPlatform $postPlatform, string $videoId, string $comment): void
+ {
+ $account = $postPlatform->socialAccount;
+ $api = rtrim((string) config('trypost.platforms.youtube.data_api'), '/');
+
+ $response = $this->socialHttp()
+ ->withToken($account->access_token)
+ ->post("{$api}/commentThreads?part=snippet", [
+ 'snippet' => [
+ 'videoId' => $videoId,
+ 'topLevelComment' => [
+ 'snippet' => ['textOriginal' => $comment],
+ ],
+ ],
+ ]);
+
+ if ($response->failed()) {
+ Log::warning('YouTube first comment failed', [
+ 'post_platform_id' => $postPlatform->id,
+ 'status' => $response->status(),
+ 'body' => $this->redactResponseBody($response->body()),
+ ]);
+ }
+ }
+
+ /**
+ * Needs instagram_business_manage_comments (direct) / instagram_manage_comments
+ * (via Facebook Page) — accounts connected before the scope was requested
+ * get a 4xx here, which is logged and ignored; reconnecting fixes it.
+ */
+ private function postInstagramComment(PostPlatform $postPlatform, string $mediaId, string $comment): void
+ {
+ $account = $postPlatform->socialAccount;
+ $baseUrl = $account->platform->instagramGraphBaseUrl();
+
+ $response = $this->socialHttp()->post("{$baseUrl}/{$mediaId}/comments", [
+ 'message' => $comment,
+ 'access_token' => $account->access_token,
+ ]);
+
+ if ($response->failed()) {
+ Log::warning('Instagram first comment failed', [
+ 'post_platform_id' => $postPlatform->id,
+ 'status' => $response->status(),
+ 'body' => $this->redactResponseBody($response->body()),
+ ]);
+ }
+ }
+}
diff --git a/app/Services/Social/YouTubePublisher.php b/app/Services/Social/YouTubePublisher.php
index f3f6e4438..c1868c6fb 100644
--- a/app/Services/Social/YouTubePublisher.php
+++ b/app/Services/Social/YouTubePublisher.php
@@ -93,7 +93,7 @@ private function publishShort(PostPlatform $postPlatform, $media, SocialAccount
}
$title = $this->buildTitle($content);
- $description = $content;
+ $description = $this->resolveDescription($postPlatform, $content);
$tempFile = tempnam(sys_get_temp_dir(), 'yt_upload_');
$handle = null;
@@ -221,6 +221,18 @@ private function buildTitle(string $content): string
return $title.$shortsTag;
}
+ /**
+ * The video description: the per-platform `meta.description` when the user
+ * provided one (YouTube allows 5000 chars and renders links clickable),
+ * otherwise the post content — the pre-meta behavior.
+ */
+ private function resolveDescription(PostPlatform $postPlatform, string $content): string
+ {
+ $description = trim((string) data_get($postPlatform->meta, 'description'));
+
+ return $description !== '' ? $description : $content;
+ }
+
private function handleGoogleError(Exception $e): never
{
throw YouTubePublishException::fromGoogleException($e);
diff --git a/app/Support/PostPlatformMetaRules.php b/app/Support/PostPlatformMetaRules.php
index 15cc71046..7cd4b32ec 100644
--- a/app/Support/PostPlatformMetaRules.php
+++ b/app/Support/PostPlatformMetaRules.php
@@ -59,6 +59,16 @@ public static function rules(): array
'platforms.*.meta.brand_content_toggle' => ['sometimes', 'boolean'],
'platforms.*.meta.brand_organic_toggle' => ['sometimes', 'boolean'],
+ // YouTube — full video description (YouTube allows 5000 chars with
+ // clickable links; without it the publisher falls back to the post
+ // content, which is capped at the 100-char Shorts title).
+ 'platforms.*.meta.description' => ['sometimes', 'nullable', 'string', 'max:5000'],
+
+ // First comment posted right after a successful publish (YouTube and
+ // Instagram). Capped at Instagram's comment limit, the stricter of
+ // the two.
+ 'platforms.*.meta.first_comment' => ['sometimes', 'nullable', 'string', 'max:2200'],
+
// Pinterest
'platforms.*.meta.board_id' => ['sometimes', 'nullable', 'string'],
'platforms.*.meta.title' => ['sometimes', 'nullable', 'string', 'max:100'],
@@ -90,6 +100,8 @@ public static function messages(): array
'platforms.*.meta.link.url' => __('posts.form.pinterest.link_invalid'),
'platforms.*.meta.link.max' => __('posts.form.pinterest.link_max'),
'platforms.*.meta.title.max' => __('posts.form.pinterest.title_max'),
+ 'platforms.*.meta.description.max' => __('posts.form.youtube.description_max'),
+ 'platforms.*.meta.first_comment.max' => __('posts.form.first_comment.max'),
];
}
@@ -103,6 +115,8 @@ public static function attributes(): array
return [
'platforms.*.meta.title' => __('posts.form.pinterest.title'),
'platforms.*.meta.link' => __('posts.form.pinterest.link'),
+ 'platforms.*.meta.description' => __('posts.form.youtube.description'),
+ 'platforms.*.meta.first_comment' => __('posts.form.first_comment.label'),
];
}
diff --git a/lang/ar/posts.php b/lang/ar/posts.php
index 77c703250..209e29373 100644
--- a/lang/ar/posts.php
+++ b/lang/ar/posts.php
@@ -142,6 +142,21 @@
'document_title' => 'عنوان المستند',
'document_title_placeholder' => 'يظهر على منشور مستند PDF الخاص بك',
],
+ 'youtube' => [
+ 'settings' => 'إعدادات YouTube',
+ 'posting_to' => 'النشر في',
+ 'description' => 'الوصف',
+ 'description_placeholder' => 'الوصف الكامل للفيديو: معلومات وروابط ووسوم…',
+ 'description_hint' => 'حتى 5000 حرف، والروابط قابلة للنقر. إذا تُرك فارغًا يُستخدم نص المنشور.',
+ 'description_max' => 'لا يمكن أن يتجاوز الوصف 5000 حرف.',
+ ],
+ 'first_comment' => [
+ 'label' => 'التعليق الأول',
+ 'placeholder' => 'رابط وإضافات للتعليق الأول…',
+ 'hint' => 'يُنشر باسم قناتك فور نشر الفيديو.',
+ 'hint_instagram' => 'يُنشر فور النشر — المكان الكلاسيكي للروابط التي لا تكون قابلة للنقر في وصف Instagram. أعد ربط الحساب مرة واحدة لمنح إذن التعليقات.',
+ 'max' => 'لا يمكن أن يتجاوز التعليق الأول 2200 حرف.',
+ ],
'pinterest' => [
'settings' => 'إعدادات Pinterest',
'posting_to' => 'النشر إلى',
diff --git a/lang/de/posts.php b/lang/de/posts.php
index a082a28d3..5f7117e78 100644
--- a/lang/de/posts.php
+++ b/lang/de/posts.php
@@ -144,6 +144,21 @@
'document_title' => 'Dokumenttitel',
'document_title_placeholder' => 'Wird bei deinem PDF-Dokument-Beitrag angezeigt',
],
+ 'youtube' => [
+ 'settings' => 'YouTube-Einstellungen',
+ 'posting_to' => 'Veröffentlichen auf',
+ 'description' => 'Beschreibung',
+ 'description_placeholder' => 'Vollständige Videobeschreibung: Fakten, Links, Hashtags…',
+ 'description_hint' => 'Bis zu 5000 Zeichen, Links sind klickbar. Wenn leer, wird der Beitragstext verwendet.',
+ 'description_max' => 'Die Beschreibung darf 5000 Zeichen nicht überschreiten.',
+ ],
+ 'first_comment' => [
+ 'label' => 'Erster Kommentar',
+ 'placeholder' => 'Link und Extras für den ersten Kommentar…',
+ 'hint' => 'Wird direkt nach der Veröffentlichung vom Kanal gepostet.',
+ 'hint_instagram' => 'Wird direkt nach dem Beitrag gepostet — der klassische Ort für Links, die in Instagram-Beschreibungen nicht klickbar sind. Konto einmal neu verbinden, um die Kommentar-Berechtigung zu erteilen.',
+ 'max' => 'Der erste Kommentar darf 2200 Zeichen nicht überschreiten.',
+ ],
'pinterest' => [
'settings' => 'Pinterest-Einstellungen',
'posting_to' => 'Veröffentlichen auf',
diff --git a/lang/el/posts.php b/lang/el/posts.php
index a4592be22..bf3618356 100644
--- a/lang/el/posts.php
+++ b/lang/el/posts.php
@@ -142,6 +142,21 @@
'document_title' => 'Τίτλος εγγράφου',
'document_title_placeholder' => 'Εμφανίζεται στη δημοσίευση εγγράφου PDF σας',
],
+ 'youtube' => [
+ 'settings' => 'Ρυθμίσεις YouTube',
+ 'posting_to' => 'Δημοσίευση σε',
+ 'description' => 'Περιγραφή',
+ 'description_placeholder' => 'Πλήρης περιγραφή βίντεο: πληροφορίες, σύνδεσμοι, hashtags…',
+ 'description_hint' => 'Έως 5000 χαρακτήρες, οι σύνδεσμοι είναι κλικαρίσιμοι. Αν είναι κενό, χρησιμοποιείται το κείμενο της ανάρτησης.',
+ 'description_max' => 'Η περιγραφή δεν μπορεί να υπερβαίνει τους 5000 χαρακτήρες.',
+ ],
+ 'first_comment' => [
+ 'label' => 'Πρώτο σχόλιο',
+ 'placeholder' => 'Σύνδεσμος και πρόσθετα για το πρώτο σχόλιο…',
+ 'hint' => 'Δημοσιεύεται από το κανάλι σας αμέσως μετά τη δημοσίευση του βίντεο.',
+ 'hint_instagram' => 'Δημοσιεύεται αμέσως μετά την ανάρτηση — το κλασικό σημείο για συνδέσμους που οι λεζάντες του Instagram δεν κάνουν κλικαρίσιμους. Επανασυνδέστε τον λογαριασμό μία φορά για να δώσετε το δικαίωμα σχολίων.',
+ 'max' => 'Το πρώτο σχόλιο δεν μπορεί να υπερβαίνει τους 2200 χαρακτήρες.',
+ ],
'pinterest' => [
'settings' => 'Ρυθμίσεις Pinterest',
'posting_to' => 'Δημοσίευση σε',
diff --git a/lang/en/posts.php b/lang/en/posts.php
index ffdf6cecf..539b77a90 100644
--- a/lang/en/posts.php
+++ b/lang/en/posts.php
@@ -142,6 +142,21 @@
'document_title' => 'Document title',
'document_title_placeholder' => 'Shown on your PDF document post',
],
+ 'youtube' => [
+ 'settings' => 'YouTube settings',
+ 'posting_to' => 'Posting to',
+ 'description' => 'Description',
+ 'description_placeholder' => 'Full video description: facts, links, hashtags…',
+ 'description_hint' => 'Up to 5000 characters, links are clickable. When empty, the post text is used.',
+ 'description_max' => 'Description may not exceed 5000 characters.',
+ ],
+ 'first_comment' => [
+ 'label' => 'First comment',
+ 'placeholder' => 'Link and extras for the first comment…',
+ 'hint' => 'Posted by your channel right after the video is published.',
+ 'hint_instagram' => 'Posted right after publishing — the classic spot for links Instagram captions can\'t make clickable. Requires reconnecting the account once to grant the comments permission.',
+ 'max' => 'First comment may not exceed 2200 characters.',
+ ],
'pinterest' => [
'settings' => 'Pinterest Settings',
'posting_to' => 'Posting to',
diff --git a/lang/es/posts.php b/lang/es/posts.php
index 566e78ecc..71a230820 100644
--- a/lang/es/posts.php
+++ b/lang/es/posts.php
@@ -142,6 +142,21 @@
'document_title' => 'Título del documento',
'document_title_placeholder' => 'Se muestra en tu publicación de documento PDF',
],
+ 'youtube' => [
+ 'settings' => 'Ajustes de YouTube',
+ 'posting_to' => 'Publicando en',
+ 'description' => 'Descripción',
+ 'description_placeholder' => 'Descripción completa del vídeo: datos, enlaces, hashtags…',
+ 'description_hint' => 'Hasta 5000 caracteres, los enlaces son clicables. Si está vacío, se usa el texto de la publicación.',
+ 'description_max' => 'La descripción no puede superar los 5000 caracteres.',
+ ],
+ 'first_comment' => [
+ 'label' => 'Primer comentario',
+ 'placeholder' => 'Enlace y extras para el primer comentario…',
+ 'hint' => 'Se publica desde tu canal justo después de publicar el vídeo.',
+ 'hint_instagram' => 'Se publica justo después de la publicación: el lugar clásico para enlaces que Instagram no hace clicables en la descripción. Reconecta la cuenta una vez para conceder el permiso de comentarios.',
+ 'max' => 'El primer comentario no puede superar los 2200 caracteres.',
+ ],
'pinterest' => [
'settings' => 'Configuración de Pinterest',
'posting_to' => 'Publicando en',
diff --git a/lang/fr/posts.php b/lang/fr/posts.php
index b246551f2..6eeeb3e41 100644
--- a/lang/fr/posts.php
+++ b/lang/fr/posts.php
@@ -142,6 +142,21 @@
'document_title' => 'Titre du document',
'document_title_placeholder' => 'Affiché sur votre publication de document PDF',
],
+ 'youtube' => [
+ 'settings' => 'Paramètres YouTube',
+ 'posting_to' => 'Publication sur',
+ 'description' => 'Description',
+ 'description_placeholder' => 'Description complète de la vidéo : infos, liens, hashtags…',
+ 'description_hint' => 'Jusqu\'à 5000 caractères, les liens sont cliquables. Si vide, le texte de la publication est utilisé.',
+ 'description_max' => 'La description ne peut pas dépasser 5000 caractères.',
+ ],
+ 'first_comment' => [
+ 'label' => 'Premier commentaire',
+ 'placeholder' => 'Lien et compléments pour le premier commentaire…',
+ 'hint' => 'Publié par votre chaîne juste après la mise en ligne de la vidéo.',
+ 'hint_instagram' => 'Publié juste après la publication — l\'endroit classique pour les liens que les légendes Instagram ne rendent pas cliquables. Reconnectez le compte une fois pour accorder l\'autorisation de commenter.',
+ 'max' => 'Le premier commentaire ne peut pas dépasser 2200 caractères.',
+ ],
'pinterest' => [
'settings' => 'Paramètres Pinterest',
'posting_to' => 'Publier sur',
diff --git a/lang/it/posts.php b/lang/it/posts.php
index 8bb0645e9..e8e40d8b9 100644
--- a/lang/it/posts.php
+++ b/lang/it/posts.php
@@ -142,6 +142,21 @@
'document_title' => 'Titolo del documento',
'document_title_placeholder' => 'Mostrato sul tuo post con documento PDF',
],
+ 'youtube' => [
+ 'settings' => 'Impostazioni YouTube',
+ 'posting_to' => 'Pubblicazione su',
+ 'description' => 'Descrizione',
+ 'description_placeholder' => 'Descrizione completa del video: dettagli, link, hashtag…',
+ 'description_hint' => 'Fino a 5000 caratteri, i link sono cliccabili. Se vuoto, viene usato il testo del post.',
+ 'description_max' => 'La descrizione non può superare i 5000 caratteri.',
+ ],
+ 'first_comment' => [
+ 'label' => 'Primo commento',
+ 'placeholder' => 'Link ed extra per il primo commento…',
+ 'hint' => 'Pubblicato dal tuo canale subito dopo la pubblicazione del video.',
+ 'hint_instagram' => 'Pubblicato subito dopo il post: il posto classico per i link che le didascalie di Instagram non rendono cliccabili. Ricollega l\'account una volta per concedere il permesso sui commenti.',
+ 'max' => 'Il primo commento non può superare i 2200 caratteri.',
+ ],
'pinterest' => [
'settings' => 'Impostazioni Pinterest',
'posting_to' => 'Pubblicazione su',
diff --git a/lang/ja/posts.php b/lang/ja/posts.php
index f0092fb3f..27dfb3a98 100644
--- a/lang/ja/posts.php
+++ b/lang/ja/posts.php
@@ -142,6 +142,21 @@
'document_title' => 'ドキュメントタイトル',
'document_title_placeholder' => 'PDF ドキュメント投稿に表示されます',
],
+ 'youtube' => [
+ 'settings' => 'YouTube設定',
+ 'posting_to' => '投稿先',
+ 'description' => '説明',
+ 'description_placeholder' => '動画の詳細説明:情報、リンク、ハッシュタグなど…',
+ 'description_hint' => '最大5000文字、リンクはクリック可能です。空の場合は投稿テキストが使われます。',
+ 'description_max' => '説明は5000文字を超えられません。',
+ ],
+ 'first_comment' => [
+ 'label' => '最初のコメント',
+ 'placeholder' => '最初のコメントに入れるリンクや補足…',
+ 'hint' => '動画公開直後にチャンネルから投稿されます。',
+ 'hint_instagram' => '投稿直後にコメントされます。Instagramのキャプションでクリックできないリンクの定番の置き場所です。コメント権限を付与するには一度アカウントを再連携してください。',
+ 'max' => '最初のコメントは2200文字を超えられません。',
+ ],
'pinterest' => [
'settings' => 'Pinterest 設定',
'posting_to' => '投稿先',
diff --git a/lang/ko/posts.php b/lang/ko/posts.php
index 2ec32c9a4..0af8502ee 100644
--- a/lang/ko/posts.php
+++ b/lang/ko/posts.php
@@ -142,6 +142,21 @@
'document_title' => '문서 제목',
'document_title_placeholder' => 'PDF 문서 게시물에 표시됩니다',
],
+ 'youtube' => [
+ 'settings' => 'YouTube 설정',
+ 'posting_to' => '게시 위치',
+ 'description' => '설명',
+ 'description_placeholder' => '동영상 전체 설명: 정보, 링크, 해시태그…',
+ 'description_hint' => '최대 5000자, 링크는 클릭 가능합니다. 비워두면 게시물 텍스트가 사용됩니다.',
+ 'description_max' => '설명은 5000자를 초과할 수 없습니다.',
+ ],
+ 'first_comment' => [
+ 'label' => '첫 댓글',
+ 'placeholder' => '첫 댓글에 넣을 링크와 추가 내용…',
+ 'hint' => '동영상 게시 직후 채널 명의로 작성됩니다.',
+ 'hint_instagram' => '게시 직후 작성됩니다 — Instagram 캡션에서 클릭되지 않는 링크를 넣는 고전적인 방법입니다. 댓글 권한을 부여하려면 계정을 한 번 다시 연결하세요.',
+ 'max' => '첫 댓글은 2200자를 초과할 수 없습니다.',
+ ],
'pinterest' => [
'settings' => 'Pinterest 설정',
'posting_to' => '게시 대상',
diff --git a/lang/nl/posts.php b/lang/nl/posts.php
index 2e06df707..5316d63e8 100644
--- a/lang/nl/posts.php
+++ b/lang/nl/posts.php
@@ -142,6 +142,21 @@
'document_title' => 'Documenttitel',
'document_title_placeholder' => 'Getoond op je PDF-documentpost',
],
+ 'youtube' => [
+ 'settings' => 'YouTube-instellingen',
+ 'posting_to' => 'Publiceren op',
+ 'description' => 'Beschrijving',
+ 'description_placeholder' => 'Volledige videobeschrijving: feiten, links, hashtags…',
+ 'description_hint' => 'Tot 5000 tekens, links zijn klikbaar. Indien leeg wordt de berichttekst gebruikt.',
+ 'description_max' => 'De beschrijving mag niet langer zijn dan 5000 tekens.',
+ ],
+ 'first_comment' => [
+ 'label' => 'Eerste reactie',
+ 'placeholder' => 'Link en extra\'s voor de eerste reactie…',
+ 'hint' => 'Direct na publicatie door je kanaal geplaatst.',
+ 'hint_instagram' => 'Direct na het bericht geplaatst — dé plek voor links die Instagram-bijschriften niet klikbaar maken. Verbind het account één keer opnieuw om de reactietoestemming te geven.',
+ 'max' => 'De eerste reactie mag niet langer zijn dan 2200 tekens.',
+ ],
'pinterest' => [
'settings' => 'Pinterest-instellingen',
'posting_to' => 'Posten naar',
diff --git a/lang/pl/posts.php b/lang/pl/posts.php
index d47721c06..d4b89a039 100644
--- a/lang/pl/posts.php
+++ b/lang/pl/posts.php
@@ -142,6 +142,21 @@
'document_title' => 'Tytuł dokumentu',
'document_title_placeholder' => 'Wyświetlany w Twoim poście z dokumentem PDF',
],
+ 'youtube' => [
+ 'settings' => 'Ustawienia YouTube',
+ 'posting_to' => 'Publikowanie na',
+ 'description' => 'Opis',
+ 'description_placeholder' => 'Pełny opis filmu: informacje, linki, hasztagi…',
+ 'description_hint' => 'Do 5000 znaków, linki są klikalne. Gdy pusto, używany jest tekst posta.',
+ 'description_max' => 'Opis nie może przekraczać 5000 znaków.',
+ ],
+ 'first_comment' => [
+ 'label' => 'Pierwszy komentarz',
+ 'placeholder' => 'Link i dodatki do pierwszego komentarza…',
+ 'hint' => 'Publikowany przez Twój kanał zaraz po opublikowaniu filmu.',
+ 'hint_instagram' => 'Publikowany zaraz po poście — klasyczne miejsce na linki, których opisy na Instagramie nie czynią klikalnymi. Połącz konto ponownie, aby nadać uprawnienie do komentarzy.',
+ 'max' => 'Pierwszy komentarz nie może przekraczać 2200 znaków.',
+ ],
'pinterest' => [
'settings' => 'Ustawienia Pinterest',
'posting_to' => 'Publikowanie na',
diff --git a/lang/pt-BR/posts.php b/lang/pt-BR/posts.php
index 3e4c0a0ec..15ec6852b 100644
--- a/lang/pt-BR/posts.php
+++ b/lang/pt-BR/posts.php
@@ -142,6 +142,21 @@
'document_title' => 'Título do documento',
'document_title_placeholder' => 'Aparece no seu post de documento PDF',
],
+ 'youtube' => [
+ 'settings' => 'Configurações do YouTube',
+ 'posting_to' => 'Publicando em',
+ 'description' => 'Descrição',
+ 'description_placeholder' => 'Descrição completa do vídeo: informações, links, hashtags…',
+ 'description_hint' => 'Até 5000 caracteres, os links são clicáveis. Se vazio, o texto da publicação é usado.',
+ 'description_max' => 'A descrição não pode exceder 5000 caracteres.',
+ ],
+ 'first_comment' => [
+ 'label' => 'Primeiro comentário',
+ 'placeholder' => 'Link e extras para o primeiro comentário…',
+ 'hint' => 'Publicado pelo seu canal logo após o vídeo ir ao ar.',
+ 'hint_instagram' => 'Publicado logo após a postagem — o lugar clássico para links que as legendas do Instagram não tornam clicáveis. Reconecte a conta uma vez para conceder a permissão de comentários.',
+ 'max' => 'O primeiro comentário não pode exceder 2200 caracteres.',
+ ],
'pinterest' => [
'settings' => 'Configurações do Pinterest',
'posting_to' => 'Publicando em',
diff --git a/lang/ru/posts.php b/lang/ru/posts.php
index 19439375f..abe0d4be7 100644
--- a/lang/ru/posts.php
+++ b/lang/ru/posts.php
@@ -142,6 +142,21 @@
'document_title' => 'Название документа',
'document_title_placeholder' => 'Отображается в вашем посте с PDF-документом',
],
+ 'youtube' => [
+ 'settings' => 'Настройки YouTube',
+ 'posting_to' => 'Публикация в',
+ 'description' => 'Описание',
+ 'description_placeholder' => 'Полное описание ролика: факты, ссылки, хэштеги…',
+ 'description_hint' => 'До 5000 знаков, ссылки кликабельны. Если пусто — используется текст поста.',
+ 'description_max' => 'Описание не может быть длиннее 5000 знаков.',
+ ],
+ 'first_comment' => [
+ 'label' => 'Первый комментарий',
+ 'placeholder' => 'Ссылка и дополнения для первого комментария…',
+ 'hint' => 'Публикуется от имени канала сразу после выхода ролика.',
+ 'hint_instagram' => 'Публикуется сразу после поста — классическое место для ссылок, которые в описании Instagram некликабельны. Требуется один раз переподключить аккаунт, чтобы выдать право на комментарии.',
+ 'max' => 'Первый комментарий не может быть длиннее 2200 знаков.',
+ ],
'pinterest' => [
'settings' => 'Настройки Pinterest',
'posting_to' => 'Публикация в',
diff --git a/lang/tr/posts.php b/lang/tr/posts.php
index f19d6c8a1..a7d8387ab 100644
--- a/lang/tr/posts.php
+++ b/lang/tr/posts.php
@@ -144,6 +144,21 @@
'document_title' => 'Belge başlığı',
'document_title_placeholder' => 'PDF belge gönderinizde gösterilir',
],
+ 'youtube' => [
+ 'settings' => 'YouTube ayarları',
+ 'posting_to' => 'Şurada yayınlanıyor',
+ 'description' => 'Açıklama',
+ 'description_placeholder' => 'Videonun tam açıklaması: bilgiler, bağlantılar, etiketler…',
+ 'description_hint' => 'En fazla 5000 karakter, bağlantılar tıklanabilir. Boşsa gönderi metni kullanılır.',
+ 'description_max' => 'Açıklama 5000 karakteri aşamaz.',
+ ],
+ 'first_comment' => [
+ 'label' => 'İlk yorum',
+ 'placeholder' => 'İlk yorum için bağlantı ve ekler…',
+ 'hint' => 'Video yayınlandıktan hemen sonra kanalınız tarafından gönderilir.',
+ 'hint_instagram' => 'Gönderiden hemen sonra paylaşılır — Instagram açıklamalarında tıklanamayan bağlantılar için klasik yer. Yorum iznini vermek için hesabı bir kez yeniden bağlayın.',
+ 'max' => 'İlk yorum 2200 karakteri aşamaz.',
+ ],
'pinterest' => [
'settings' => 'Pinterest Ayarları',
'posting_to' => 'Şuraya paylaşılıyor',
diff --git a/lang/uk/posts.php b/lang/uk/posts.php
index bcd9b2505..363233c8e 100644
--- a/lang/uk/posts.php
+++ b/lang/uk/posts.php
@@ -142,6 +142,21 @@
'document_title' => 'Назва документа',
'document_title_placeholder' => 'Відображається у вашому пості з PDF-документом',
],
+ 'youtube' => [
+ 'settings' => 'Налаштування YouTube',
+ 'posting_to' => 'Публікація в',
+ 'description' => 'Опис',
+ 'description_placeholder' => 'Повний опис відео: факти, посилання, хештеги…',
+ 'description_hint' => 'До 5000 знаків, посилання клікабельні. Якщо порожньо — використовується текст поста.',
+ 'description_max' => 'Опис не може перевищувати 5000 знаків.',
+ ],
+ 'first_comment' => [
+ 'label' => 'Перший коментар',
+ 'placeholder' => 'Посилання та доповнення для першого коментаря…',
+ 'hint' => 'Публікується від імені каналу одразу після виходу відео.',
+ 'hint_instagram' => 'Публікується одразу після поста — класичне місце для посилань, які в описі Instagram неклікабельні. Потрібно один раз перепідключити акаунт, щоб надати право на коментарі.',
+ 'max' => 'Перший коментар не може перевищувати 2200 знаків.',
+ ],
'pinterest' => [
'settings' => 'Налаштування Pinterest',
'posting_to' => 'Публікація в',
diff --git a/lang/zh/posts.php b/lang/zh/posts.php
index af5dc2750..e5383d7f6 100644
--- a/lang/zh/posts.php
+++ b/lang/zh/posts.php
@@ -142,6 +142,21 @@
'document_title' => '文档标题',
'document_title_placeholder' => '显示在你的 PDF 文档帖子上',
],
+ 'youtube' => [
+ 'settings' => 'YouTube 设置',
+ 'posting_to' => '发布到',
+ 'description' => '描述',
+ 'description_placeholder' => '视频完整描述:信息、链接、话题标签…',
+ 'description_hint' => '最多 5000 个字符,链接可点击。留空则使用帖子文本。',
+ 'description_max' => '描述不能超过 5000 个字符。',
+ ],
+ 'first_comment' => [
+ 'label' => '首条评论',
+ 'placeholder' => '首条评论中的链接和补充内容…',
+ 'hint' => '视频发布后立即以频道名义发表。',
+ 'hint_instagram' => '发布后立即评论——这是放置 Instagram 描述中无法点击的链接的经典位置。需重新连接一次账号以授予评论权限。',
+ 'max' => '首条评论不能超过 2200 个字符。',
+ ],
'pinterest' => [
'settings' => 'Pinterest 设置',
'posting_to' => '发布到',
diff --git a/resources/js/components/ChannelConfigurator.vue b/resources/js/components/ChannelConfigurator.vue
index ce3d00e87..95c69f1df 100644
--- a/resources/js/components/ChannelConfigurator.vue
+++ b/resources/js/components/ChannelConfigurator.vue
@@ -8,6 +8,7 @@ import InstagramSettings from '@/components/posts/editor/InstagramSettings.vue';
import LinkedInSettings from '@/components/posts/editor/LinkedInSettings.vue';
import PinterestSettings from '@/components/posts/editor/PinterestSettings.vue';
import TikTokSettings from '@/components/posts/editor/TikTokSettings.vue';
+import YouTubeSettings from '@/components/posts/editor/YouTubeSettings.vue';
import { Avatar } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
@@ -178,6 +179,15 @@ const selectedChannels = computed(() => props.channels.filter((channel) => isSel
:preview-only="previewOnly"
@update:meta="emit('update:meta', channel.id, $event)"
/>
+
diff --git a/resources/js/components/posts/editor/InstagramSettings.vue b/resources/js/components/posts/editor/InstagramSettings.vue
index a294eb948..2fa6083fe 100644
--- a/resources/js/components/posts/editor/InstagramSettings.vue
+++ b/resources/js/components/posts/editor/InstagramSettings.vue
@@ -3,6 +3,7 @@ import { IconAlertTriangle, IconChevronDown, IconChevronUp } from '@tabler/icons
import { computed, ref, watch } from 'vue';
import { Avatar } from '@/components/ui/avatar';
+import { Textarea } from '@/components/ui/textarea';
import { getMediaValidationWarning } from '@/composables/useMedia';
import { getPlatformLogo } from '@/composables/usePlatformLogo';
import { fallbackImageCapableVariant, filterImageCapableVariants } from '@/lib/aiGenerateVariants';
@@ -79,6 +80,13 @@ const pickAspectRatio = (value: string) => {
emit('update:meta', { ...props.meta, aspect_ratio: value });
};
+const FIRST_COMMENT_MAX = 2200;
+
+const firstComment = computed({
+ get: () => (props.meta?.first_comment as string | undefined) || '',
+ set: (value: string) => emit('update:meta', { ...props.meta, first_comment: value || null }),
+});
+
const warning = computed(() => getMediaValidationWarning(props.contentType, props.media));
@@ -154,6 +162,23 @@ const warning = computed(() => getMediaValidationWarning(props.contentType, prop
+
+
+
{{ $t('posts.form.first_comment.label') }}
+
+ {{ firstComment.length }}/{{ FIRST_COMMENT_MAX }}
+
+
+
+
{{ $t('posts.form.first_comment.hint_instagram') }}
+
+
+import { IconChevronDown, IconChevronUp } from '@tabler/icons-vue';
+import { computed, ref } from 'vue';
+
+import { Avatar } from '@/components/ui/avatar';
+import { Textarea } from '@/components/ui/textarea';
+import { getPlatformLogo } from '@/composables/usePlatformLogo';
+
+interface SocialAccount {
+ id: string;
+ platform: string;
+ display_name: string;
+ username: string;
+ display_label: string;
+ avatar_url: string | null;
+}
+
+interface Props {
+ socialAccount: SocialAccount | null;
+ platform: string;
+ meta?: Record;
+ disabled?: boolean;
+ previewOnly?: boolean;
+}
+
+const props = withDefaults(defineProps(), {
+ meta: () => ({}),
+ disabled: false,
+ previewOnly: false,
+});
+
+const emit = defineEmits<{
+ 'update:meta': [value: Record];
+}>();
+
+const open = ref(false);
+
+const DESCRIPTION_MAX = 5000;
+const FIRST_COMMENT_MAX = 2200;
+
+const description = computed({
+ get: () => (props.meta?.description as string | undefined) || '',
+ set: (value: string) => emit('update:meta', { ...props.meta, description: value || null }),
+});
+
+const firstComment = computed({
+ get: () => (props.meta?.first_comment as string | undefined) || '',
+ set: (value: string) => emit('update:meta', { ...props.meta, first_comment: value || null }),
+});
+
+
+
+
+
+
+
+
+
+
+
{{ $t('posts.form.youtube.posting_to') }}
+
+ {{ socialAccount.display_label }}
+ @{{ socialAccount.username }}
+
+
+
+
+
+
+
{{ $t('posts.form.youtube.description') }}
+
+ {{ description.length }}/{{ DESCRIPTION_MAX }}
+
+
+
+
{{ $t('posts.form.youtube.description_hint') }}
+
+
+
+
+
{{ $t('posts.form.first_comment.label') }}
+
+ {{ firstComment.length }}/{{ FIRST_COMMENT_MAX }}
+
+
+
+
{{ $t('posts.form.first_comment.hint') }}
+
+
+
+
diff --git a/tests/Feature/Api/PostApiPlatformMetaTest.php b/tests/Feature/Api/PostApiPlatformMetaTest.php
index 2bfac126b..0f4fa2700 100644
--- a/tests/Feature/Api/PostApiPlatformMetaTest.php
+++ b/tests/Feature/Api/PostApiPlatformMetaTest.php
@@ -289,6 +289,45 @@
Queue::assertPushed(PublishPost::class);
});
+it('persists YouTube description and first_comment meta on store', function () {
+ $youtube = SocialAccount::factory()->create(['workspace_id' => $this->workspace->id, 'platform' => Platform::YouTube]);
+
+ $this->withHeaders($this->headers)
+ ->postJson(route('api.posts.store'), [
+ 'content' => 'Short title text',
+ 'platforms' => [[
+ 'social_account_id' => $youtube->id,
+ 'content_type' => ContentType::YouTubeShort->value,
+ 'meta' => [
+ 'description' => "Full description with facts and links:\nhttps://example.com/map",
+ 'first_comment' => 'More details: https://example.com',
+ ],
+ ]],
+ ])
+ ->assertCreated();
+
+ $meta = PostPlatform::where('social_account_id', $youtube->id)->sole()->meta;
+
+ expect(data_get($meta, 'description'))->toBe("Full description with facts and links:\nhttps://example.com/map")
+ ->and(data_get($meta, 'first_comment'))->toBe('More details: https://example.com');
+});
+
+it('rejects a YouTube description over 5000 characters', function () {
+ $youtube = SocialAccount::factory()->create(['workspace_id' => $this->workspace->id, 'platform' => Platform::YouTube]);
+
+ $this->withHeaders($this->headers)
+ ->postJson(route('api.posts.store'), [
+ 'content' => 'Short title text',
+ 'platforms' => [[
+ 'social_account_id' => $youtube->id,
+ 'content_type' => ContentType::YouTubeShort->value,
+ 'meta' => ['description' => str_repeat('a', 5001)],
+ ]],
+ ])
+ ->assertUnprocessable()
+ ->assertJsonValidationErrors(['platforms.0.meta.description']);
+});
+
it('persists Pinterest title and link meta on store', function () {
$pinterest = SocialAccount::factory()->create(['workspace_id' => $this->workspace->id, 'platform' => Platform::Pinterest]);
diff --git a/tests/Feature/Mcp/PostPlatformMetaToolTest.php b/tests/Feature/Mcp/PostPlatformMetaToolTest.php
index 910816385..27cb1d049 100644
--- a/tests/Feature/Mcp/PostPlatformMetaToolTest.php
+++ b/tests/Feature/Mcp/PostPlatformMetaToolTest.php
@@ -288,6 +288,30 @@
Queue::assertPushed(PublishPost::class);
});
+test('create post persists YouTube description and first_comment meta', function () {
+ $youtube = SocialAccount::factory()->create(['workspace_id' => $this->workspace->id, 'platform' => Platform::YouTube]);
+
+ $response = TryPostServer::actingAs($this->user)
+ ->tool(CreatePostTool::class, [
+ 'content' => 'Short title text',
+ 'platforms' => [[
+ 'social_account_id' => $youtube->id,
+ 'content_type' => ContentType::YouTubeShort->value,
+ 'meta' => [
+ 'description' => 'Full description with a link: https://example.com/map',
+ 'first_comment' => 'More details: https://example.com',
+ ],
+ ]],
+ ]);
+
+ $response->assertOk();
+
+ $meta = PostPlatform::where('social_account_id', $youtube->id)->sole()->meta;
+
+ expect(data_get($meta, 'description'))->toBe('Full description with a link: https://example.com/map')
+ ->and(data_get($meta, 'first_comment'))->toBe('More details: https://example.com');
+});
+
test('create post persists Pinterest title and link meta', function () {
$pinterest = SocialAccount::factory()->create(['workspace_id' => $this->workspace->id, 'platform' => Platform::Pinterest]);
diff --git a/tests/Feature/Services/Social/FirstCommentPosterTest.php b/tests/Feature/Services/Social/FirstCommentPosterTest.php
new file mode 100644
index 000000000..ffe119634
--- /dev/null
+++ b/tests/Feature/Services/Social/FirstCommentPosterTest.php
@@ -0,0 +1,109 @@
+user = User::factory()->create();
+ $this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
+ $this->post = Post::factory()->create([
+ 'workspace_id' => $this->workspace->id,
+ 'user_id' => $this->user->id,
+ 'content' => 'Post body',
+ ]);
+ $this->poster = new FirstCommentPoster;
+});
+
+function makePlatformRow(Workspace $workspace, Post $post, SocialAccount $account, ContentType $contentType, ?array $meta): PostPlatform
+{
+ return PostPlatform::factory()->create([
+ 'post_id' => $post->id,
+ 'social_account_id' => $account->id,
+ 'platform' => $account->platform,
+ 'content_type' => $contentType,
+ 'meta' => $meta,
+ ]);
+}
+
+test('posts a youtube first comment via commentThreads.insert', function () {
+ $account = SocialAccount::factory()->youtube()->create(['workspace_id' => $this->workspace->id]);
+ $row = makePlatformRow($this->workspace, $this->post, $account, ContentType::YouTubeShort, [
+ 'first_comment' => 'Link: https://example.com',
+ ]);
+
+ Http::fake(['*' => Http::response(['id' => 'thread-1'], 200)]);
+
+ $this->poster->post($row, 'video-123');
+
+ $api = rtrim((string) config('trypost.platforms.youtube.data_api'), '/');
+
+ Http::assertSent(function ($request) use ($api) {
+ return str_starts_with($request->url(), "{$api}/commentThreads")
+ && data_get($request->data(), 'snippet.videoId') === 'video-123'
+ && data_get($request->data(), 'snippet.topLevelComment.snippet.textOriginal') === 'Link: https://example.com';
+ });
+});
+
+test('posts an instagram first comment on the published media', function () {
+ $account = SocialAccount::factory()->instagram()->create(['workspace_id' => $this->workspace->id]);
+ $row = makePlatformRow($this->workspace, $this->post, $account, ContentType::InstagramFeed, [
+ 'first_comment' => 'More: https://example.com',
+ ]);
+
+ Http::fake(['*' => Http::response(['id' => 'comment-1'], 200)]);
+
+ $this->poster->post($row, 'media-456');
+
+ $base = $account->platform->instagramGraphBaseUrl();
+
+ Http::assertSent(function ($request) use ($base) {
+ return str_starts_with($request->url(), "{$base}/media-456/comments")
+ && $request['message'] === 'More: https://example.com';
+ });
+});
+
+test('does nothing when first_comment meta is empty', function () {
+ $account = SocialAccount::factory()->youtube()->create(['workspace_id' => $this->workspace->id]);
+ $row = makePlatformRow($this->workspace, $this->post, $account, ContentType::YouTubeShort, ['first_comment' => ' ']);
+
+ Http::fake();
+
+ $this->poster->post($row, 'video-123');
+
+ Http::assertNothingSent();
+});
+
+test('a failed first comment never throws', function () {
+ $account = SocialAccount::factory()->instagram()->create(['workspace_id' => $this->workspace->id]);
+ $row = makePlatformRow($this->workspace, $this->post, $account, ContentType::InstagramFeed, [
+ 'first_comment' => 'More: https://example.com',
+ ]);
+
+ Http::fake(['*' => Http::response(['error' => ['message' => 'Missing permission']], 403)]);
+
+ $this->poster->post($row, 'media-456');
+
+ expect(true)->toBeTrue();
+});
+
+test('unsupported platforms are skipped without requests', function () {
+ $account = SocialAccount::factory()->x()->create(['workspace_id' => $this->workspace->id]);
+ $row = makePlatformRow($this->workspace, $this->post, $account, ContentType::XPost, [
+ 'first_comment' => 'ignored',
+ ]);
+
+ Http::fake();
+
+ $this->poster->post($row, 'tweet-1');
+
+ Http::assertNothingSent();
+});
diff --git a/tests/Feature/Services/Social/YouTubePublisherTest.php b/tests/Feature/Services/Social/YouTubePublisherTest.php
index ca8f33729..687275fdf 100644
--- a/tests/Feature/Services/Social/YouTubePublisherTest.php
+++ b/tests/Feature/Services/Social/YouTubePublisherTest.php
@@ -173,6 +173,26 @@
->toThrow(Exception::class, 'YouTube Shorts require a title');
});
+test('youtube publisher resolves description from platform meta with content fallback', function () {
+ $publisher = new YouTubePublisher;
+ $reflection = new ReflectionClass($publisher);
+ $method = $reflection->getMethod('resolveDescription');
+ $method->setAccessible(true);
+
+ // No meta: falls back to the post content (pre-meta behavior)
+ $this->postPlatform->meta = null;
+ expect($method->invoke($publisher, $this->postPlatform, 'Post content'))->toBe('Post content');
+
+ // Meta description wins over the content
+ $this->postPlatform->meta = ['description' => "Full description\nhttps://example.com/map"];
+ expect($method->invoke($publisher, $this->postPlatform, 'Post content'))
+ ->toBe("Full description\nhttps://example.com/map");
+
+ // Whitespace-only meta description falls back to content
+ $this->postPlatform->meta = ['description' => ' '];
+ expect($method->invoke($publisher, $this->postPlatform, 'Post content'))->toBe('Post content');
+});
+
test('youtube publisher builds correct title with shorts tag', function () {
$publisher = new YouTubePublisher;
$reflection = new ReflectionClass($publisher);
diff --git a/tests/Unit/PostPlatformMetaRulesTest.php b/tests/Unit/PostPlatformMetaRulesTest.php
index f3af6ec52..2dfe4343f 100644
--- a/tests/Unit/PostPlatformMetaRulesTest.php
+++ b/tests/Unit/PostPlatformMetaRulesTest.php
@@ -4,18 +4,22 @@
use App\Support\PostPlatformMetaRules;
-test('custom meta messages only cover pinterest title and link', function () {
+test('custom meta messages cover pinterest, youtube and first-comment fields', function () {
expect(PostPlatformMetaRules::messages())->toBe([
'platforms.*.meta.link.url' => __('posts.form.pinterest.link_invalid'),
'platforms.*.meta.link.max' => __('posts.form.pinterest.link_max'),
'platforms.*.meta.title.max' => __('posts.form.pinterest.title_max'),
+ 'platforms.*.meta.description.max' => __('posts.form.youtube.description_max'),
+ 'platforms.*.meta.first_comment.max' => __('posts.form.first_comment.max'),
]);
});
-test('custom meta attributes only rename pinterest title and link', function () {
+test('custom meta attributes rename pinterest, youtube and first-comment fields', function () {
expect(PostPlatformMetaRules::attributes())->toBe([
'platforms.*.meta.title' => __('posts.form.pinterest.title'),
'platforms.*.meta.link' => __('posts.form.pinterest.link'),
+ 'platforms.*.meta.description' => __('posts.form.youtube.description'),
+ 'platforms.*.meta.first_comment' => __('posts.form.first_comment.label'),
]);
});
@@ -29,5 +33,7 @@
'platforms.*.meta.channel_id',
'platforms.*.meta.title',
'platforms.*.meta.link',
+ 'platforms.*.meta.description',
+ 'platforms.*.meta.first_comment',
]);
});
From 81150fcd821382f1725d6f8cafaf5663025d14ae Mon Sep 17 00:00:00 2001
From: Grigory Frolov <2168057+gynsus@users.noreply.github.com>
Date: Sun, 23 Aug 2026 11:58:37 +0300
Subject: [PATCH 2/5] fix: cap YouTube content at the description limit, not
the title's
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The 100-char cap predates the per-platform meta: back then the whole
content served as both title and description. Now the title comes from
meta.title (validated at 100) or is auto-truncated by buildTitle, and the
content lands in the description (5000) unless meta.description overrides
it — yet a fully meta-equipped post still failed publish-time validation
because the shared caption was longer than a title. Cap at 5000.
---
app/Enums/SocialAccount/Platform.php | 11 ++++++-----
1 file changed, 6 insertions(+), 5 deletions(-)
diff --git a/app/Enums/SocialAccount/Platform.php b/app/Enums/SocialAccount/Platform.php
index 1d9d207c8..8b640c7b4 100644
--- a/app/Enums/SocialAccount/Platform.php
+++ b/app/Enums/SocialAccount/Platform.php
@@ -167,10 +167,11 @@ public function supportsAltText(): bool
* - LinkedIn UGC: 3000 (`commentary` field)
* - X standard tweet: 280 (X Premium accepts 25K — ignored, conservative)
* - TikTok caption: 2200
- * - YouTube Shorts: title=100, description=5000. We feed `content` to both
- * (publisher derives title from the first line via `buildTitle`), and
- * Shorts UX only shows ~100 chars before "more" — capping at 100 keeps
- * posts appropriate for the format.
+ * - YouTube Shorts: description=5000 (the cap here). The title never
+ * overflows: it comes from `meta.title` (validated at 100) or is
+ * derived from the first content line and truncated by `buildTitle`;
+ * the full content goes to the description unless `meta.description`
+ * overrides it.
* - Facebook text status: 10000 (API allows 63206; we cap below
* that — 63k-char posts are unrealistic and emoji-heavy content
* risks overflowing the TEXT column's 65535-byte ceiling)
@@ -188,7 +189,7 @@ public function maxContentLength(): int
self::LinkedIn, self::LinkedInPage => 3000,
self::X => 280,
self::TikTok => 2200,
- self::YouTube => 100,
+ self::YouTube => 5000,
self::Facebook => 10000,
self::Instagram, self::InstagramFacebook => 2200,
self::Threads => 500,
From ddfb2f07f1dfa5ba25cb3b8fcd630de99cb24aa9 Mon Sep 17 00:00:00 2001
From: Grigory Frolov <2168057+gynsus@users.noreply.github.com>
Date: Sun, 23 Aug 2026 12:05:05 +0300
Subject: [PATCH 3/5] test: YouTube content cap expectation follows the
description limit
---
tests/Unit/Enums/PlatformTest.php | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/tests/Unit/Enums/PlatformTest.php b/tests/Unit/Enums/PlatformTest.php
index 803756c5c..c5baca3d9 100644
--- a/tests/Unit/Enums/PlatformTest.php
+++ b/tests/Unit/Enums/PlatformTest.php
@@ -61,7 +61,7 @@
expect(Platform::LinkedIn->maxContentLength())->toBe(3000);
expect(Platform::X->maxContentLength())->toBe(280);
expect(Platform::TikTok->maxContentLength())->toBe(2200);
- expect(Platform::YouTube->maxContentLength())->toBe(100);
+ expect(Platform::YouTube->maxContentLength())->toBe(5000);
expect(Platform::Facebook->maxContentLength())->toBe(10000);
expect(Platform::Instagram->maxContentLength())->toBe(2200);
expect(Platform::Threads->maxContentLength())->toBe(500);
From 1dd9e43da7f233fb7ceb55bb1dcff1bc0722f184 Mon Sep 17 00:00:00 2001
From: Grigory Frolov <2168057+gynsus@users.noreply.github.com>
Date: Fri, 21 Aug 2026 15:37:57 +0300
Subject: [PATCH 4/5] =?UTF-8?q?feat:=20per-platform=20promotion=20fields?=
=?UTF-8?q?=20=E2=80=94=20caption=20override,=20locations,=20YouTube=20SEO?=
=?UTF-8?q?=20meta?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Per-platform meta additions, all wired through web/API/MCP validation:
- content: a caption override for one platform row (short copy for X next
to a long Instagram caption) without splitting the post. Publishers and
publish-time length checks read PostPlatform::resolvedContent(); the
editor gets a per-channel override card with the platform's limit, and
the preview renders the override.
- location_id/location_name (Instagram + Facebook): tags feed/reel/
carousel containers and FB feed/photo posts with a Facebook place. Meta
closed place search to third parties, so the ID is entered by hand.
- YouTube: title (own field instead of the trimmed first content line),
tags -> snippet.tags, category_id, default_language, and
recording_location {lat,lng,description} -> recordingDetails.
- Video alt text: Mastodon and Bluesky accept media descriptions on
video; the alt editor now opens for video items too.
---
app/Mcp/Tools/Post/CreatePostTool.php | 2 +-
app/Mcp/Tools/Post/UpdatePostTool.php | 2 +-
app/Models/PostPlatform.php | 14 +++
.../Social/AbstractLinkedInPublisher.php | 4 +-
app/Services/Social/BlueskyPublisher.php | 6 +-
.../Social/Concerns/HasSocialHttpClient.php | 2 +-
.../Social/Discord/DiscordPublisher.php | 4 +-
app/Services/Social/FacebookPublisher.php | 31 +++++-
app/Services/Social/InstagramPublisher.php | 33 +++++--
app/Services/Social/MastodonPublisher.php | 4 +-
app/Services/Social/PinterestPublisher.php | 4 +-
.../Social/Telegram/TelegramPublisher.php | 4 +-
app/Services/Social/ThreadsPublisher.php | 2 +-
app/Services/Social/TikTokPublisher.php | 2 +-
app/Services/Social/XPublisher.php | 2 +-
app/Services/Social/YouTubePublisher.php | 52 +++++++++-
app/Support/PostPlatformMetaRules.php | 21 ++++
lang/ar/posts.php | 25 +++++
lang/de/posts.php | 25 +++++
lang/el/posts.php | 25 +++++
lang/en/posts.php | 25 +++++
lang/es/posts.php | 25 +++++
lang/fr/posts.php | 25 +++++
lang/it/posts.php | 25 +++++
lang/ja/posts.php | 25 +++++
lang/ko/posts.php | 25 +++++
lang/nl/posts.php | 25 +++++
lang/pl/posts.php | 25 +++++
lang/pt-BR/posts.php | 25 +++++
lang/ru/posts.php | 25 +++++
lang/tr/posts.php | 25 +++++
lang/uk/posts.php | 25 +++++
lang/zh/posts.php | 25 +++++
.../js/components/ChannelConfigurator.vue | 7 ++
.../posts/editor/ChannelCaptionOverride.vue | 71 ++++++++++++++
.../posts/editor/FacebookSettings.vue | 20 ++++
.../posts/editor/InstagramSettings.vue | 20 ++++
.../posts/editor/PostEditorComposer.vue | 6 +-
.../js/components/posts/editor/PreviewTab.vue | 2 +-
.../components/posts/editor/ScheduleTab.vue | 1 +
.../posts/editor/YouTubeSettings.vue | 98 +++++++++++++++++++
resources/js/types/channel.ts | 1 +
tests/Feature/Api/PostApiPlatformMetaTest.php | 71 ++++++++++++++
.../Social/InstagramPublisherTest.php | 28 ++++++
.../Services/Social/MastodonPublisherTest.php | 18 ++++
.../Services/Social/YouTubePublisherTest.php | 15 +++
46 files changed, 911 insertions(+), 36 deletions(-)
create mode 100644 resources/js/components/posts/editor/ChannelCaptionOverride.vue
diff --git a/app/Mcp/Tools/Post/CreatePostTool.php b/app/Mcp/Tools/Post/CreatePostTool.php
index 372c6f21d..3b6ccfbd3 100644
--- a/app/Mcp/Tools/Post/CreatePostTool.php
+++ b/app/Mcp/Tools/Post/CreatePostTool.php
@@ -79,7 +79,7 @@ public function schema(JsonSchema $schema): array
->items($schema->object(fn ($p) => [
'social_account_id' => $p->string()->required()->description('UUID of the connected social account.'),
'content_type' => $p->string()->required()->description('Format for this platform (e.g. linkedin_post, x_post, instagram_feed).'),
- 'meta' => $p->object()->description('Per-platform metadata. Instagram/Facebook: aspect_ratio (1:1|4:5|16:9|original). TikTok: privacy_level (required to publish) + flags (allow_comments, allow_duet, allow_stitch, disclose, brand_content_toggle, brand_organic_toggle, is_aigc, auto_add_music). Pinterest: board_id (required to publish — call ListPinterestBoardsTool first), title (≤100), link (destination URL). Pin description comes from the post content. Discord: channel_id (required to publish — call ListDiscordChannelsTool first), mentions ([{token,label}]), embeds ([{title,description,url,image,color}]). YouTube: description (≤5000, clickable links; falls back to post content). YouTube/Instagram: first_comment (≤2200, posted by the account right after publish).'),
+ 'meta' => $p->object()->description('Per-platform metadata. Instagram/Facebook: aspect_ratio (1:1|4:5|16:9|original). TikTok: privacy_level (required to publish) + flags (allow_comments, allow_duet, allow_stitch, disclose, brand_content_toggle, brand_organic_toggle, is_aigc, auto_add_music). Pinterest: board_id (required to publish — call ListPinterestBoardsTool first), title (≤100), link (destination URL). Pin description comes from the post content. Discord: channel_id (required to publish — call ListDiscordChannelsTool first), mentions ([{token,label}]), embeds ([{title,description,url,image,color}]). YouTube: description (≤5000, clickable links; falls back to post content). YouTube/Instagram: first_comment (≤2200, posted by the account right after publish). Any platform: content (per-platform caption override; empty = shared post text). Instagram/Facebook: location_id (Facebook place ID) + location_name. YouTube: title (≤100), tags (string array), category_id, default_language, recording_location {lat,lng,description}.'),
]))
->description('Platforms to publish on. Accounts not listed remain available but disabled.'),
];
diff --git a/app/Mcp/Tools/Post/UpdatePostTool.php b/app/Mcp/Tools/Post/UpdatePostTool.php
index aa63e02dd..88c32831d 100644
--- a/app/Mcp/Tools/Post/UpdatePostTool.php
+++ b/app/Mcp/Tools/Post/UpdatePostTool.php
@@ -119,7 +119,7 @@ public function schema(JsonSchema $schema): array
->items($schema->object(fn ($p) => [
'id' => $p->string()->required()->description('UUID of the post_platform row (from get-post-tool / list-posts-tool).'),
'content_type' => $p->string()->description('New content_type for this platform.'),
- 'meta' => $p->object()->description('Per-platform metadata override. Instagram/Facebook: aspect_ratio. TikTok: privacy_level (required to publish) + flags. Pinterest: board_id (required to publish — call ListPinterestBoardsTool first), title (≤100), link (destination URL). Pin description comes from the post content. Discord: channel_id (required to publish — call ListDiscordChannelsTool first), mentions, embeds. YouTube: description (≤5000, clickable links; falls back to post content). YouTube/Instagram: first_comment (≤2200, posted by the account right after publish). Merged with existing meta.'),
+ 'meta' => $p->object()->description('Per-platform metadata override. Instagram/Facebook: aspect_ratio. TikTok: privacy_level (required to publish) + flags. Pinterest: board_id (required to publish — call ListPinterestBoardsTool first), title (≤100), link (destination URL). Pin description comes from the post content. Discord: channel_id (required to publish — call ListDiscordChannelsTool first), mentions, embeds. YouTube: description (≤5000, clickable links; falls back to post content). YouTube/Instagram: first_comment (≤2200, posted by the account right after publish). Any platform: content (per-platform caption override; empty = shared post text). Instagram/Facebook: location_id (Facebook place ID) + location_name. YouTube: title (≤100), tags (string array), category_id, default_language, recording_location {lat,lng,description}. Merged with existing meta.'),
]))
->description('Platforms to enable for publishing. Any platform NOT listed will be disabled. Pass an empty array to disable all.'),
];
diff --git a/app/Models/PostPlatform.php b/app/Models/PostPlatform.php
index 35f0f0dda..c73033596 100644
--- a/app/Models/PostPlatform.php
+++ b/app/Models/PostPlatform.php
@@ -53,6 +53,20 @@ protected function casts(): array
];
}
+ /**
+ * The text this platform row publishes: the per-platform `meta.content`
+ * override when the user provided one (a short copy for X next to a long
+ * Instagram caption, without splitting the post), otherwise the post's
+ * shared content. Publishers and publish-time length validation must read
+ * content through this method, never `post->content` directly.
+ */
+ public function resolvedContent(): ?string
+ {
+ $override = trim((string) data_get($this->meta, 'content'));
+
+ return $override !== '' ? $override : $this->post?->content;
+ }
+
public function post(): BelongsTo
{
return $this->belongsTo(Post::class);
diff --git a/app/Services/Social/AbstractLinkedInPublisher.php b/app/Services/Social/AbstractLinkedInPublisher.php
index 774842be8..c900a7cd6 100644
--- a/app/Services/Social/AbstractLinkedInPublisher.php
+++ b/app/Services/Social/AbstractLinkedInPublisher.php
@@ -61,8 +61,8 @@ public function publish(PostPlatform $postPlatform): array
{
$this->validateContentLength($postPlatform);
- $content = $postPlatform->post->content
- ? app(ContentSanitizer::class)->sanitize($postPlatform->post->content, $postPlatform->platform)
+ $content = $postPlatform->resolvedContent()
+ ? app(ContentSanitizer::class)->sanitize($postPlatform->resolvedContent(), $postPlatform->platform)
: null;
$this->account = $postPlatform->socialAccount;
diff --git a/app/Services/Social/BlueskyPublisher.php b/app/Services/Social/BlueskyPublisher.php
index a881b5c79..046a2926f 100644
--- a/app/Services/Social/BlueskyPublisher.php
+++ b/app/Services/Social/BlueskyPublisher.php
@@ -51,7 +51,7 @@ public function publish(PostPlatform $postPlatform): array
{
$this->validateContentLength($postPlatform);
- $content = $postPlatform->post->content ? app(ContentSanitizer::class)->sanitize($postPlatform->post->content, $postPlatform->platform) : null;
+ $content = $postPlatform->resolvedContent() ? app(ContentSanitizer::class)->sanitize($postPlatform->resolvedContent(), $postPlatform->platform) : null;
$account = $postPlatform->socialAccount;
$service = $account->meta['service'] ?? config('trypost.platforms.bluesky.default_service');
@@ -100,6 +100,10 @@ public function publish(PostPlatform $postPlatform): array
'$type' => BlueskyLexicon::EMBED_VIDEO,
'video' => $videoBlob,
];
+
+ if (($alt = $video->altTextFor(Platform::Bluesky)) !== null) {
+ $embed['alt'] = $alt;
+ }
}
}
}
diff --git a/app/Services/Social/Concerns/HasSocialHttpClient.php b/app/Services/Social/Concerns/HasSocialHttpClient.php
index 3977eee17..540eb348d 100644
--- a/app/Services/Social/Concerns/HasSocialHttpClient.php
+++ b/app/Services/Social/Concerns/HasSocialHttpClient.php
@@ -22,7 +22,7 @@ trait HasSocialHttpClient
*/
protected function validateContentLength(PostPlatform $postPlatform): void
{
- $raw = $postPlatform->post->content ?? '';
+ $raw = $postPlatform->resolvedContent() ?? '';
$content = app(ContentSanitizer::class)->displayText($raw, $postPlatform->platform);
if ($postPlatform->platform->contentOverflow($content) === 0) {
diff --git a/app/Services/Social/Discord/DiscordPublisher.php b/app/Services/Social/Discord/DiscordPublisher.php
index cb58c7b3f..552f5c52f 100644
--- a/app/Services/Social/Discord/DiscordPublisher.php
+++ b/app/Services/Social/Discord/DiscordPublisher.php
@@ -52,8 +52,8 @@ public function publish(PostPlatform $postPlatform): array
// bot is in (including another workspace's).
$this->guardChannelBelongsToGuild($guildId, $channelId);
- $content = $postPlatform->post->content
- ? app(ContentSanitizer::class)->sanitize($postPlatform->post->content, $postPlatform->platform)
+ $content = $postPlatform->resolvedContent()
+ ? app(ContentSanitizer::class)->sanitize($postPlatform->resolvedContent(), $postPlatform->platform)
: '';
$content = $this->appendMentions($content, $postPlatform);
diff --git a/app/Services/Social/FacebookPublisher.php b/app/Services/Social/FacebookPublisher.php
index 64a7e1a27..6e7403bf3 100644
--- a/app/Services/Social/FacebookPublisher.php
+++ b/app/Services/Social/FacebookPublisher.php
@@ -24,6 +24,13 @@ class FacebookPublisher
private string $baseUrl;
+ /**
+ * Facebook place ID from the platform row's `meta.location_id` — tags feed
+ * and photo posts with a location (`place` param). Reels/stories don't
+ * accept it.
+ */
+ private ?string $placeId = null;
+
public function __construct()
{
$this->baseUrl = config('trypost.platforms.facebook.graph_api');
@@ -42,7 +49,8 @@ public function publish(PostPlatform $postPlatform): array
{
$this->validateContentLength($postPlatform);
- $content = $postPlatform->post->content ? app(ContentSanitizer::class)->sanitize($postPlatform->post->content, $postPlatform->platform) : null;
+ $content = $postPlatform->resolvedContent() ? app(ContentSanitizer::class)->sanitize($postPlatform->resolvedContent(), $postPlatform->platform) : null;
+ $this->placeId = trim((string) data_get($postPlatform->meta, 'location_id')) ?: null;
$account = $postPlatform->socialAccount;
$pageId = $account->platform_user_id;
@@ -100,12 +108,25 @@ private function publishPost(string $pageId, string $accessToken, ?string $conte
);
}
+ /**
+ * @param array $payload
+ * @return array
+ */
+ private function withPlace(array $payload): array
+ {
+ if ($this->placeId !== null) {
+ $payload['place'] = $this->placeId;
+ }
+
+ return $payload;
+ }
+
private function publishTextPost(string $pageId, string $accessToken, string $content): array
{
- $response = $this->facebookHttp()->post("{$this->baseUrl}/{$pageId}/feed", [
+ $response = $this->facebookHttp()->post("{$this->baseUrl}/{$pageId}/feed", $this->withPlace([
'message' => $content,
'access_token' => $accessToken,
- ]);
+ ]));
if ($response->failed()) {
Log::error('Facebook text post failed', [
@@ -141,7 +162,7 @@ private function publishSingleImagePost(string $pageId, string $accessToken, ?st
$payload['alt_text_custom'] = $alt;
}
- $response = $this->facebookHttp()->post("{$this->baseUrl}/{$pageId}/photos", $payload);
+ $response = $this->facebookHttp()->post("{$this->baseUrl}/{$pageId}/photos", $this->withPlace($payload));
if ($response->failed()) {
Log::error('Facebook single image post failed', [
@@ -216,7 +237,7 @@ private function publishMultiImagePost(string $pageId, string $accessToken, ?str
$postData["attached_media[{$index}]"] = json_encode($media);
}
- $response = $this->facebookHttp()->post("{$this->baseUrl}/{$pageId}/feed", $postData);
+ $response = $this->facebookHttp()->post("{$this->baseUrl}/{$pageId}/feed", $this->withPlace($postData));
if ($response->failed()) {
Log::error('Facebook multi-image post failed', [
diff --git a/app/Services/Social/InstagramPublisher.php b/app/Services/Social/InstagramPublisher.php
index 21d97f21b..6c8027fe7 100644
--- a/app/Services/Social/InstagramPublisher.php
+++ b/app/Services/Social/InstagramPublisher.php
@@ -38,6 +38,13 @@ class InstagramPublisher
private const string WORKFLOW_FINAL_CONTAINER = 'final_container';
+ /**
+ * Facebook place ID from the platform row's `meta.location_id` — tags the
+ * post with a location. Applied to feed/reel/carousel containers; the API
+ * does not accept it on stories or carousel children.
+ */
+ private ?string $locationId = null;
+
public function publish(PostPlatform $postPlatform): array
{
$this->postPlatform = $postPlatform;
@@ -45,6 +52,7 @@ public function publish(PostPlatform $postPlatform): array
$account = $postPlatform->socialAccount;
$this->baseUrl = $account->platform->instagramGraphBaseUrl();
+ $this->locationId = trim((string) data_get($postPlatform->meta, 'location_id')) ?: null;
if ($account->needsProactiveTokenRefresh()) {
app(ConnectionVerifier::class)->refreshToken($account);
@@ -53,7 +61,7 @@ public function publish(PostPlatform $postPlatform): array
$instagramId = $account->platform_user_id;
$accessToken = $account->access_token;
- $content = $postPlatform->post->content ? app(ContentSanitizer::class)->sanitize($postPlatform->post->content, $postPlatform->platform) : null;
+ $content = $postPlatform->resolvedContent() ? app(ContentSanitizer::class)->sanitize($postPlatform->resolvedContent(), $postPlatform->platform) : null;
$pendingWorkflow = PublishCheckpoint::instagramWorkflow($postPlatform->error_context);
@@ -101,6 +109,19 @@ private function publishFeed(string $instagramId, string $accessToken, ?string $
return $this->publishSingleImage($instagramId, $accessToken, $content, $firstMedia, $aspectRatio);
}
+ /**
+ * @param array $params
+ * @return array
+ */
+ private function withLocation(array $params): array
+ {
+ if ($this->locationId !== null) {
+ $params['location_id'] = $this->locationId;
+ }
+
+ return $params;
+ }
+
private function publishSingleImage(string $instagramId, string $accessToken, ?string $content, $media, ?string $aspectRatio): array
{
$imageUrl = $this->cropImageForAspectRatio($media->url, $aspectRatio);
@@ -117,19 +138,19 @@ private function publishSingleImage(string $instagramId, string $accessToken, ?s
$params['alt_text'] = $alt;
}
- $containerId = $this->createContainer($instagramId, $params, 'container');
+ $containerId = $this->createContainer($instagramId, $this->withLocation($params), 'container');
return $this->finishContainer($instagramId, $accessToken, $containerId);
}
private function publishReel(string $instagramId, string $accessToken, ?string $content, $media): array
{
- $containerId = $this->createContainer($instagramId, [
+ $containerId = $this->createContainer($instagramId, $this->withLocation([
'video_url' => $media->url,
'caption' => $content,
'media_type' => 'REELS',
'access_token' => $accessToken,
- ], 'reel container');
+ ]), 'reel container');
return $this->finishContainer($instagramId, $accessToken, $containerId);
}
@@ -233,12 +254,12 @@ private function finishCarousel(string $instagramId, string $accessToken, ?strin
$this->waitForMediaProcessing($childId, $accessToken, $workflow);
}
- $carouselId = $this->createContainer($instagramId, [
+ $carouselId = $this->createContainer($instagramId, $this->withLocation([
'media_type' => 'CAROUSEL',
'caption' => $content,
'children' => implode(',', $childContainers),
'access_token' => $accessToken,
- ], 'carousel container');
+ ]), 'carousel container');
return $this->finishContainer($instagramId, $accessToken, $carouselId);
}
diff --git a/app/Services/Social/MastodonPublisher.php b/app/Services/Social/MastodonPublisher.php
index 57350e87f..e8571172a 100644
--- a/app/Services/Social/MastodonPublisher.php
+++ b/app/Services/Social/MastodonPublisher.php
@@ -23,7 +23,7 @@ public function publish(PostPlatform $postPlatform): array
{
$this->validateContentLength($postPlatform);
- $content = $postPlatform->post->content ? app(ContentSanitizer::class)->sanitize($postPlatform->post->content, $postPlatform->platform) : null;
+ $content = $postPlatform->resolvedContent() ? app(ContentSanitizer::class)->sanitize($postPlatform->resolvedContent(), $postPlatform->platform) : null;
$account = $postPlatform->socialAccount;
$instance = $account->meta['instance'] ?? config('trypost.platforms.mastodon.default_instance');
@@ -33,7 +33,7 @@ public function publish(PostPlatform $postPlatform): array
// Upload media first (max 4)
foreach ($medias->take(4) as $media) {
- $mediaId = $this->uploadMedia($account, $instance, $media->url, $media->original_filename, $media->isImage() ? $media->altTextFor(Platform::Mastodon) : null);
+ $mediaId = $this->uploadMedia($account, $instance, $media->url, $media->original_filename, $media->altTextFor(Platform::Mastodon));
if ($mediaId) {
$mediaIds[] = $mediaId;
}
diff --git a/app/Services/Social/PinterestPublisher.php b/app/Services/Social/PinterestPublisher.php
index bbee0944f..8045bf895 100644
--- a/app/Services/Social/PinterestPublisher.php
+++ b/app/Services/Social/PinterestPublisher.php
@@ -42,8 +42,8 @@ public function publish(PostPlatform $postPlatform): array
app(ConnectionVerifier::class)->refreshToken($account);
}
- $content = $postPlatform->post->content
- ? app(ContentSanitizer::class)->sanitize($postPlatform->post->content, $postPlatform->platform)
+ $content = $postPlatform->resolvedContent()
+ ? app(ContentSanitizer::class)->sanitize($postPlatform->resolvedContent(), $postPlatform->platform)
: null;
return match ($postPlatform->content_type) {
diff --git a/app/Services/Social/Telegram/TelegramPublisher.php b/app/Services/Social/Telegram/TelegramPublisher.php
index 7a589da19..a7645f92f 100644
--- a/app/Services/Social/Telegram/TelegramPublisher.php
+++ b/app/Services/Social/Telegram/TelegramPublisher.php
@@ -33,8 +33,8 @@ public function publish(PostPlatform $postPlatform): array
$account = $postPlatform->socialAccount;
$chatId = (string) data_get($account->meta, 'chat_id');
- $content = $postPlatform->post->content
- ? app(ContentSanitizer::class)->sanitize($postPlatform->post->content, $postPlatform->platform)
+ $content = $postPlatform->resolvedContent()
+ ? app(ContentSanitizer::class)->sanitize($postPlatform->resolvedContent(), $postPlatform->platform)
: '';
$media = $postPlatform->post->mediaItems->take(self::ALBUM_CHUNK);
diff --git a/app/Services/Social/ThreadsPublisher.php b/app/Services/Social/ThreadsPublisher.php
index 8585cfcad..30f981d1a 100644
--- a/app/Services/Social/ThreadsPublisher.php
+++ b/app/Services/Social/ThreadsPublisher.php
@@ -35,7 +35,7 @@ public function publish(PostPlatform $postPlatform): array
{
$this->validateContentLength($postPlatform);
- $content = $postPlatform->post->content ? app(ContentSanitizer::class)->sanitize($postPlatform->post->content, $postPlatform->platform) : null;
+ $content = $postPlatform->resolvedContent() ? app(ContentSanitizer::class)->sanitize($postPlatform->resolvedContent(), $postPlatform->platform) : null;
$account = $postPlatform->socialAccount;
diff --git a/app/Services/Social/TikTokPublisher.php b/app/Services/Social/TikTokPublisher.php
index 645ff67d4..85ecca443 100644
--- a/app/Services/Social/TikTokPublisher.php
+++ b/app/Services/Social/TikTokPublisher.php
@@ -45,7 +45,7 @@ public function publish(PostPlatform $postPlatform): array
{
$this->validateContentLength($postPlatform);
- $content = $postPlatform->post->content ? app(ContentSanitizer::class)->sanitize($postPlatform->post->content, $postPlatform->platform) : null;
+ $content = $postPlatform->resolvedContent() ? app(ContentSanitizer::class)->sanitize($postPlatform->resolvedContent(), $postPlatform->platform) : null;
$account = $postPlatform->socialAccount;
diff --git a/app/Services/Social/XPublisher.php b/app/Services/Social/XPublisher.php
index ae4aee94c..de0d183a9 100644
--- a/app/Services/Social/XPublisher.php
+++ b/app/Services/Social/XPublisher.php
@@ -35,7 +35,7 @@ public function publish(PostPlatform $postPlatform): array
{
$this->validateContentLength($postPlatform);
- $content = $postPlatform->post->content ? app(ContentSanitizer::class)->sanitize($postPlatform->post->content, $postPlatform->platform) : null;
+ $content = $postPlatform->resolvedContent() ? app(ContentSanitizer::class)->sanitize($postPlatform->resolvedContent(), $postPlatform->platform) : null;
$account = $postPlatform->socialAccount;
diff --git a/app/Services/Social/YouTubePublisher.php b/app/Services/Social/YouTubePublisher.php
index c1868c6fb..881850aa4 100644
--- a/app/Services/Social/YouTubePublisher.php
+++ b/app/Services/Social/YouTubePublisher.php
@@ -14,6 +14,8 @@
use Google\Service\YouTube;
use Google\Service\YouTube\Video;
use Google\Service\YouTube\VideoSnippet;
+use Google\Service\YouTube\GeoPoint;
+use Google\Service\YouTube\VideoRecordingDetails;
use Google\Service\YouTube\VideoStatus;
use Google_Http_MediaFileUpload;
use Illuminate\Support\Facades\Http;
@@ -29,7 +31,7 @@ public function publish(PostPlatform $postPlatform): array
{
$this->validateContentLength($postPlatform);
- $content = $postPlatform->post->content ? app(ContentSanitizer::class)->sanitize($postPlatform->post->content, $postPlatform->platform) : null;
+ $content = $postPlatform->resolvedContent() ? app(ContentSanitizer::class)->sanitize($postPlatform->resolvedContent(), $postPlatform->platform) : null;
$account = $postPlatform->socialAccount;
@@ -92,7 +94,7 @@ private function publishShort(PostPlatform $postPlatform, $media, SocialAccount
);
}
- $title = $this->buildTitle($content);
+ $title = $this->resolveTitle($postPlatform, $content);
$description = $this->resolveDescription($postPlatform, $content);
$tempFile = tempnam(sys_get_temp_dir(), 'yt_upload_');
@@ -130,7 +132,19 @@ private function publishShort(PostPlatform $postPlatform, $media, SocialAccount
$snippet = new VideoSnippet;
$snippet->setTitle($title);
$snippet->setDescription($description);
- $snippet->setCategoryId('22');
+ $snippet->setCategoryId((string) (data_get($postPlatform->meta, 'category_id') ?: '22'));
+
+ $tags = array_values(array_filter(array_map(
+ fn ($tag) => trim((string) $tag),
+ (array) data_get($postPlatform->meta, 'tags', []),
+ )));
+ if ($tags !== []) {
+ $snippet->setTags($tags);
+ }
+
+ if (filled($language = data_get($postPlatform->meta, 'default_language'))) {
+ $snippet->setDefaultLanguage((string) $language);
+ }
$status = new VideoStatus;
$status->setPrivacyStatus('public');
@@ -140,8 +154,26 @@ private function publishShort(PostPlatform $postPlatform, $media, SocialAccount
$video->setSnippet($snippet);
$video->setStatus($status);
+ $parts = 'snippet,status';
+
+ $recording = data_get($postPlatform->meta, 'recording_location');
+ if (is_array($recording) && isset($recording['lat'], $recording['lng'])) {
+ $location = new GeoPoint;
+ $location->setLatitude((float) $recording['lat']);
+ $location->setLongitude((float) $recording['lng']);
+
+ $details = new VideoRecordingDetails;
+ $details->setLocation($location);
+ if (filled($recording['description'] ?? null)) {
+ $details->setLocationDescription((string) $recording['description']);
+ }
+
+ $video->setRecordingDetails($details);
+ $parts .= ',recordingDetails';
+ }
+
// Initialize resumable upload request
- $insertRequest = $youtube->videos->insert('snippet,status', $video);
+ $insertRequest = $youtube->videos->insert($parts, $video);
$mediaUpload = new Google_Http_MediaFileUpload(
$client,
@@ -221,6 +253,18 @@ private function buildTitle(string $content): string
return $title.$shortsTag;
}
+ /**
+ * The video title: the per-platform `meta.title` when set (YouTube caps
+ * titles at 100 chars — the shared `title` meta rule enforces that),
+ * otherwise derived from the first line of the content as before.
+ */
+ private function resolveTitle(PostPlatform $postPlatform, string $content): string
+ {
+ $title = trim((string) data_get($postPlatform->meta, 'title'));
+
+ return $title !== '' ? $title : $this->buildTitle($content);
+ }
+
/**
* The video description: the per-platform `meta.description` when the user
* provided one (YouTube allows 5000 chars and renders links clickable),
diff --git a/app/Support/PostPlatformMetaRules.php b/app/Support/PostPlatformMetaRules.php
index 7cd4b32ec..e4c1dab64 100644
--- a/app/Support/PostPlatformMetaRules.php
+++ b/app/Support/PostPlatformMetaRules.php
@@ -69,6 +69,27 @@ public static function rules(): array
// the two.
'platforms.*.meta.first_comment' => ['sometimes', 'nullable', 'string', 'max:2200'],
+ // Any platform — caption override for this platform row only. The
+ // platform's own hard cap is enforced at publish time against the
+ // resolved text (PostPlatform::resolvedContent()).
+ 'platforms.*.meta.content' => ['sometimes', 'nullable', 'string', 'max:16000'],
+
+ // Instagram / Facebook — place tag. Meta closed place search to
+ // third parties, so the ID is entered by hand; location_name is
+ // only for display in the editor.
+ 'platforms.*.meta.location_id' => ['sometimes', 'nullable', 'string', 'max:64'],
+ 'platforms.*.meta.location_name' => ['sometimes', 'nullable', 'string', 'max:200'],
+
+ // YouTube (title reuses the shared 100-char `title` rule below)
+ 'platforms.*.meta.tags' => ['sometimes', 'nullable', 'array', 'max:30'],
+ 'platforms.*.meta.tags.*' => ['required', 'string', 'max:100'],
+ 'platforms.*.meta.category_id' => ['sometimes', 'nullable', 'string', 'max:10'],
+ 'platforms.*.meta.default_language' => ['sometimes', 'nullable', 'string', 'max:12'],
+ 'platforms.*.meta.recording_location' => ['sometimes', 'nullable', 'array'],
+ 'platforms.*.meta.recording_location.lat' => ['required_with:platforms.*.meta.recording_location', 'numeric', 'between:-90,90'],
+ 'platforms.*.meta.recording_location.lng' => ['required_with:platforms.*.meta.recording_location', 'numeric', 'between:-180,180'],
+ 'platforms.*.meta.recording_location.description' => ['sometimes', 'nullable', 'string', 'max:200'],
+
// Pinterest
'platforms.*.meta.board_id' => ['sometimes', 'nullable', 'string'],
'platforms.*.meta.title' => ['sometimes', 'nullable', 'string', 'max:100'],
diff --git a/lang/ar/posts.php b/lang/ar/posts.php
index 209e29373..f1578d03b 100644
--- a/lang/ar/posts.php
+++ b/lang/ar/posts.php
@@ -142,6 +142,19 @@
'document_title' => 'عنوان المستند',
'document_title_placeholder' => 'يظهر على منشور مستند PDF الخاص بك',
],
+ 'custom_caption' => [
+ 'label' => 'نص مخصص لـ :platform',
+ 'active' => 'مفعّل',
+ 'hint' => 'اتركه فارغًا لاستخدام نص المنشور المشترك.',
+ 'placeholder' => 'نص لهذه المنصة فقط…',
+ 'over_limit' => 'يتجاوز حد هذه المنصة (:limit حرفًا).',
+ ],
+ 'location' => [
+ 'label' => 'الموقع',
+ 'id_placeholder' => 'معرّف المكان',
+ 'name_placeholder' => 'الاسم (للعرض)',
+ 'hint' => 'معرّف مكان فيسبوك؛ البحث عن الأماكن مغلق للتطبيقات الخارجية — أدخل المعرّف مرة واحدة وأعد استخدامه.',
+ ],
'youtube' => [
'settings' => 'إعدادات YouTube',
'posting_to' => 'النشر في',
@@ -149,6 +162,18 @@
'description_placeholder' => 'الوصف الكامل للفيديو: معلومات وروابط ووسوم…',
'description_hint' => 'حتى 5000 حرف، والروابط قابلة للنقر. إذا تُرك فارغًا يُستخدم نص المنشور.',
'description_max' => 'لا يمكن أن يتجاوز الوصف 5000 حرف.',
+ 'title' => 'العنوان',
+ 'title_placeholder' => 'عنوان الفيديو (بدل السطر الأول من المنشور)',
+ 'title_hint' => 'حتى 100 حرف؛ يُضاف " #Shorts" تلقائيًا عند اشتقاقه من النص.',
+ 'tags' => 'الوسوم',
+ 'tags_placeholder' => 'brisbane, pixel art, map',
+ 'tags_hint' => 'مفصولة بفواصل؛ تساعد البحث والتوصيات.',
+ 'category' => 'معرّف الفئة',
+ 'language' => 'اللغة',
+ 'location' => 'مكان التصوير',
+ 'location_placeholder' => 'Brisbane QLD',
+ 'lat' => 'خط العرض',
+ 'lng' => 'خط الطول',
],
'first_comment' => [
'label' => 'التعليق الأول',
diff --git a/lang/de/posts.php b/lang/de/posts.php
index 5f7117e78..055b0b42a 100644
--- a/lang/de/posts.php
+++ b/lang/de/posts.php
@@ -144,6 +144,19 @@
'document_title' => 'Dokumenttitel',
'document_title_placeholder' => 'Wird bei deinem PDF-Dokument-Beitrag angezeigt',
],
+ 'custom_caption' => [
+ 'label' => 'Eigener Text für :platform',
+ 'active' => 'an',
+ 'hint' => 'Leer lassen, um den gemeinsamen Beitragstext zu verwenden.',
+ 'placeholder' => 'Text nur für diese Plattform…',
+ 'over_limit' => 'Länger als das Limit dieser Plattform (:limit Zeichen).',
+ ],
+ 'location' => [
+ 'label' => 'Standort',
+ 'id_placeholder' => 'Orts-ID',
+ 'name_placeholder' => 'Name (zur Anzeige)',
+ 'hint' => 'Facebook-Orts-ID; die Ortssuche ist für Dritt-Apps geschlossen — ID einmal ermitteln und wiederverwenden.',
+ ],
'youtube' => [
'settings' => 'YouTube-Einstellungen',
'posting_to' => 'Veröffentlichen auf',
@@ -151,6 +164,18 @@
'description_placeholder' => 'Vollständige Videobeschreibung: Fakten, Links, Hashtags…',
'description_hint' => 'Bis zu 5000 Zeichen, Links sind klickbar. Wenn leer, wird der Beitragstext verwendet.',
'description_max' => 'Die Beschreibung darf 5000 Zeichen nicht überschreiten.',
+ 'title' => 'Titel',
+ 'title_placeholder' => 'Videotitel (statt der ersten Zeile des Beitrags)',
+ 'title_hint' => 'Bis zu 100 Zeichen; „ #Shorts" wird automatisch angehängt, wenn der Titel aus dem Text stammt.',
+ 'tags' => 'Tags',
+ 'tags_placeholder' => 'brisbane, pixel art, stadtkarte',
+ 'tags_hint' => 'Kommagetrennt; hilft Suche und Empfehlungen.',
+ 'category' => 'Kategorie-ID',
+ 'language' => 'Sprache',
+ 'location' => 'Aufnahmeort',
+ 'location_placeholder' => 'Brisbane QLD',
+ 'lat' => 'Breitengrad',
+ 'lng' => 'Längengrad',
],
'first_comment' => [
'label' => 'Erster Kommentar',
diff --git a/lang/el/posts.php b/lang/el/posts.php
index bf3618356..987e7a90a 100644
--- a/lang/el/posts.php
+++ b/lang/el/posts.php
@@ -142,6 +142,19 @@
'document_title' => 'Τίτλος εγγράφου',
'document_title_placeholder' => 'Εμφανίζεται στη δημοσίευση εγγράφου PDF σας',
],
+ 'custom_caption' => [
+ 'label' => 'Ξεχωριστό κείμενο για :platform',
+ 'active' => 'ενεργό',
+ 'hint' => 'Κενό: χρησιμοποιείται το κοινό κείμενο της ανάρτησης.',
+ 'placeholder' => 'Κείμενο μόνο για αυτήν την πλατφόρμα…',
+ 'over_limit' => 'Υπερβαίνει το όριο της πλατφόρμας (:limit χαρακτήρες).',
+ ],
+ 'location' => [
+ 'label' => 'Τοποθεσία',
+ 'id_placeholder' => 'ID τοποθεσίας',
+ 'name_placeholder' => 'Όνομα (εμφάνιση)',
+ 'hint' => 'Facebook place ID· η αναζήτηση τοποθεσιών είναι κλειστή για τρίτους — βρείτε το ID μία φορά και επαναχρησιμοποιήστε το.',
+ ],
'youtube' => [
'settings' => 'Ρυθμίσεις YouTube',
'posting_to' => 'Δημοσίευση σε',
@@ -149,6 +162,18 @@
'description_placeholder' => 'Πλήρης περιγραφή βίντεο: πληροφορίες, σύνδεσμοι, hashtags…',
'description_hint' => 'Έως 5000 χαρακτήρες, οι σύνδεσμοι είναι κλικαρίσιμοι. Αν είναι κενό, χρησιμοποιείται το κείμενο της ανάρτησης.',
'description_max' => 'Η περιγραφή δεν μπορεί να υπερβαίνει τους 5000 χαρακτήρες.',
+ 'title' => 'Τίτλος',
+ 'title_placeholder' => 'Τίτλος βίντεο (αντί της πρώτης γραμμής)',
+ 'title_hint' => 'Έως 100 χαρακτήρες· το " #Shorts" προστίθεται αυτόματα όταν προκύπτει από το κείμενο.',
+ 'tags' => 'Ετικέτες',
+ 'tags_placeholder' => 'brisbane, pixel art, map',
+ 'tags_hint' => 'Χωρισμένες με κόμματα· βοηθούν αναζήτηση και προτάσεις.',
+ 'category' => 'ID κατηγορίας',
+ 'language' => 'Γλώσσα',
+ 'location' => 'Τοποθεσία λήψης',
+ 'location_placeholder' => 'Brisbane QLD',
+ 'lat' => 'Γεωγρ. πλάτος',
+ 'lng' => 'Γεωγρ. μήκος',
],
'first_comment' => [
'label' => 'Πρώτο σχόλιο',
diff --git a/lang/en/posts.php b/lang/en/posts.php
index 539b77a90..c9d8e991f 100644
--- a/lang/en/posts.php
+++ b/lang/en/posts.php
@@ -142,6 +142,19 @@
'document_title' => 'Document title',
'document_title_placeholder' => 'Shown on your PDF document post',
],
+ 'custom_caption' => [
+ 'label' => 'Custom text for :platform',
+ 'active' => 'on',
+ 'hint' => 'Leave empty to use the shared post text.',
+ 'placeholder' => 'Text for this platform only…',
+ 'over_limit' => 'Longer than this platform allows (:limit characters).',
+ ],
+ 'location' => [
+ 'label' => 'Location',
+ 'id_placeholder' => 'Place ID',
+ 'name_placeholder' => 'Name (for display)',
+ 'hint' => 'Facebook place ID; place search is closed to third-party apps, so enter the ID once and reuse it.',
+ ],
'youtube' => [
'settings' => 'YouTube settings',
'posting_to' => 'Posting to',
@@ -149,6 +162,18 @@
'description_placeholder' => 'Full video description: facts, links, hashtags…',
'description_hint' => 'Up to 5000 characters, links are clickable. When empty, the post text is used.',
'description_max' => 'Description may not exceed 5000 characters.',
+ 'title' => 'Title',
+ 'title_placeholder' => 'Video title (instead of the first line of the post)',
+ 'title_hint' => 'Up to 100 characters; " #Shorts" is appended automatically when derived from the post text.',
+ 'tags' => 'Tags',
+ 'tags_placeholder' => 'brisbane, pixel art, city map',
+ 'tags_hint' => 'Comma-separated; helps search and recommendations.',
+ 'category' => 'Category ID',
+ 'language' => 'Language',
+ 'location' => 'Recording location',
+ 'location_placeholder' => 'Brisbane QLD',
+ 'lat' => 'Latitude',
+ 'lng' => 'Longitude',
],
'first_comment' => [
'label' => 'First comment',
diff --git a/lang/es/posts.php b/lang/es/posts.php
index 71a230820..ccf8d99c2 100644
--- a/lang/es/posts.php
+++ b/lang/es/posts.php
@@ -142,6 +142,19 @@
'document_title' => 'Título del documento',
'document_title_placeholder' => 'Se muestra en tu publicación de documento PDF',
],
+ 'custom_caption' => [
+ 'label' => 'Texto propio para :platform',
+ 'active' => 'sí',
+ 'hint' => 'Vacío: se usa el texto común de la publicación.',
+ 'placeholder' => 'Texto solo para esta plataforma…',
+ 'over_limit' => 'Supera el límite de esta plataforma (:limit caracteres).',
+ ],
+ 'location' => [
+ 'label' => 'Ubicación',
+ 'id_placeholder' => 'ID del lugar',
+ 'name_placeholder' => 'Nombre (visual)',
+ 'hint' => 'ID de lugar de Facebook; la búsqueda de lugares está cerrada a terceros: introduce el ID una vez y reutilízalo.',
+ ],
'youtube' => [
'settings' => 'Ajustes de YouTube',
'posting_to' => 'Publicando en',
@@ -149,6 +162,18 @@
'description_placeholder' => 'Descripción completa del vídeo: datos, enlaces, hashtags…',
'description_hint' => 'Hasta 5000 caracteres, los enlaces son clicables. Si está vacío, se usa el texto de la publicación.',
'description_max' => 'La descripción no puede superar los 5000 caracteres.',
+ 'title' => 'Título',
+ 'title_placeholder' => 'Título del vídeo (en lugar de la primera línea)',
+ 'title_hint' => 'Hasta 100 caracteres; " #Shorts" se añade automáticamente si se deriva del texto.',
+ 'tags' => 'Etiquetas',
+ 'tags_placeholder' => 'brisbane, pixel art, mapa',
+ 'tags_hint' => 'Separadas por comas; ayudan a la búsqueda y recomendaciones.',
+ 'category' => 'ID de categoría',
+ 'language' => 'Idioma',
+ 'location' => 'Lugar de grabación',
+ 'location_placeholder' => 'Brisbane QLD',
+ 'lat' => 'Latitud',
+ 'lng' => 'Longitud',
],
'first_comment' => [
'label' => 'Primer comentario',
diff --git a/lang/fr/posts.php b/lang/fr/posts.php
index 6eeeb3e41..5adb99ad1 100644
--- a/lang/fr/posts.php
+++ b/lang/fr/posts.php
@@ -142,6 +142,19 @@
'document_title' => 'Titre du document',
'document_title_placeholder' => 'Affiché sur votre publication de document PDF',
],
+ 'custom_caption' => [
+ 'label' => 'Texte spécifique pour :platform',
+ 'active' => 'actif',
+ 'hint' => 'Vide : le texte commun de la publication est utilisé.',
+ 'placeholder' => 'Texte pour cette plateforme uniquement…',
+ 'over_limit' => 'Dépasse la limite de cette plateforme (:limit caractères).',
+ ],
+ 'location' => [
+ 'label' => 'Lieu',
+ 'id_placeholder' => 'ID du lieu',
+ 'name_placeholder' => 'Nom (affichage)',
+ 'hint' => 'ID de lieu Facebook ; la recherche de lieux est fermée aux apps tierces — saisissez l’ID une fois et réutilisez-le.',
+ ],
'youtube' => [
'settings' => 'Paramètres YouTube',
'posting_to' => 'Publication sur',
@@ -149,6 +162,18 @@
'description_placeholder' => 'Description complète de la vidéo : infos, liens, hashtags…',
'description_hint' => 'Jusqu\'à 5000 caractères, les liens sont cliquables. Si vide, le texte de la publication est utilisé.',
'description_max' => 'La description ne peut pas dépasser 5000 caractères.',
+ 'title' => 'Titre',
+ 'title_placeholder' => 'Titre de la vidéo (au lieu de la première ligne)',
+ 'title_hint' => 'Jusqu’à 100 caractères ; « #Shorts » est ajouté automatiquement s’il provient du texte.',
+ 'tags' => 'Tags',
+ 'tags_placeholder' => 'brisbane, pixel art, carte',
+ 'tags_hint' => 'Séparés par des virgules ; aide la recherche et les recommandations.',
+ 'category' => 'ID de catégorie',
+ 'language' => 'Langue',
+ 'location' => 'Lieu de tournage',
+ 'location_placeholder' => 'Brisbane QLD',
+ 'lat' => 'Latitude',
+ 'lng' => 'Longitude',
],
'first_comment' => [
'label' => 'Premier commentaire',
diff --git a/lang/it/posts.php b/lang/it/posts.php
index e8e40d8b9..a13845661 100644
--- a/lang/it/posts.php
+++ b/lang/it/posts.php
@@ -142,6 +142,19 @@
'document_title' => 'Titolo del documento',
'document_title_placeholder' => 'Mostrato sul tuo post con documento PDF',
],
+ 'custom_caption' => [
+ 'label' => 'Testo dedicato per :platform',
+ 'active' => 'attivo',
+ 'hint' => 'Vuoto: viene usato il testo comune del post.',
+ 'placeholder' => 'Testo solo per questa piattaforma…',
+ 'over_limit' => 'Supera il limite di questa piattaforma (:limit caratteri).',
+ ],
+ 'location' => [
+ 'label' => 'Posizione',
+ 'id_placeholder' => 'ID del luogo',
+ 'name_placeholder' => 'Nome (visuale)',
+ 'hint' => 'ID luogo di Facebook; la ricerca dei luoghi è chiusa alle app di terzi: inserisci l’ID una volta e riusalo.',
+ ],
'youtube' => [
'settings' => 'Impostazioni YouTube',
'posting_to' => 'Pubblicazione su',
@@ -149,6 +162,18 @@
'description_placeholder' => 'Descrizione completa del video: dettagli, link, hashtag…',
'description_hint' => 'Fino a 5000 caratteri, i link sono cliccabili. Se vuoto, viene usato il testo del post.',
'description_max' => 'La descrizione non può superare i 5000 caratteri.',
+ 'title' => 'Titolo',
+ 'title_placeholder' => 'Titolo del video (invece della prima riga)',
+ 'title_hint' => 'Fino a 100 caratteri; " #Shorts" viene aggiunto automaticamente se derivato dal testo.',
+ 'tags' => 'Tag',
+ 'tags_placeholder' => 'brisbane, pixel art, mappa',
+ 'tags_hint' => 'Separati da virgole; aiutano ricerca e consigli.',
+ 'category' => 'ID categoria',
+ 'language' => 'Lingua',
+ 'location' => 'Luogo di ripresa',
+ 'location_placeholder' => 'Brisbane QLD',
+ 'lat' => 'Latitudine',
+ 'lng' => 'Longitudine',
],
'first_comment' => [
'label' => 'Primo commento',
diff --git a/lang/ja/posts.php b/lang/ja/posts.php
index 27dfb3a98..2de9d58fb 100644
--- a/lang/ja/posts.php
+++ b/lang/ja/posts.php
@@ -142,6 +142,19 @@
'document_title' => 'ドキュメントタイトル',
'document_title_placeholder' => 'PDF ドキュメント投稿に表示されます',
],
+ 'custom_caption' => [
+ 'label' => ':platform 専用テキスト',
+ 'active' => '有効',
+ 'hint' => '空の場合は投稿の共通テキストが使われます。',
+ 'placeholder' => 'このプラットフォームだけのテキスト…',
+ 'over_limit' => 'このプラットフォームの上限(:limit文字)を超えています。',
+ ],
+ 'location' => [
+ 'label' => '位置情報',
+ 'id_placeholder' => 'プレイスID',
+ 'name_placeholder' => '名称(表示用)',
+ 'hint' => 'FacebookのプレイスID。場所検索はサードパーティに閉鎖されているため、IDを一度調べて再利用してください。',
+ ],
'youtube' => [
'settings' => 'YouTube設定',
'posting_to' => '投稿先',
@@ -149,6 +162,18 @@
'description_placeholder' => '動画の詳細説明:情報、リンク、ハッシュタグなど…',
'description_hint' => '最大5000文字、リンクはクリック可能です。空の場合は投稿テキストが使われます。',
'description_max' => '説明は5000文字を超えられません。',
+ 'title' => 'タイトル',
+ 'title_placeholder' => '動画タイトル(投稿の1行目の代わり)',
+ 'title_hint' => '最大100文字。本文から生成される場合は「 #Shorts」が自動で付きます。',
+ 'tags' => 'タグ',
+ 'tags_placeholder' => 'brisbane, pixel art, map',
+ 'tags_hint' => 'カンマ区切り。検索とおすすめに役立ちます。',
+ 'category' => 'カテゴリID',
+ 'language' => '言語',
+ 'location' => '撮影場所',
+ 'location_placeholder' => 'Brisbane QLD',
+ 'lat' => '緯度',
+ 'lng' => '経度',
],
'first_comment' => [
'label' => '最初のコメント',
diff --git a/lang/ko/posts.php b/lang/ko/posts.php
index 0af8502ee..8786ce2bb 100644
--- a/lang/ko/posts.php
+++ b/lang/ko/posts.php
@@ -142,6 +142,19 @@
'document_title' => '문서 제목',
'document_title_placeholder' => 'PDF 문서 게시물에 표시됩니다',
],
+ 'custom_caption' => [
+ 'label' => ':platform 전용 텍스트',
+ 'active' => '사용',
+ 'hint' => '비워두면 게시물의 공통 텍스트가 사용됩니다.',
+ 'placeholder' => '이 플랫폼만을 위한 텍스트…',
+ 'over_limit' => '이 플랫폼의 한도(:limit자)를 초과했습니다.',
+ ],
+ 'location' => [
+ 'label' => '위치',
+ 'id_placeholder' => '장소 ID',
+ 'name_placeholder' => '이름(표시용)',
+ 'hint' => 'Facebook 장소 ID. 장소 검색은 서드파티에 닫혀 있으므로 ID를 한 번 찾아 재사용하세요.',
+ ],
'youtube' => [
'settings' => 'YouTube 설정',
'posting_to' => '게시 위치',
@@ -149,6 +162,18 @@
'description_placeholder' => '동영상 전체 설명: 정보, 링크, 해시태그…',
'description_hint' => '최대 5000자, 링크는 클릭 가능합니다. 비워두면 게시물 텍스트가 사용됩니다.',
'description_max' => '설명은 5000자를 초과할 수 없습니다.',
+ 'title' => '제목',
+ 'title_placeholder' => '동영상 제목(게시물 첫 줄 대신)',
+ 'title_hint' => '최대 100자. 본문에서 생성되면 " #Shorts"가 자동으로 붙습니다.',
+ 'tags' => '태그',
+ 'tags_placeholder' => 'brisbane, pixel art, map',
+ 'tags_hint' => '쉼표로 구분. 검색과 추천에 도움이 됩니다.',
+ 'category' => '카테고리 ID',
+ 'language' => '언어',
+ 'location' => '촬영 위치',
+ 'location_placeholder' => 'Brisbane QLD',
+ 'lat' => '위도',
+ 'lng' => '경도',
],
'first_comment' => [
'label' => '첫 댓글',
diff --git a/lang/nl/posts.php b/lang/nl/posts.php
index 5316d63e8..2b6ef71d6 100644
--- a/lang/nl/posts.php
+++ b/lang/nl/posts.php
@@ -142,6 +142,19 @@
'document_title' => 'Documenttitel',
'document_title_placeholder' => 'Getoond op je PDF-documentpost',
],
+ 'custom_caption' => [
+ 'label' => 'Eigen tekst voor :platform',
+ 'active' => 'aan',
+ 'hint' => 'Leeg: de gedeelde berichttekst wordt gebruikt.',
+ 'placeholder' => 'Tekst alleen voor dit platform…',
+ 'over_limit' => 'Langer dan de limiet van dit platform (:limit tekens).',
+ ],
+ 'location' => [
+ 'label' => 'Locatie',
+ 'id_placeholder' => 'Plaats-ID',
+ 'name_placeholder' => 'Naam (weergave)',
+ 'hint' => 'Facebook-plaats-ID; plaats zoeken is gesloten voor externe apps — voer de ID één keer in en hergebruik.',
+ ],
'youtube' => [
'settings' => 'YouTube-instellingen',
'posting_to' => 'Publiceren op',
@@ -149,6 +162,18 @@
'description_placeholder' => 'Volledige videobeschrijving: feiten, links, hashtags…',
'description_hint' => 'Tot 5000 tekens, links zijn klikbaar. Indien leeg wordt de berichttekst gebruikt.',
'description_max' => 'De beschrijving mag niet langer zijn dan 5000 tekens.',
+ 'title' => 'Titel',
+ 'title_placeholder' => 'Videotitel (in plaats van de eerste regel)',
+ 'title_hint' => 'Tot 100 tekens; " #Shorts" wordt automatisch toegevoegd als de titel uit de tekst komt.',
+ 'tags' => 'Tags',
+ 'tags_placeholder' => 'brisbane, pixel art, kaart',
+ 'tags_hint' => 'Kommagescheiden; helpt zoeken en aanbevelingen.',
+ 'category' => 'Categorie-ID',
+ 'language' => 'Taal',
+ 'location' => 'Opnamelocatie',
+ 'location_placeholder' => 'Brisbane QLD',
+ 'lat' => 'Breedtegraad',
+ 'lng' => 'Lengtegraad',
],
'first_comment' => [
'label' => 'Eerste reactie',
diff --git a/lang/pl/posts.php b/lang/pl/posts.php
index d4b89a039..1334b857a 100644
--- a/lang/pl/posts.php
+++ b/lang/pl/posts.php
@@ -142,6 +142,19 @@
'document_title' => 'Tytuł dokumentu',
'document_title_placeholder' => 'Wyświetlany w Twoim poście z dokumentem PDF',
],
+ 'custom_caption' => [
+ 'label' => 'Własny tekst dla :platform',
+ 'active' => 'wł.',
+ 'hint' => 'Puste — używany jest wspólny tekst posta.',
+ 'placeholder' => 'Tekst tylko dla tej platformy…',
+ 'over_limit' => 'Przekracza limit tej platformy (:limit znaków).',
+ ],
+ 'location' => [
+ 'label' => 'Lokalizacja',
+ 'id_placeholder' => 'ID miejsca',
+ 'name_placeholder' => 'Nazwa (podgląd)',
+ 'hint' => 'ID miejsca Facebooka; wyszukiwanie miejsc jest zamknięte dla aplikacji zewnętrznych — wpisz ID raz i używaj ponownie.',
+ ],
'youtube' => [
'settings' => 'Ustawienia YouTube',
'posting_to' => 'Publikowanie na',
@@ -149,6 +162,18 @@
'description_placeholder' => 'Pełny opis filmu: informacje, linki, hasztagi…',
'description_hint' => 'Do 5000 znaków, linki są klikalne. Gdy pusto, używany jest tekst posta.',
'description_max' => 'Opis nie może przekraczać 5000 znaków.',
+ 'title' => 'Tytuł',
+ 'title_placeholder' => 'Tytuł filmu (zamiast pierwszej linii posta)',
+ 'title_hint' => 'Do 100 znaków; „ #Shorts" dodawane automatycznie, gdy tytuł pochodzi z tekstu.',
+ 'tags' => 'Tagi',
+ 'tags_placeholder' => 'brisbane, pixel art, mapa',
+ 'tags_hint' => 'Rozdzielone przecinkami; pomagają wyszukiwarce i rekomendacjom.',
+ 'category' => 'ID kategorii',
+ 'language' => 'Język',
+ 'location' => 'Miejsce nagrania',
+ 'location_placeholder' => 'Brisbane QLD',
+ 'lat' => 'Szerokość',
+ 'lng' => 'Długość',
],
'first_comment' => [
'label' => 'Pierwszy komentarz',
diff --git a/lang/pt-BR/posts.php b/lang/pt-BR/posts.php
index 15ec6852b..f2a1eeb2c 100644
--- a/lang/pt-BR/posts.php
+++ b/lang/pt-BR/posts.php
@@ -142,6 +142,19 @@
'document_title' => 'Título do documento',
'document_title_placeholder' => 'Aparece no seu post de documento PDF',
],
+ 'custom_caption' => [
+ 'label' => 'Texto próprio para :platform',
+ 'active' => 'ativo',
+ 'hint' => 'Vazio: o texto comum da publicação é usado.',
+ 'placeholder' => 'Texto apenas para esta plataforma…',
+ 'over_limit' => 'Excede o limite desta plataforma (:limit caracteres).',
+ ],
+ 'location' => [
+ 'label' => 'Localização',
+ 'id_placeholder' => 'ID do lugar',
+ 'name_placeholder' => 'Nome (exibição)',
+ 'hint' => 'ID de lugar do Facebook; a busca de lugares está fechada para terceiros — informe o ID uma vez e reutilize.',
+ ],
'youtube' => [
'settings' => 'Configurações do YouTube',
'posting_to' => 'Publicando em',
@@ -149,6 +162,18 @@
'description_placeholder' => 'Descrição completa do vídeo: informações, links, hashtags…',
'description_hint' => 'Até 5000 caracteres, os links são clicáveis. Se vazio, o texto da publicação é usado.',
'description_max' => 'A descrição não pode exceder 5000 caracteres.',
+ 'title' => 'Título',
+ 'title_placeholder' => 'Título do vídeo (em vez da primeira linha)',
+ 'title_hint' => 'Até 100 caracteres; " #Shorts" é adicionado automaticamente quando derivado do texto.',
+ 'tags' => 'Tags',
+ 'tags_placeholder' => 'brisbane, pixel art, mapa',
+ 'tags_hint' => 'Separadas por vírgulas; ajudam busca e recomendações.',
+ 'category' => 'ID da categoria',
+ 'language' => 'Idioma',
+ 'location' => 'Local de gravação',
+ 'location_placeholder' => 'Brisbane QLD',
+ 'lat' => 'Latitude',
+ 'lng' => 'Longitude',
],
'first_comment' => [
'label' => 'Primeiro comentário',
diff --git a/lang/ru/posts.php b/lang/ru/posts.php
index abe0d4be7..6d3e0e277 100644
--- a/lang/ru/posts.php
+++ b/lang/ru/posts.php
@@ -142,6 +142,19 @@
'document_title' => 'Название документа',
'document_title_placeholder' => 'Отображается в вашем посте с PDF-документом',
],
+ 'custom_caption' => [
+ 'label' => 'Свой текст для :platform',
+ 'active' => 'вкл',
+ 'hint' => 'Пусто — используется общий текст поста.',
+ 'placeholder' => 'Текст только для этой платформы…',
+ 'over_limit' => 'Длиннее лимита платформы (:limit символов).',
+ ],
+ 'location' => [
+ 'label' => 'Локация',
+ 'id_placeholder' => 'ID места',
+ 'name_placeholder' => 'Название (для себя)',
+ 'hint' => 'ID места Facebook; поиск мест закрыт для сторонних приложений — найдите ID один раз и переиспользуйте.',
+ ],
'youtube' => [
'settings' => 'Настройки YouTube',
'posting_to' => 'Публикация в',
@@ -149,6 +162,18 @@
'description_placeholder' => 'Полное описание ролика: факты, ссылки, хэштеги…',
'description_hint' => 'До 5000 знаков, ссылки кликабельны. Если пусто — используется текст поста.',
'description_max' => 'Описание не может быть длиннее 5000 знаков.',
+ 'title' => 'Заголовок',
+ 'title_placeholder' => 'Заголовок ролика (вместо первой строки поста)',
+ 'title_hint' => 'До 100 символов; « #Shorts» добавляется автоматически, если заголовок берётся из текста поста.',
+ 'tags' => 'Теги',
+ 'tags_placeholder' => 'брисбен, пиксель-арт, карта города',
+ 'tags_hint' => 'Через запятую; помогают поиску и рекомендациям.',
+ 'category' => 'ID категории',
+ 'language' => 'Язык',
+ 'location' => 'Место съёмки',
+ 'location_placeholder' => 'Brisbane QLD',
+ 'lat' => 'Широта',
+ 'lng' => 'Долгота',
],
'first_comment' => [
'label' => 'Первый комментарий',
diff --git a/lang/tr/posts.php b/lang/tr/posts.php
index a7d8387ab..dd1048f55 100644
--- a/lang/tr/posts.php
+++ b/lang/tr/posts.php
@@ -144,6 +144,19 @@
'document_title' => 'Belge başlığı',
'document_title_placeholder' => 'PDF belge gönderinizde gösterilir',
],
+ 'custom_caption' => [
+ 'label' => ':platform için özel metin',
+ 'active' => 'açık',
+ 'hint' => 'Boşsa gönderinin ortak metni kullanılır.',
+ 'placeholder' => 'Yalnızca bu platform için metin…',
+ 'over_limit' => 'Bu platformun sınırını aşıyor (:limit karakter).',
+ ],
+ 'location' => [
+ 'label' => 'Konum',
+ 'id_placeholder' => 'Yer ID',
+ 'name_placeholder' => 'Ad (görünüm)',
+ 'hint' => 'Facebook yer ID’si; yer arama üçüncü taraflara kapalı — ID’yi bir kez bulun ve yeniden kullanın.',
+ ],
'youtube' => [
'settings' => 'YouTube ayarları',
'posting_to' => 'Şurada yayınlanıyor',
@@ -151,6 +164,18 @@
'description_placeholder' => 'Videonun tam açıklaması: bilgiler, bağlantılar, etiketler…',
'description_hint' => 'En fazla 5000 karakter, bağlantılar tıklanabilir. Boşsa gönderi metni kullanılır.',
'description_max' => 'Açıklama 5000 karakteri aşamaz.',
+ 'title' => 'Başlık',
+ 'title_placeholder' => 'Video başlığı (gönderinin ilk satırı yerine)',
+ 'title_hint' => 'En fazla 100 karakter; metinden türetildiğinde " #Shorts" otomatik eklenir.',
+ 'tags' => 'Etiketler',
+ 'tags_placeholder' => 'brisbane, pixel art, harita',
+ 'tags_hint' => 'Virgülle ayrılır; arama ve önerilere yardımcı olur.',
+ 'category' => 'Kategori ID',
+ 'language' => 'Dil',
+ 'location' => 'Çekim yeri',
+ 'location_placeholder' => 'Brisbane QLD',
+ 'lat' => 'Enlem',
+ 'lng' => 'Boylam',
],
'first_comment' => [
'label' => 'İlk yorum',
diff --git a/lang/uk/posts.php b/lang/uk/posts.php
index 363233c8e..a6e775d16 100644
--- a/lang/uk/posts.php
+++ b/lang/uk/posts.php
@@ -142,6 +142,19 @@
'document_title' => 'Назва документа',
'document_title_placeholder' => 'Відображається у вашому пості з PDF-документом',
],
+ 'custom_caption' => [
+ 'label' => 'Свій текст для :platform',
+ 'active' => 'увімк',
+ 'hint' => 'Порожньо — використовується спільний текст поста.',
+ 'placeholder' => 'Текст лише для цієї платформи…',
+ 'over_limit' => 'Довше за ліміт платформи (:limit символів).',
+ ],
+ 'location' => [
+ 'label' => 'Локація',
+ 'id_placeholder' => 'ID місця',
+ 'name_placeholder' => 'Назва (для себе)',
+ 'hint' => 'ID місця Facebook; пошук місць закритий для сторонніх застосунків — знайдіть ID один раз і повторно використовуйте.',
+ ],
'youtube' => [
'settings' => 'Налаштування YouTube',
'posting_to' => 'Публікація в',
@@ -149,6 +162,18 @@
'description_placeholder' => 'Повний опис відео: факти, посилання, хештеги…',
'description_hint' => 'До 5000 знаків, посилання клікабельні. Якщо порожньо — використовується текст поста.',
'description_max' => 'Опис не може перевищувати 5000 знаків.',
+ 'title' => 'Заголовок',
+ 'title_placeholder' => 'Заголовок відео (замість першого рядка поста)',
+ 'title_hint' => 'До 100 символів; « #Shorts» додається автоматично, якщо береться з тексту поста.',
+ 'tags' => 'Теги',
+ 'tags_placeholder' => 'брисбен, піксель-арт, мапа міста',
+ 'tags_hint' => 'Через кому; допомагають пошуку та рекомендаціям.',
+ 'category' => 'ID категорії',
+ 'language' => 'Мова',
+ 'location' => 'Місце зйомки',
+ 'location_placeholder' => 'Brisbane QLD',
+ 'lat' => 'Широта',
+ 'lng' => 'Довгота',
],
'first_comment' => [
'label' => 'Перший коментар',
diff --git a/lang/zh/posts.php b/lang/zh/posts.php
index e5383d7f6..03fa8cafc 100644
--- a/lang/zh/posts.php
+++ b/lang/zh/posts.php
@@ -142,6 +142,19 @@
'document_title' => '文档标题',
'document_title_placeholder' => '显示在你的 PDF 文档帖子上',
],
+ 'custom_caption' => [
+ 'label' => '为 :platform 定制文本',
+ 'active' => '启用',
+ 'hint' => '留空则使用帖子共用文本。',
+ 'placeholder' => '仅用于此平台的文本…',
+ 'over_limit' => '超过该平台上限(:limit 个字符)。',
+ ],
+ 'location' => [
+ 'label' => '位置',
+ 'id_placeholder' => '地点 ID',
+ 'name_placeholder' => '名称(展示用)',
+ 'hint' => 'Facebook 地点 ID;地点搜索已对第三方关闭,请查一次 ID 后重复使用。',
+ ],
'youtube' => [
'settings' => 'YouTube 设置',
'posting_to' => '发布到',
@@ -149,6 +162,18 @@
'description_placeholder' => '视频完整描述:信息、链接、话题标签…',
'description_hint' => '最多 5000 个字符,链接可点击。留空则使用帖子文本。',
'description_max' => '描述不能超过 5000 个字符。',
+ 'title' => '标题',
+ 'title_placeholder' => '视频标题(代替帖子的第一行)',
+ 'title_hint' => '最多 100 个字符;由正文生成时会自动附加" #Shorts"。',
+ 'tags' => '标签',
+ 'tags_placeholder' => 'brisbane, pixel art, map',
+ 'tags_hint' => '用逗号分隔;有助于搜索和推荐。',
+ 'category' => '类别 ID',
+ 'language' => '语言',
+ 'location' => '拍摄地点',
+ 'location_placeholder' => 'Brisbane QLD',
+ 'lat' => '纬度',
+ 'lng' => '经度',
],
'first_comment' => [
'label' => '首条评论',
diff --git a/resources/js/components/ChannelConfigurator.vue b/resources/js/components/ChannelConfigurator.vue
index 95c69f1df..acee57792 100644
--- a/resources/js/components/ChannelConfigurator.vue
+++ b/resources/js/components/ChannelConfigurator.vue
@@ -2,6 +2,7 @@
import { IconAlertCircle, IconCircleCheck } from '@tabler/icons-vue';
import { computed } from 'vue';
+import ChannelCaptionOverride from '@/components/posts/editor/ChannelCaptionOverride.vue';
import DiscordSettings from '@/components/posts/editor/DiscordSettings.vue';
import FacebookSettings from '@/components/posts/editor/FacebookSettings.vue';
import InstagramSettings from '@/components/posts/editor/InstagramSettings.vue';
@@ -188,6 +189,12 @@ const selectedChannels = computed(() => props.channels.filter((channel) => isSel
:preview-only="previewOnly"
@update:meta="emit('update:meta', channel.id, $event)"
/>
+
diff --git a/resources/js/components/posts/editor/ChannelCaptionOverride.vue b/resources/js/components/posts/editor/ChannelCaptionOverride.vue
new file mode 100644
index 000000000..f8e866e85
--- /dev/null
+++ b/resources/js/components/posts/editor/ChannelCaptionOverride.vue
@@ -0,0 +1,71 @@
+
+
+
+
+
+
+
+
+
{{ $t('posts.form.custom_caption.hint') }}
+
+ {{ override.length }}/{{ limit }}
+
+
+
+
+ {{ $t('posts.form.custom_caption.over_limit', { limit: limit ?? 0 }) }}
+
+
+
+
diff --git a/resources/js/components/posts/editor/FacebookSettings.vue b/resources/js/components/posts/editor/FacebookSettings.vue
index 9d76b8637..0d07f5b97 100644
--- a/resources/js/components/posts/editor/FacebookSettings.vue
+++ b/resources/js/components/posts/editor/FacebookSettings.vue
@@ -3,6 +3,7 @@ import { IconAlertTriangle, IconChevronDown, IconChevronUp } from '@tabler/icons
import { computed, ref, watch } from 'vue';
import { Avatar } from '@/components/ui/avatar';
+import { Input } from '@/components/ui/input';
import { getMediaValidationWarning } from '@/composables/useMedia';
import { getPlatformLogo } from '@/composables/usePlatformLogo';
import { fallbackImageCapableVariant, filterImageCapableVariants } from '@/lib/aiGenerateVariants';
@@ -69,6 +70,16 @@ const aspectRatios = [
const isFeed = computed(() => props.contentType === ContentType.FacebookPost);
const selectedAspectRatio = computed(() => props.meta.aspect_ratio ?? 'original');
+const locationId = computed({
+ get: () => (props.meta?.location_id as string | undefined) || '',
+ set: (value: string) => emit('update:meta', { ...props.meta, location_id: value.trim() || null }),
+});
+
+const locationName = computed({
+ get: () => (props.meta?.location_name as string | undefined) || '',
+ set: (value: string) => emit('update:meta', { ...props.meta, location_name: value || null }),
+});
+
const pickVariant = (value: string) => {
if (props.disabled) return;
emit('update:contentType', value);
@@ -154,6 +165,15 @@ const warning = computed(() => getMediaValidationWarning(props.contentType, prop
+
+
{{ $t('posts.form.location.label') }}
+
+
+
+
+
{{ $t('posts.form.location.hint') }}
+
+
{
const FIRST_COMMENT_MAX = 2200;
+const locationId = computed({
+ get: () => (props.meta?.location_id as string | undefined) || '',
+ set: (value: string) => emit('update:meta', { ...props.meta, location_id: value.trim() || null }),
+});
+
+const locationName = computed({
+ get: () => (props.meta?.location_name as string | undefined) || '',
+ set: (value: string) => emit('update:meta', { ...props.meta, location_name: value || null }),
+});
+
const firstComment = computed({
get: () => (props.meta?.first_comment as string | undefined) || '',
set: (value: string) => emit('update:meta', { ...props.meta, first_comment: value || null }),
@@ -162,6 +173,15 @@ const warning = computed(() => getMediaValidationWarning(props.contentType, prop
+
+
{{ $t('posts.form.location.label') }}
+
+
+
+
+
{{ $t('posts.form.location.hint') }}
+
+
{{ $t('posts.form.first_comment.label') }}
diff --git a/resources/js/components/posts/editor/PostEditorComposer.vue b/resources/js/components/posts/editor/PostEditorComposer.vue
index f30f5b3ac..09ea0a942 100644
--- a/resources/js/components/posts/editor/PostEditorComposer.vue
+++ b/resources/js/components/posts/editor/PostEditorComposer.vue
@@ -84,7 +84,7 @@ const openPreview = (item: MediaItem) => {
media.value.map((m) => ({
url: m.url,
type: classify(m) ?? MediaType.Image,
- altText: isImage(m) ? m.meta?.alt_text : undefined,
+ altText: isImage(m) || isVideo(m) ? m.meta?.alt_text : undefined,
})),
idx,
);
@@ -304,7 +304,7 @@ const onAltTextSave = (alt: string): void => {
{{ date.formatClock(item.meta.duration) }}
@@ -350,7 +350,7 @@ const onAltTextSave = (alt: string): void => {
+
+
+
{{ $t('posts.form.youtube.title') }}
+
{{ title.length }}/{{ TITLE_MAX }}
+
+
+
{{ $t('posts.form.youtube.title_hint') }}
+
+
+
+
{{ $t('posts.form.youtube.tags') }}
+
+
{{ $t('posts.form.youtube.tags_hint') }}
+
+
+
+
+
{{ $t('posts.form.youtube.category') }}
+
+
+
+
{{ $t('posts.form.youtube.language') }}
+
+
+
+
+
+
{{ $t('posts.form.first_comment.label') }}
diff --git a/resources/js/types/channel.ts b/resources/js/types/channel.ts
index 8bbed59ed..a4296d6c4 100644
--- a/resources/js/types/channel.ts
+++ b/resources/js/types/channel.ts
@@ -42,4 +42,5 @@ export interface Channel {
creatorInfo?: ChannelTikTokCreatorInfo | null;
boards?: PinterestBoard[];
boardsTruncated?: boolean;
+ contentLimit?: number | null;
}
diff --git a/tests/Feature/Api/PostApiPlatformMetaTest.php b/tests/Feature/Api/PostApiPlatformMetaTest.php
index 0f4fa2700..4791cb370 100644
--- a/tests/Feature/Api/PostApiPlatformMetaTest.php
+++ b/tests/Feature/Api/PostApiPlatformMetaTest.php
@@ -328,6 +328,77 @@
->assertJsonValidationErrors(['platforms.0.meta.description']);
});
+it('persists YouTube SEO meta and recording location on store', function () {
+ $youtube = SocialAccount::factory()->create(['workspace_id' => $this->workspace->id, 'platform' => Platform::YouTube]);
+
+ $this->withHeaders($this->headers)
+ ->postJson(route('api.posts.store'), [
+ 'content' => 'Short title text',
+ 'platforms' => [[
+ 'social_account_id' => $youtube->id,
+ 'content_type' => ContentType::YouTubeShort->value,
+ 'meta' => [
+ 'title' => 'Pixel Brisbane — Story Bridge',
+ 'tags' => ['brisbane', 'pixel art'],
+ 'category_id' => '19',
+ 'default_language' => 'en',
+ 'recording_location' => ['lat' => -27.4679, 'lng' => 153.0281, 'description' => 'Brisbane QLD'],
+ ],
+ ]],
+ ])
+ ->assertCreated();
+
+ $meta = PostPlatform::where('social_account_id', $youtube->id)->sole()->meta;
+
+ expect(data_get($meta, 'title'))->toBe('Pixel Brisbane — Story Bridge')
+ ->and(data_get($meta, 'tags'))->toBe(['brisbane', 'pixel art'])
+ ->and(data_get($meta, 'category_id'))->toBe('19')
+ ->and(data_get($meta, 'default_language'))->toBe('en')
+ ->and(data_get($meta, 'recording_location.lat'))->toBe(-27.4679)
+ ->and(data_get($meta, 'recording_location.description'))->toBe('Brisbane QLD');
+});
+
+it('rejects an out-of-range recording location latitude', function () {
+ $youtube = SocialAccount::factory()->create(['workspace_id' => $this->workspace->id, 'platform' => Platform::YouTube]);
+
+ $this->withHeaders($this->headers)
+ ->postJson(route('api.posts.store'), [
+ 'content' => 'Short title text',
+ 'platforms' => [[
+ 'social_account_id' => $youtube->id,
+ 'content_type' => ContentType::YouTubeShort->value,
+ 'meta' => ['recording_location' => ['lat' => 120, 'lng' => 10]],
+ ]],
+ ])
+ ->assertUnprocessable()
+ ->assertJsonValidationErrors(['platforms.0.meta.recording_location.lat']);
+});
+
+it('persists a per-platform content override and instagram location meta on store', function () {
+ $instagram = SocialAccount::factory()->create(['workspace_id' => $this->workspace->id, 'platform' => Platform::Instagram]);
+
+ $this->withHeaders($this->headers)
+ ->postJson(route('api.posts.store'), [
+ 'content' => 'Shared caption',
+ 'platforms' => [[
+ 'social_account_id' => $instagram->id,
+ 'content_type' => ContentType::InstagramFeed->value,
+ 'meta' => [
+ 'content' => 'Instagram-only caption with #hashtags',
+ 'location_id' => '106190712750250',
+ 'location_name' => 'Brisbane',
+ ],
+ ]],
+ ])
+ ->assertCreated();
+
+ $row = PostPlatform::where('social_account_id', $instagram->id)->sole();
+
+ expect(data_get($row->meta, 'content'))->toBe('Instagram-only caption with #hashtags')
+ ->and(data_get($row->meta, 'location_id'))->toBe('106190712750250')
+ ->and($row->resolvedContent())->toBe('Instagram-only caption with #hashtags');
+});
+
it('persists Pinterest title and link meta on store', function () {
$pinterest = SocialAccount::factory()->create(['workspace_id' => $this->workspace->id, 'platform' => Platform::Pinterest]);
diff --git a/tests/Feature/Services/Social/InstagramPublisherTest.php b/tests/Feature/Services/Social/InstagramPublisherTest.php
index 8715b18b1..ce0f10fef 100644
--- a/tests/Feature/Services/Social/InstagramPublisherTest.php
+++ b/tests/Feature/Services/Social/InstagramPublisherTest.php
@@ -2035,3 +2035,31 @@ function fakeJpegBytes(int $width = 1200, int $height = 800): string
&& ! str_contains($request->url(), 'media_publish')
&& data_get($request->data(), 'caption') === 'New post: https://acme.com/blog');
});
+
+test('instagram publisher tags the container with the meta location', function () {
+ $this->post->update([
+ 'media' => [[
+ 'id' => 'test-media-id',
+ 'path' => 'media/2026-01/test-image.jpg',
+ 'url' => 'https://example.com/media/2026-01/test-image.jpg',
+ 'mime_type' => 'image/jpeg',
+ 'original_filename' => 'test.jpg',
+ ]],
+ ]);
+ $this->postPlatform->update(['meta' => ['location_id' => '106190712750250']]);
+ $this->postPlatform->refresh();
+
+ Http::fake([
+ 'https://graph.instagram.com/v25.0/ig_123456789/media' => Http::response(['id' => 'container-123'], 200),
+ 'https://graph.instagram.com/v25.0/container-123*' => Http::response(['status_code' => 'FINISHED'], 200),
+ 'https://graph.instagram.com/v25.0/ig_123456789/media_publish' => Http::response(['id' => 'media-789'], 200),
+ 'https://graph.instagram.com/v25.0/media-789*' => Http::response(['permalink' => 'https://instagram.com/p/x'], 200),
+ ]);
+
+ $this->publisher->publish($this->postPlatform);
+
+ Http::assertSent(function ($request) {
+ return str_ends_with(parse_url($request->url(), PHP_URL_PATH), '/ig_123456789/media')
+ && ($request['location_id'] ?? null) === '106190712750250';
+ });
+});
diff --git a/tests/Feature/Services/Social/MastodonPublisherTest.php b/tests/Feature/Services/Social/MastodonPublisherTest.php
index 37f527d57..d5b1aeddb 100644
--- a/tests/Feature/Services/Social/MastodonPublisherTest.php
+++ b/tests/Feature/Services/Social/MastodonPublisherTest.php
@@ -580,3 +580,21 @@
Http::assertSent(fn ($request) => str_contains($request->url(), '/api/v1/statuses')
&& $request['status'] === 'New post: https://acme.com/blog');
});
+
+test('mastodon publisher uses the per-platform content override', function () {
+ $this->postPlatform->update(['meta' => ['content' => 'Short Mastodon-only version']]);
+ $this->postPlatform->refresh();
+
+ Http::fake([
+ 'https://mastodon.social/api/v1/statuses' => Http::response([
+ 'id' => '42', 'url' => 'https://mastodon.social/@testuser/42',
+ ], 200),
+ ]);
+
+ $this->publisher->publish($this->postPlatform);
+
+ Http::assertSent(function ($request) {
+ return str_contains($request->url(), '/api/v1/statuses')
+ && $request['status'] === 'Short Mastodon-only version';
+ });
+});
diff --git a/tests/Feature/Services/Social/YouTubePublisherTest.php b/tests/Feature/Services/Social/YouTubePublisherTest.php
index 687275fdf..b84a0a1df 100644
--- a/tests/Feature/Services/Social/YouTubePublisherTest.php
+++ b/tests/Feature/Services/Social/YouTubePublisherTest.php
@@ -219,3 +219,18 @@
$title = $method->invoke($publisher, $newlineContent);
expect($title)->toBe('Title line #Shorts');
});
+
+test('youtube publisher resolves title from platform meta with content fallback', function () {
+ $publisher = new YouTubePublisher;
+ $reflection = new ReflectionClass($publisher);
+ $method = $reflection->getMethod('resolveTitle');
+ $method->setAccessible(true);
+
+ $this->postPlatform->meta = null;
+ expect($method->invoke($publisher, $this->postPlatform, 'My awesome short video'))
+ ->toBe('My awesome short video #Shorts');
+
+ $this->postPlatform->meta = ['title' => 'Exact custom title'];
+ expect($method->invoke($publisher, $this->postPlatform, 'My awesome short video'))
+ ->toBe('Exact custom title');
+});
From 02501ccf012a4ff494a8bd450772f2145a3fe3af Mon Sep 17 00:00:00 2001
From: Grigory Frolov <2168057+gynsus@users.noreply.github.com>
Date: Fri, 21 Aug 2026 15:49:02 +0300
Subject: [PATCH 5/5] test: mastodon video alt is now sent as the media
description
---
tests/Feature/Services/Social/MastodonPublisherTest.php | 9 ++++++---
1 file changed, 6 insertions(+), 3 deletions(-)
diff --git a/tests/Feature/Services/Social/MastodonPublisherTest.php b/tests/Feature/Services/Social/MastodonPublisherTest.php
index d5b1aeddb..2b1d20b6b 100644
--- a/tests/Feature/Services/Social/MastodonPublisherTest.php
+++ b/tests/Feature/Services/Social/MastodonPublisherTest.php
@@ -261,7 +261,7 @@
@unlink($optimizedFile);
});
-test('mastodon publisher does not send a description for a non-image even if it carries alt text', function () {
+test('mastodon publisher sends a video alt text as the media description', function () {
$this->post->update([
'media' => [
[
@@ -270,7 +270,7 @@
'url' => 'https://example.com/media/2026-01/clip.mp4',
'mime_type' => 'video/mp4',
'original_filename' => 'clip.mp4',
- 'meta' => ['alt_text' => 'alt must not be sent for a video'],
+ 'meta' => ['alt_text' => 'A pixel-art flyover of Brisbane'],
],
],
]);
@@ -303,7 +303,10 @@
return false;
}
- return collect($request->data())->firstWhere('name', 'description') === null;
+ // Mastodon accepts descriptions on any media type, video included.
+ $description = collect($request->data())->firstWhere('name', 'description');
+
+ return $description !== null && $description['contents'] === 'A pixel-art flyover of Brisbane';
});
});