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 6f2a2af94..273428054 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 096463a89..066ec7fbf 100644 --- a/app/Jobs/PublishToSocialPlatform.php +++ b/app/Jobs/PublishToSocialPlatform.php @@ -29,6 +29,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\GoogleBusinessDerivativeCleaner; @@ -126,6 +127,15 @@ public function handle(): void $result = $publisher->publish($this->postPlatform); $this->recordPublishResult($result); + + // The post is live; a failed first comment must never fail it — + // FirstCommentPoster logs and swallows its own errors. Posts + // held in pending review (Google Business) get no comment. + if ($this->postPlatform->status === PostPlatformStatus::Published + && ($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 0183b2663..67081ad1b 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 PUBLIC_TO_EVERYONE|MUTUAL_FOLLOW_FRIENDS|FOLLOWER_OF_CREATOR|SELF_ONLY (required to publish) + flags (allow_comments, allow_duet, allow_stitch, disclose, brand_content_toggle, brand_organic_toggle, is_aigc, auto_add_music). SELF_ONLY cannot be combined with brand_content_toggle. 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 Shorts: description (optional plain text, at most 5000 bytes). Post content remains the title source, limited to 100 characters. Omit description or pass null to use the post content as description.'), + 'meta' => $p->object()->description('Per-platform metadata. Instagram/Facebook: aspect_ratio (1:1|4:5|16:9|original). TikTok: privacy_level PUBLIC_TO_EVERYONE|MUTUAL_FOLLOW_FRIENDS|FOLLOWER_OF_CREATOR|SELF_ONLY (required to publish) + flags (allow_comments, allow_duet, allow_stitch, disclose, brand_content_toggle, brand_organic_toggle, is_aigc, auto_add_music). SELF_ONLY cannot be combined with brand_content_toggle. 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 Shorts: description (optional plain text, at most 5000 bytes). Post content remains the title source, limited to 100 characters. Omit description or pass null to use the post content as description. 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, overrides the content-derived title), 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 0b2af698e..7188ef7e5 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 PUBLIC_TO_EVERYONE|MUTUAL_FOLLOW_FRIENDS|FOLLOWER_OF_CREATOR|SELF_ONLY (required to publish) + flags. SELF_ONLY cannot be combined with brand_content_toggle. 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. YouTube Shorts: description (optional plain text, at most 5000 bytes). Post content remains the title source, limited to 100 characters. Omit description to keep the current override; pass null to remove it and use the post content as description.'), + 'meta' => $p->object()->description('Per-platform metadata override. Instagram/Facebook: aspect_ratio. TikTok: privacy_level PUBLIC_TO_EVERYONE|MUTUAL_FOLLOW_FRIENDS|FOLLOWER_OF_CREATOR|SELF_ONLY (required to publish) + flags. SELF_ONLY cannot be combined with brand_content_toggle. 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. YouTube Shorts: description (optional plain text, at most 5000 bytes). Post content remains the title source, limited to 100 characters. Omit description to keep the current override; pass null to remove it and use the post content as description. 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, overrides the content-derived title), tags (string array), category_id, default_language, recording_location {lat,lng,description}.'), ])) ->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 c0b0ffbf4..e94ac9d40 100644 --- a/app/Models/PostPlatform.php +++ b/app/Models/PostPlatform.php @@ -60,6 +60,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 a541df9f8..de4588937 100644 --- a/app/Services/Social/AbstractLinkedInPublisher.php +++ b/app/Services/Social/AbstractLinkedInPublisher.php @@ -76,8 +76,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 e20d5caf6..17c194775 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 44b6a0150..1adca5936 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 c62bc57b6..6e1fc0253 100644 --- a/app/Services/Social/FacebookPublisher.php +++ b/app/Services/Social/FacebookPublisher.php @@ -37,6 +37,12 @@ class FacebookPublisher private string $baseUrl; + /** + * Facebook place ID from the platform row's `meta.location_id` — tags feed + * and photo posts with a location. Stories do not accept it. + */ + private ?string $placeId = null; + public function __construct() { $this->baseUrl = config('trypost.platforms.facebook.graph_api'); @@ -53,6 +59,7 @@ public function publish(PostPlatform $postPlatform): array $pageId = $account->platform_user_id; $accessToken = $account->access_token; $content = $this->sanitizedContent($postPlatform); + $this->placeId = trim((string) data_get($postPlatform->meta, 'location_id')) ?: null; $media = $postPlatform->post->mediaItems; $contentType = $postPlatform->content_type; @@ -149,11 +156,11 @@ private function postTextWithLink(string $pageId, string $accessToken, string $c */ private function postTextToFeed(string $pageId, string $accessToken, string $content, ?string $link, bool $reportFailure = true): Response { - return $this->postToGraph("{$pageId}/feed", [ + return $this->postToGraph("{$pageId}/feed", $this->withPlace([ 'message' => $content, 'access_token' => $accessToken, ...$this->optionalField('link', $link), - ], 'text post', $reportFailure); + ]), 'text post', $reportFailure); } /** @@ -161,12 +168,12 @@ private function postTextToFeed(string $pageId, string $accessToken, string $con */ private function publishSingleImagePost(string $pageId, string $accessToken, ?string $content, MediaItem $media, ?string $aspectRatio): array { - $response = $this->postToGraph("{$pageId}/photos", [ + $response = $this->postToGraph("{$pageId}/photos", $this->withPlace([ 'url' => $this->cropImageForAspectRatio($media->url, $aspectRatio), 'access_token' => $accessToken, ...$this->optionalField('message', $content), ...$this->altText($media), - ], 'single image post'); + ]), 'single image post'); $data = $response->json(); @@ -196,13 +203,13 @@ private function publishMultiImagePost(string $pageId, string $accessToken, ?str ); } - $response = $this->postToGraph("{$pageId}/feed", [ + $response = $this->postToGraph("{$pageId}/feed", $this->withPlace([ 'access_token' => $accessToken, ...$this->optionalField('message', $content), ...$photoIds ->mapWithKeys(fn (string $photoId, int $index): array => ["attached_media[{$index}]" => json_encode(['media_fbid' => $photoId])]) ->all(), - ], 'multi-image post'); + ]), 'multi-image post'); return $this->feedPostResult(data_get($response->json(), 'id')); } @@ -489,7 +496,7 @@ private function requireVideo(?MediaItem $media, string $format): MediaItem private function sanitizedContent(PostPlatform $postPlatform): ?string { - $content = $postPlatform->post->content; + $content = $postPlatform->resolvedContent(); return filled($content) ? app(ContentSanitizer::class)->sanitize($content, $postPlatform->platform) @@ -505,6 +512,20 @@ private function facebookHttp(): PendingRequest return $this->socialHttp()->asForm(); } + + /** + * @param array $payload + * @return array + */ + private function withPlace(array $payload): array + { + if ($this->placeId !== null) { + $payload['place'] = $this->placeId; + } + + return $payload; + } + /** * POST a form payload to a Graph edge and turn any failure into the typed * exception, logging the redacted body under `$label` first. 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 8be62d24b..463f4fe0e 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 ee3a2b2e1..b6831af0d 100644 --- a/app/Services/Social/MastodonPublisher.php +++ b/app/Services/Social/MastodonPublisher.php @@ -24,7 +24,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'); @@ -34,7 +34,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 3cb6df412..65408e7b8 100644 --- a/app/Services/Social/PinterestPublisher.php +++ b/app/Services/Social/PinterestPublisher.php @@ -43,8 +43,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 7692f22cd..61260f929 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 46e1c486b..8e4bf6f28 100644 --- a/app/Services/Social/TikTokPublisher.php +++ b/app/Services/Social/TikTokPublisher.php @@ -47,7 +47,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 f2e7a2f2c..03210786f 100644 --- a/app/Services/Social/XPublisher.php +++ b/app/Services/Social/XPublisher.php @@ -37,7 +37,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 94d110891..514795289 100644 --- a/app/Services/Social/YouTubePublisher.php +++ b/app/Services/Social/YouTubePublisher.php @@ -14,7 +14,9 @@ use Google\Client as GoogleClient; use Google\Service\Exception; use Google\Service\YouTube; +use Google\Service\YouTube\GeoPoint; use Google\Service\YouTube\Video; +use Google\Service\YouTube\VideoRecordingDetails; use Google\Service\YouTube\VideoSnippet; use Google\Service\YouTube\VideoStatus; use Google_Http_MediaFileUpload; @@ -31,8 +33,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; $description = $this->resolveDescription($postPlatform, $content); @@ -60,7 +62,7 @@ public function publish(PostPlatform $postPlatform): array ); } - return $this->publishShort($firstMedia, $account, $content, $description); + return $this->publishShort($postPlatform, $firstMedia, $account, $content, $description); } private function createGoogleClient(SocialAccount $account): GoogleClient @@ -88,7 +90,7 @@ private function createGoogleClient(SocialAccount $account): GoogleClient return $client; } - private function publishShort(MediaItem $media, SocialAccount $account, ?string $content, string $description): array + private function publishShort(PostPlatform $postPlatform, MediaItem $media, SocialAccount $account, ?string $content, string $description): array { if (empty($content)) { throw new YouTubePublishException( @@ -108,7 +110,7 @@ private function publishShort(MediaItem $media, SocialAccount $account, ?string try { $this->downloadVideo($media, $tempFile); - $video = $this->uploadVideo($media, $account, $tempFile, $this->buildVideo($content, $description)); + $video = $this->uploadVideo($media, $account, $tempFile, $this->buildVideo($postPlatform, $content, $description)); $videoId = $video->getId(); return [ @@ -169,7 +171,7 @@ private function uploadVideo(MediaItem $media, SocialAccount $account, string $p $youtube = new YouTube($client); $mediaUpload = new Google_Http_MediaFileUpload( $client, - $youtube->videos->insert('snippet,status', $video), + $youtube->videos->insert($video->getRecordingDetails() !== null ? 'snippet,status,recordingDetails' : 'snippet,status', $video), $media->mime_type ?: 'video/mp4', null, true, @@ -214,12 +216,24 @@ private function resolveDescription(PostPlatform $postPlatform, ?string $content return $description; } - private function buildVideo(string $content, string $description): Video + private function buildVideo(PostPlatform $postPlatform, string $content, string $description): Video { $snippet = new VideoSnippet; - $snippet->setTitle($this->buildTitle($content)); + $snippet->setTitle($this->resolveTitle($postPlatform, $content)); $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'); @@ -229,9 +243,36 @@ private function buildVideo(string $content, string $description): Video $video->setSnippet($snippet); $video->setStatus($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); + } + return $video; } + /** + * 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); + } + private function buildTitle(string $content): string { $maxLength = 100; diff --git a/app/Support/PostPlatformMetaRules.php b/app/Support/PostPlatformMetaRules.php index f7608db84..151958df7 100644 --- a/app/Support/PostPlatformMetaRules.php +++ b/app/Support/PostPlatformMetaRules.php @@ -53,6 +53,32 @@ public static function rules(): array 'platforms.*.meta.brand_content_toggle' => ['sometimes', 'boolean'], 'platforms.*.meta.brand_organic_toggle' => ['sometimes', 'boolean'], + // 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'], @@ -105,6 +131,7 @@ public static function messages(): array 'platforms.*.meta.title.max' => __('posts.form.pinterest.title_max'), 'platforms.*.meta.event.end_date.after_or_equal' => __('posts.form.google_business.event_end_date_before_start'), 'platforms.*.meta.event.title.max' => __('posts.form.google_business.title_max'), + 'platforms.*.meta.first_comment.max' => __('posts.form.first_comment.max'), ]; } @@ -121,6 +148,7 @@ public static function attributes(): array 'platforms.*.meta.link' => __('posts.form.pinterest.link'), 'platforms.*.meta.event.title' => __('posts.form.google_business.event_title'), 'platforms.*.meta.call_to_action.url' => __('posts.form.google_business.cta_url'), + 'platforms.*.meta.first_comment' => __('posts.form.first_comment.label'), ]; } diff --git a/lang/ar/posts.php b/lang/ar/posts.php index 1005d7b03..f69f074bf 100644 --- a/lang/ar/posts.php +++ b/lang/ar/posts.php @@ -142,6 +142,19 @@ 'document_title' => 'عنوان المستند', 'document_title_placeholder' => 'يظهر على منشور مستند PDF الخاص بك', ], + 'custom_caption' => [ + 'label' => 'نص مخصص لـ :platform', + 'active' => 'مفعّل', + 'hint' => 'اتركه فارغًا لاستخدام نص المنشور المشترك.', + 'placeholder' => 'نص لهذه المنصة فقط…', + 'over_limit' => 'يتجاوز حد هذه المنصة (:limit حرفًا).', + ], + 'location' => [ + 'label' => 'الموقع', + 'id_placeholder' => 'معرّف المكان', + 'name_placeholder' => 'الاسم (للعرض)', + 'hint' => 'معرّف مكان فيسبوك؛ البحث عن الأماكن مغلق للتطبيقات الخارجية — أدخل المعرّف مرة واحدة وأعد استخدامه.', + ], 'youtube' => [ 'settings' => 'إعدادات YouTube', 'posting_to' => 'النشر على', @@ -150,6 +163,25 @@ 'description_max' => 'يجب ألا يتجاوز وصف YouTube ‏5000 بايت.', 'description_invalid' => 'يجب أن يكون وصف YouTube نصًا صالحًا.', 'description_bytes' => ':used / :limit بايت', + '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', diff --git a/lang/de/posts.php b/lang/de/posts.php index 0bd362f66..e0f3292c9 100644 --- a/lang/de/posts.php +++ b/lang/de/posts.php @@ -144,6 +144,19 @@ 'document_title' => 'Dokumenttitel', 'document_title_placeholder' => 'Wird bei deinem PDF-Dokument-Beitrag angezeigt', ], + 'custom_caption' => [ + 'label' => 'Eigener Text für :platform', + 'active' => 'an', + 'hint' => 'Leer lassen, um den gemeinsamen Beitragstext zu verwenden.', + 'placeholder' => 'Text nur für diese Plattform…', + 'over_limit' => 'Länger als das Limit dieser Plattform (:limit Zeichen).', + ], + 'location' => [ + 'label' => 'Standort', + 'id_placeholder' => 'Orts-ID', + 'name_placeholder' => 'Name (zur Anzeige)', + 'hint' => 'Facebook-Orts-ID; die Ortssuche ist für Dritt-Apps geschlossen — ID einmal ermitteln und wiederverwenden.', + ], 'youtube' => [ 'settings' => 'YouTube-Einstellungen', 'posting_to' => 'Veröffentlichen auf', @@ -152,6 +165,25 @@ 'description_max' => 'Die YouTube-Beschreibung darf 5.000 Bytes nicht überschreiten.', 'description_invalid' => 'Die YouTube-Beschreibung muss gültiger Text sein.', 'description_bytes' => ':used / :limit Bytes', + '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', diff --git a/lang/el/posts.php b/lang/el/posts.php index 6d8516056..6fdedc38f 100644 --- a/lang/el/posts.php +++ b/lang/el/posts.php @@ -142,6 +142,19 @@ 'document_title' => 'Τίτλος εγγράφου', 'document_title_placeholder' => 'Εμφανίζεται στη δημοσίευση εγγράφου PDF σας', ], + 'custom_caption' => [ + 'label' => 'Ξεχωριστό κείμενο για :platform', + 'active' => 'ενεργό', + 'hint' => 'Κενό: χρησιμοποιείται το κοινό κείμενο της ανάρτησης.', + 'placeholder' => 'Κείμενο μόνο για αυτήν την πλατφόρμα…', + 'over_limit' => 'Υπερβαίνει το όριο της πλατφόρμας (:limit χαρακτήρες).', + ], + 'location' => [ + 'label' => 'Τοποθεσία', + 'id_placeholder' => 'ID τοποθεσίας', + 'name_placeholder' => 'Όνομα (εμφάνιση)', + 'hint' => 'Facebook place ID· η αναζήτηση τοποθεσιών είναι κλειστή για τρίτους — βρείτε το ID μία φορά και επαναχρησιμοποιήστε το.', + ], 'youtube' => [ 'settings' => 'Ρυθμίσεις YouTube', 'posting_to' => 'Δημοσίευση σε', @@ -150,6 +163,25 @@ 'description_max' => 'Η περιγραφή YouTube δεν πρέπει να υπερβαίνει τα 5.000 byte.', 'description_invalid' => 'Η περιγραφή YouTube πρέπει να είναι έγκυρο κείμενο.', 'description_bytes' => ':used / :limit byte', + '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', diff --git a/lang/en/posts.php b/lang/en/posts.php index c97d9ca3e..67459fa30 100644 --- a/lang/en/posts.php +++ b/lang/en/posts.php @@ -142,6 +142,19 @@ 'document_title' => 'Document title', 'document_title_placeholder' => 'Shown on your PDF document post', ], + 'custom_caption' => [ + 'label' => 'Custom text for :platform', + 'active' => 'on', + 'hint' => 'Leave empty to use the shared post text.', + 'placeholder' => 'Text for this platform only…', + 'over_limit' => 'Longer than this platform allows (:limit characters).', + ], + 'location' => [ + 'label' => 'Location', + 'id_placeholder' => 'Place ID', + 'name_placeholder' => 'Name (for display)', + 'hint' => 'Facebook place ID; place search is closed to third-party apps, so enter the ID once and reuse it.', + ], 'youtube' => [ 'settings' => 'YouTube settings', 'posting_to' => 'Posting to', @@ -150,6 +163,25 @@ 'description_max' => 'The YouTube description must not exceed 5,000 bytes.', 'description_invalid' => 'The YouTube description must be valid text.', 'description_bytes' => ':used / :limit bytes', + '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', diff --git a/lang/es/posts.php b/lang/es/posts.php index b0f6ffd60..edcf98466 100644 --- a/lang/es/posts.php +++ b/lang/es/posts.php @@ -142,6 +142,19 @@ 'document_title' => 'Título del documento', 'document_title_placeholder' => 'Se muestra en tu publicación de documento PDF', ], + 'custom_caption' => [ + 'label' => 'Texto propio para :platform', + 'active' => 'sí', + 'hint' => 'Vacío: se usa el texto común de la publicación.', + 'placeholder' => 'Texto solo para esta plataforma…', + 'over_limit' => 'Supera el límite de esta plataforma (:limit caracteres).', + ], + 'location' => [ + 'label' => 'Ubicación', + 'id_placeholder' => 'ID del lugar', + 'name_placeholder' => 'Nombre (visual)', + 'hint' => 'ID de lugar de Facebook; la búsqueda de lugares está cerrada a terceros: introduce el ID una vez y reutilízalo.', + ], 'youtube' => [ 'settings' => 'Configuración de YouTube', 'posting_to' => 'Publicando en', @@ -150,6 +163,25 @@ 'description_max' => 'La descripción de YouTube no puede superar los 5.000 bytes.', 'description_invalid' => 'La descripción de YouTube debe ser texto válido.', 'description_bytes' => ':used / :limit bytes', + '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', diff --git a/lang/fr/posts.php b/lang/fr/posts.php index 4cb63393d..0debbb3bc 100644 --- a/lang/fr/posts.php +++ b/lang/fr/posts.php @@ -142,6 +142,19 @@ 'document_title' => 'Titre du document', 'document_title_placeholder' => 'Affiché sur votre publication de document PDF', ], + 'custom_caption' => [ + 'label' => 'Texte spécifique pour :platform', + 'active' => 'actif', + 'hint' => 'Vide : le texte commun de la publication est utilisé.', + 'placeholder' => 'Texte pour cette plateforme uniquement…', + 'over_limit' => 'Dépasse la limite de cette plateforme (:limit caractères).', + ], + 'location' => [ + 'label' => 'Lieu', + 'id_placeholder' => 'ID du lieu', + 'name_placeholder' => 'Nom (affichage)', + 'hint' => 'ID de lieu Facebook ; la recherche de lieux est fermée aux apps tierces — saisissez l’ID une fois et réutilisez-le.', + ], 'youtube' => [ 'settings' => 'Paramètres YouTube', 'posting_to' => 'Publication sur', @@ -150,6 +163,25 @@ 'description_max' => 'La description YouTube ne doit pas dépasser 5 000 octets.', 'description_invalid' => 'La description YouTube doit être un texte valide.', 'description_bytes' => ':used / :limit octets', + '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', diff --git a/lang/it/posts.php b/lang/it/posts.php index 3f3aa3316..ef987cae0 100644 --- a/lang/it/posts.php +++ b/lang/it/posts.php @@ -142,6 +142,19 @@ 'document_title' => 'Titolo del documento', 'document_title_placeholder' => 'Mostrato sul tuo post con documento PDF', ], + 'custom_caption' => [ + 'label' => 'Testo dedicato per :platform', + 'active' => 'attivo', + 'hint' => 'Vuoto: viene usato il testo comune del post.', + 'placeholder' => 'Testo solo per questa piattaforma…', + 'over_limit' => 'Supera il limite di questa piattaforma (:limit caratteri).', + ], + 'location' => [ + 'label' => 'Posizione', + 'id_placeholder' => 'ID del luogo', + 'name_placeholder' => 'Nome (visuale)', + 'hint' => 'ID luogo di Facebook; la ricerca dei luoghi è chiusa alle app di terzi: inserisci l’ID una volta e riusalo.', + ], 'youtube' => [ 'settings' => 'Impostazioni YouTube', 'posting_to' => 'Pubblicazione su', @@ -150,6 +163,25 @@ 'description_max' => 'La descrizione YouTube non deve superare 5.000 byte.', 'description_invalid' => 'La descrizione YouTube deve essere testo valido.', 'description_bytes' => ':used / :limit byte', + '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', diff --git a/lang/ja/posts.php b/lang/ja/posts.php index c2a011a02..61333bc35 100644 --- a/lang/ja/posts.php +++ b/lang/ja/posts.php @@ -142,6 +142,19 @@ 'document_title' => 'ドキュメントタイトル', 'document_title_placeholder' => 'PDF ドキュメント投稿に表示されます', ], + 'custom_caption' => [ + 'label' => ':platform 専用テキスト', + 'active' => '有効', + 'hint' => '空の場合は投稿の共通テキストが使われます。', + 'placeholder' => 'このプラットフォームだけのテキスト…', + 'over_limit' => 'このプラットフォームの上限(:limit文字)を超えています。', + ], + 'location' => [ + 'label' => '位置情報', + 'id_placeholder' => 'プレイスID', + 'name_placeholder' => '名称(表示用)', + 'hint' => 'FacebookのプレイスID。場所検索はサードパーティに閉鎖されているため、IDを一度調べて再利用してください。', + ], 'youtube' => [ 'settings' => 'YouTube設定', 'posting_to' => '投稿先', @@ -150,6 +163,25 @@ 'description_max' => 'YouTubeの説明は5,000バイト以内にしてください。', 'description_invalid' => 'YouTubeの説明には有効なテキストを入力してください。', 'description_bytes' => ':used / :limit バイト', + '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 設定', diff --git a/lang/ko/posts.php b/lang/ko/posts.php index bf42e246a..4a2a4534f 100644 --- a/lang/ko/posts.php +++ b/lang/ko/posts.php @@ -142,6 +142,19 @@ 'document_title' => '문서 제목', 'document_title_placeholder' => 'PDF 문서 게시물에 표시됩니다', ], + 'custom_caption' => [ + 'label' => ':platform 전용 텍스트', + 'active' => '사용', + 'hint' => '비워두면 게시물의 공통 텍스트가 사용됩니다.', + 'placeholder' => '이 플랫폼만을 위한 텍스트…', + 'over_limit' => '이 플랫폼의 한도(:limit자)를 초과했습니다.', + ], + 'location' => [ + 'label' => '위치', + 'id_placeholder' => '장소 ID', + 'name_placeholder' => '이름(표시용)', + 'hint' => 'Facebook 장소 ID. 장소 검색은 서드파티에 닫혀 있으므로 ID를 한 번 찾아 재사용하세요.', + ], 'youtube' => [ 'settings' => 'YouTube 설정', 'posting_to' => '게시 대상', @@ -150,6 +163,25 @@ 'description_max' => 'YouTube 설명은 5,000바이트를 초과할 수 없습니다.', 'description_invalid' => 'YouTube 설명은 유효한 텍스트여야 합니다.', 'description_bytes' => ':used / :limit 바이트', + '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 설정', diff --git a/lang/nl/posts.php b/lang/nl/posts.php index da32a0986..9e7648893 100644 --- a/lang/nl/posts.php +++ b/lang/nl/posts.php @@ -142,6 +142,19 @@ 'document_title' => 'Documenttitel', 'document_title_placeholder' => 'Getoond op je PDF-documentpost', ], + 'custom_caption' => [ + 'label' => 'Eigen tekst voor :platform', + 'active' => 'aan', + 'hint' => 'Leeg: de gedeelde berichttekst wordt gebruikt.', + 'placeholder' => 'Tekst alleen voor dit platform…', + 'over_limit' => 'Langer dan de limiet van dit platform (:limit tekens).', + ], + 'location' => [ + 'label' => 'Locatie', + 'id_placeholder' => 'Plaats-ID', + 'name_placeholder' => 'Naam (weergave)', + 'hint' => 'Facebook-plaats-ID; plaats zoeken is gesloten voor externe apps — voer de ID één keer in en hergebruik.', + ], 'youtube' => [ 'settings' => 'YouTube-instellingen', 'posting_to' => 'Publiceren op', @@ -150,6 +163,25 @@ 'description_max' => 'De YouTube-beschrijving mag niet langer zijn dan 5.000 bytes.', 'description_invalid' => 'De YouTube-beschrijving moet geldige tekst zijn.', 'description_bytes' => ':used / :limit bytes', + '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', diff --git a/lang/pl/posts.php b/lang/pl/posts.php index 3393ecca7..9d5dc8d33 100644 --- a/lang/pl/posts.php +++ b/lang/pl/posts.php @@ -142,6 +142,19 @@ 'document_title' => 'Tytuł dokumentu', 'document_title_placeholder' => 'Wyświetlany w Twoim poście z dokumentem PDF', ], + 'custom_caption' => [ + 'label' => 'Własny tekst dla :platform', + 'active' => 'wł.', + 'hint' => 'Puste — używany jest wspólny tekst posta.', + 'placeholder' => 'Tekst tylko dla tej platformy…', + 'over_limit' => 'Przekracza limit tej platformy (:limit znaków).', + ], + 'location' => [ + 'label' => 'Lokalizacja', + 'id_placeholder' => 'ID miejsca', + 'name_placeholder' => 'Nazwa (podgląd)', + 'hint' => 'ID miejsca Facebooka; wyszukiwanie miejsc jest zamknięte dla aplikacji zewnętrznych — wpisz ID raz i używaj ponownie.', + ], 'youtube' => [ 'settings' => 'Ustawienia YouTube', 'posting_to' => 'Publikowanie na', @@ -150,6 +163,25 @@ 'description_max' => 'Opis YouTube nie może przekraczać 5000 bajtów.', 'description_invalid' => 'Opis YouTube musi być poprawnym tekstem.', 'description_bytes' => ':used / :limit bajtó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', diff --git a/lang/pt-BR/posts.php b/lang/pt-BR/posts.php index 5cb562e66..c46e63132 100644 --- a/lang/pt-BR/posts.php +++ b/lang/pt-BR/posts.php @@ -142,6 +142,19 @@ 'document_title' => 'Título do documento', 'document_title_placeholder' => 'Aparece no seu post de documento PDF', ], + 'custom_caption' => [ + 'label' => 'Texto próprio para :platform', + 'active' => 'ativo', + 'hint' => 'Vazio: o texto comum da publicação é usado.', + 'placeholder' => 'Texto apenas para esta plataforma…', + 'over_limit' => 'Excede o limite desta plataforma (:limit caracteres).', + ], + 'location' => [ + 'label' => 'Localização', + 'id_placeholder' => 'ID do lugar', + 'name_placeholder' => 'Nome (exibição)', + 'hint' => 'ID de lugar do Facebook; a busca de lugares está fechada para terceiros — informe o ID uma vez e reutilize.', + ], 'youtube' => [ 'settings' => 'Configurações do YouTube', 'posting_to' => 'Publicando em', @@ -150,6 +163,25 @@ 'description_max' => 'A descrição do YouTube não pode ultrapassar 5.000 bytes.', 'description_invalid' => 'A descrição do YouTube deve ser um texto válido.', 'description_bytes' => ':used / :limit bytes', + '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', diff --git a/lang/ru/posts.php b/lang/ru/posts.php index b64e8164b..a193981f8 100644 --- a/lang/ru/posts.php +++ b/lang/ru/posts.php @@ -142,6 +142,19 @@ 'document_title' => 'Название документа', 'document_title_placeholder' => 'Отображается в вашем посте с PDF-документом', ], + 'custom_caption' => [ + 'label' => 'Свой текст для :platform', + 'active' => 'вкл', + 'hint' => 'Пусто — используется общий текст поста.', + 'placeholder' => 'Текст только для этой платформы…', + 'over_limit' => 'Длиннее лимита платформы (:limit символов).', + ], + 'location' => [ + 'label' => 'Локация', + 'id_placeholder' => 'ID места', + 'name_placeholder' => 'Название (для себя)', + 'hint' => 'ID места Facebook; поиск мест закрыт для сторонних приложений — найдите ID один раз и переиспользуйте.', + ], 'youtube' => [ 'settings' => 'Настройки YouTube', 'posting_to' => 'Публикация в', @@ -150,6 +163,25 @@ 'description_max' => 'Описание YouTube не должно превышать 5000 байт.', 'description_invalid' => 'Описание YouTube должно быть корректным текстом.', 'description_bytes' => ':used / :limit байт', + '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', diff --git a/lang/tr/posts.php b/lang/tr/posts.php index 52f68be2d..f61fc828e 100644 --- a/lang/tr/posts.php +++ b/lang/tr/posts.php @@ -144,6 +144,19 @@ 'document_title' => 'Belge başlığı', 'document_title_placeholder' => 'PDF belge gönderinizde gösterilir', ], + 'custom_caption' => [ + 'label' => ':platform için özel metin', + 'active' => 'açık', + 'hint' => 'Boşsa gönderinin ortak metni kullanılır.', + 'placeholder' => 'Yalnızca bu platform için metin…', + 'over_limit' => 'Bu platformun sınırını aşıyor (:limit karakter).', + ], + 'location' => [ + 'label' => 'Konum', + 'id_placeholder' => 'Yer ID', + 'name_placeholder' => 'Ad (görünüm)', + 'hint' => 'Facebook yer ID’si; yer arama üçüncü taraflara kapalı — ID’yi bir kez bulun ve yeniden kullanın.', + ], 'youtube' => [ 'settings' => 'YouTube ayarları', 'posting_to' => 'Yayınlanacak hesap', @@ -152,6 +165,25 @@ 'description_max' => 'YouTube açıklaması 5.000 baytı aşmamalıdır.', 'description_invalid' => 'YouTube açıklaması geçerli bir metin olmalıdır.', 'description_bytes' => ':used / :limit bayt', + '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ı', diff --git a/lang/uk/posts.php b/lang/uk/posts.php index 293a047a7..189b0580e 100644 --- a/lang/uk/posts.php +++ b/lang/uk/posts.php @@ -142,6 +142,19 @@ 'document_title' => 'Назва документа', 'document_title_placeholder' => 'Відображається у вашому пості з PDF-документом', ], + 'custom_caption' => [ + 'label' => 'Свій текст для :platform', + 'active' => 'увімк', + 'hint' => 'Порожньо — використовується спільний текст поста.', + 'placeholder' => 'Текст лише для цієї платформи…', + 'over_limit' => 'Довше за ліміт платформи (:limit символів).', + ], + 'location' => [ + 'label' => 'Локація', + 'id_placeholder' => 'ID місця', + 'name_placeholder' => 'Назва (для себе)', + 'hint' => 'ID місця Facebook; пошук місць закритий для сторонніх застосунків — знайдіть ID один раз і повторно використовуйте.', + ], 'youtube' => [ 'settings' => 'Налаштування YouTube', 'posting_to' => 'Публікація в', @@ -150,6 +163,25 @@ 'description_max' => 'Опис YouTube не повинен перевищувати 5000 байтів.', 'description_invalid' => 'Опис YouTube має бути коректним текстом.', 'description_bytes' => ':used / :limit байтів', + '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', diff --git a/lang/zh/posts.php b/lang/zh/posts.php index f42ea1bcb..c0846d072 100644 --- a/lang/zh/posts.php +++ b/lang/zh/posts.php @@ -142,6 +142,19 @@ 'document_title' => '文档标题', 'document_title_placeholder' => '显示在你的 PDF 文档帖子上', ], + 'custom_caption' => [ + 'label' => '为 :platform 定制文本', + 'active' => '启用', + 'hint' => '留空则使用帖子共用文本。', + 'placeholder' => '仅用于此平台的文本…', + 'over_limit' => '超过该平台上限(:limit 个字符)。', + ], + 'location' => [ + 'label' => '位置', + 'id_placeholder' => '地点 ID', + 'name_placeholder' => '名称(展示用)', + 'hint' => 'Facebook 地点 ID;地点搜索已对第三方关闭,请查一次 ID 后重复使用。', + ], 'youtube' => [ 'settings' => 'YouTube 设置', 'posting_to' => '发布到', @@ -150,6 +163,25 @@ 'description_max' => 'YouTube 描述不得超过 5,000 个字节。', 'description_invalid' => 'YouTube 描述必须是有效文本。', 'description_bytes' => ':used / :limit 字节', + '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 设置', diff --git a/resources/js/components/ChannelConfigurator.vue b/resources/js/components/ChannelConfigurator.vue index 1c8706b4f..2377db34f 100644 --- a/resources/js/components/ChannelConfigurator.vue +++ b/resources/js/components/ChannelConfigurator.vue @@ -2,6 +2,7 @@ import { IconAlertCircle, IconBan, IconCircleCheck, IconExternalLink, IconHourglass } 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 GoogleBusinessSettings from '@/components/posts/editor/GoogleBusinessSettings.vue'; @@ -185,6 +186,12 @@ const settingsProps = (channel: Channel) => ({ @update:meta="emit('update:meta', channel.id, $event)" /> + diff --git a/resources/js/components/ImagePreviewDialog.vue b/resources/js/components/ImagePreviewDialog.vue index 400bd67a7..a547c88f4 100644 --- a/resources/js/components/ImagePreviewDialog.vue +++ b/resources/js/components/ImagePreviewDialog.vue @@ -139,7 +139,7 @@ defineExpose({ open, openCollection, close });
+import { IconChevronDown, IconChevronUp, IconPencil } from '@tabler/icons-vue'; +import { computed, ref } from 'vue'; + +import { Textarea } from '@/components/ui/textarea'; +import { getPlatformLabel, getPlatformLogo } from '@/composables/usePlatformLogo'; +import type { Channel } from '@/types/channel'; + +const props = withDefaults(defineProps<{ + channel: Channel; + disabled?: boolean; + previewOnly?: boolean; +}>(), { + disabled: false, + previewOnly: false, +}); + +const emit = defineEmits<{ + 'update:meta': [value: Record]; +}>(); + +const override = computed({ + get: () => (props.channel.meta?.content as string | undefined) || '', + set: (value: string) => emit('update:meta', { ...props.channel.meta, content: value || null }), +}); + +// Раскрыта сразу, если переопределение уже задано. +const open = ref(override.value !== ''); + +const limit = computed(() => props.channel.contentLimit ?? null); +const overLimit = computed(() => limit.value !== null && override.value.length > limit.value); + + +