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