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, 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..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}]).'), + '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 3f0ae0e62..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. 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/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/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 f3f6e4438..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,8 +94,8 @@ private function publishShort(PostPlatform $postPlatform, $media, SocialAccount ); } - $title = $this->buildTitle($content); - $description = $content; + $title = $this->resolveTitle($postPlatform, $content); + $description = $this->resolveDescription($postPlatform, $content); $tempFile = tempnam(sys_get_temp_dir(), 'yt_upload_'); $handle = null; @@ -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,30 @@ 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), + * 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..e4c1dab64 100644 --- a/app/Support/PostPlatformMetaRules.php +++ b/app/Support/PostPlatformMetaRules.php @@ -59,6 +59,37 @@ 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'], + + // 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'], @@ -90,6 +121,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 +136,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..f1578d03b 100644 --- a/lang/ar/posts.php +++ b/lang/ar/posts.php @@ -142,6 +142,46 @@ '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' => 'النشر في', + 'description' => 'الوصف', + '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' => 'التعليق الأول', + '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..055b0b42a 100644 --- a/lang/de/posts.php +++ b/lang/de/posts.php @@ -144,6 +144,46 @@ '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', + '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.', + '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', + '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..987e7a90a 100644 --- a/lang/el/posts.php +++ b/lang/el/posts.php @@ -142,6 +142,46 @@ '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' => 'Δημοσίευση σε', + 'description' => 'Περιγραφή', + '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' => 'Πρώτο σχόλιο', + '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..c9d8e991f 100644 --- a/lang/en/posts.php +++ b/lang/en/posts.php @@ -142,6 +142,46 @@ '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', + '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.', + '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', + '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..ccf8d99c2 100644 --- a/lang/es/posts.php +++ b/lang/es/posts.php @@ -142,6 +142,46 @@ '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', + '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.', + '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', + '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..5adb99ad1 100644 --- a/lang/fr/posts.php +++ b/lang/fr/posts.php @@ -142,6 +142,46 @@ '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', + '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.', + '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', + '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..a13845661 100644 --- a/lang/it/posts.php +++ b/lang/it/posts.php @@ -142,6 +142,46 @@ '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', + '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.', + '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', + '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..2de9d58fb 100644 --- a/lang/ja/posts.php +++ b/lang/ja/posts.php @@ -142,6 +142,46 @@ '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' => '投稿先', + 'description' => '説明', + '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' => '最初のコメント', + '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..8786ce2bb 100644 --- a/lang/ko/posts.php +++ b/lang/ko/posts.php @@ -142,6 +142,46 @@ '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' => '게시 위치', + 'description' => '설명', + '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' => '첫 댓글', + '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..2b6ef71d6 100644 --- a/lang/nl/posts.php +++ b/lang/nl/posts.php @@ -142,6 +142,46 @@ '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', + '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.', + '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', + '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..1334b857a 100644 --- a/lang/pl/posts.php +++ b/lang/pl/posts.php @@ -142,6 +142,46 @@ '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', + '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.', + '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', + '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..f2a1eeb2c 100644 --- a/lang/pt-BR/posts.php +++ b/lang/pt-BR/posts.php @@ -142,6 +142,46 @@ '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', + '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.', + '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', + '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..6d3e0e277 100644 --- a/lang/ru/posts.php +++ b/lang/ru/posts.php @@ -142,6 +142,46 @@ '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' => 'Публикация в', + 'description' => 'Описание', + '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' => 'Первый комментарий', + '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..dd1048f55 100644 --- a/lang/tr/posts.php +++ b/lang/tr/posts.php @@ -144,6 +144,46 @@ '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', + '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.', + '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', + '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..a6e775d16 100644 --- a/lang/uk/posts.php +++ b/lang/uk/posts.php @@ -142,6 +142,46 @@ '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' => 'Публікація в', + 'description' => 'Опис', + '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' => 'Перший коментар', + '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..03fa8cafc 100644 --- a/lang/zh/posts.php +++ b/lang/zh/posts.php @@ -142,6 +142,46 @@ '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' => '发布到', + 'description' => '描述', + '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' => '首条评论', + '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..acee57792 100644 --- a/resources/js/components/ChannelConfigurator.vue +++ b/resources/js/components/ChannelConfigurator.vue @@ -2,12 +2,14 @@ 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'; 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 +180,21 @@ 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 @@ + + +