diff --git a/app/Actions/Post/UpdatePost.php b/app/Actions/Post/UpdatePost.php index f3c87f625..f0cb1a415 100644 --- a/app/Actions/Post/UpdatePost.php +++ b/app/Actions/Post/UpdatePost.php @@ -12,6 +12,7 @@ use App\Models\Post; use App\Models\PostPlatform; use App\Models\Workspace; +use App\Support\PostPlatformMetaRules; use App\Support\PostStatusRules; use App\Support\Social\AbandonGoogleBusinessReview; use App\Support\Social\GoogleBusinessDerivativeCleaner; @@ -74,7 +75,16 @@ public static function execute(Workspace $workspace, Post $post, array $data): a ->where('id', data_get($platformData, 'id')) ->update($updateData); } + } + + if (in_array($post->status, [PostStatus::Scheduled, PostStatus::Publishing], true)) { + PostPlatformMetaRules::assertStoredPostPublishable( + $post, + collect(data_get($data, 'platforms', []))->pluck('id')->all(), + ); + } + if (Arr::has($data, 'platforms')) { $post->postPlatforms() ->disabled() ->where('platform', Platform::GoogleBusiness) diff --git a/app/Enums/SocialAccount/Platform.php b/app/Enums/SocialAccount/Platform.php index c87258188..eb642fe33 100644 --- a/app/Enums/SocialAccount/Platform.php +++ b/app/Enums/SocialAccount/Platform.php @@ -164,10 +164,10 @@ 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: content supplies the title, capped at 100 characters + * (publisher derives it from the first line via `buildTitle`). Optional + * meta.description is separate plain text, capped at 5000 UTF-8 bytes; + * absent descriptions fall back to content. * - 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) diff --git a/app/Mcp/Tools/Post/CreatePostTool.php b/app/Mcp/Tools/Post/CreatePostTool.php index 0de4bf31a..0183b2663 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}]).'), + '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.'), ])) ->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 a8b975a43..0b2af698e 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.'), + '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.'), ])) ->description('Platforms to enable for publishing. Any platform NOT listed will be disabled. Pass an empty array to disable all.'), ]; diff --git a/app/Rules/ValidYouTubeDescription.php b/app/Rules/ValidYouTubeDescription.php new file mode 100644 index 000000000..70bb5ab8c --- /dev/null +++ b/app/Rules/ValidYouTubeDescription.php @@ -0,0 +1,25 @@ +translate(); + } + } +} diff --git a/app/Services/Post/PostPreviewer.php b/app/Services/Post/PostPreviewer.php index ca8343ea2..575b8de8e 100644 --- a/app/Services/Post/PostPreviewer.php +++ b/app/Services/Post/PostPreviewer.php @@ -4,9 +4,11 @@ namespace App\Services\Post; +use App\Enums\SocialAccount\Platform; use App\Models\Post; use App\Models\PostPlatform; use App\Services\Social\ContentSanitizer; +use App\Support\YouTubeDescription; use Illuminate\Support\Collection; /** @@ -31,7 +33,9 @@ public function __construct(private readonly ContentSanitizer $sanitizer) {} * sanitized_content: string, * sanitized_length: int, * max_content_length: int, - * truncated: bool + * truncated: bool, + * description?: string, + * description_length_bytes?: int * }> * } */ @@ -59,7 +63,7 @@ private function platformPreviews(Post $post, string $original): Collection $platform = $pp->socialAccount?->platform ?? $pp->platform; $sanitized = $this->sanitizer->sanitize($original, $platform); - return [ + $preview = [ 'post_platform_id' => $pp->id, 'platform' => $platform->value, 'content_type' => $pp->content_type?->value, @@ -68,6 +72,14 @@ private function platformPreviews(Post $post, string $original): Collection 'max_content_length' => $platform->maxContentLength(), 'truncated' => mb_strlen($sanitized) < mb_strlen($original), ]; + + if ($platform === Platform::YouTube) { + $description = YouTubeDescription::resolve($pp->meta, $sanitized); + $preview['description'] = $description; + $preview['description_length_bytes'] = strlen($description); + } + + return $preview; }); } } diff --git a/app/Services/Social/YouTubePublisher.php b/app/Services/Social/YouTubePublisher.php index a12b60a36..94d110891 100644 --- a/app/Services/Social/YouTubePublisher.php +++ b/app/Services/Social/YouTubePublisher.php @@ -4,11 +4,13 @@ namespace App\Services\Social; +use App\Dto\MediaItem; use App\Exceptions\Social\ErrorCategory; use App\Exceptions\Social\YouTubePublishException; use App\Models\PostPlatform; use App\Models\SocialAccount; use App\Services\Social\Concerns\HasSocialHttpClient; +use App\Support\YouTubeDescription; use Google\Client as GoogleClient; use Google\Service\Exception; use Google\Service\YouTube; @@ -29,7 +31,10 @@ 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->post->content + ? app(ContentSanitizer::class)->sanitize($postPlatform->post->content, $postPlatform->platform) + : null; + $description = $this->resolveDescription($postPlatform, $content); $account = $postPlatform->socialAccount; @@ -55,12 +60,12 @@ public function publish(PostPlatform $postPlatform): array ); } - return $this->publishShort($postPlatform, $firstMedia, $account, $content); + return $this->publishShort($firstMedia, $account, $content, $description); } private function createGoogleClient(SocialAccount $account): GoogleClient { - $client = new GoogleClient; + $client = app(GoogleClient::class); $client->setClientId(config('services.google.client_id')); $client->setClientSecret(config('services.google.client_secret')); @@ -83,7 +88,7 @@ private function createGoogleClient(SocialAccount $account): GoogleClient return $client; } - private function publishShort(PostPlatform $postPlatform, $media, SocialAccount $account, ?string $content): array + private function publishShort(MediaItem $media, SocialAccount $account, ?string $content, string $description): array { if (empty($content)) { throw new YouTubePublishException( @@ -92,60 +97,79 @@ private function publishShort(PostPlatform $postPlatform, $media, SocialAccount ); } - $title = $this->buildTitle($content); - $description = $content; - $tempFile = tempnam(sys_get_temp_dir(), 'yt_upload_'); - $handle = null; - try { - // Download video to temp file (memory-safe) - $downloadResponse = Http::withOptions(['sink' => $tempFile]) - ->timeout(600) - ->get($media->url); + if ($tempFile === false) { + throw new YouTubePublishException( + userMessage: 'Failed to create temp file for YouTube upload', + category: ErrorCategory::ServerError, + ); + } - if ($downloadResponse->failed()) { - throw new YouTubePublishException( - userMessage: 'Failed to download video for YouTube upload: HTTP '.$downloadResponse->status(), - category: ErrorCategory::ServerError, - ); - } + try { + $this->downloadVideo($media, $tempFile); + $video = $this->uploadVideo($media, $account, $tempFile, $this->buildVideo($content, $description)); + $videoId = $video->getId(); - $fileSize = filesize($tempFile); + return [ + 'id' => $videoId, + 'url' => "https://www.youtube.com/shorts/{$videoId}", + ]; + } catch (Exception $e) { + throw YouTubePublishException::fromGoogleException($e); + } catch (\Throwable $e) { + Log::error('YouTube upload failed', [ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); - if ($fileSize === false || $fileSize < 1024) { - throw new YouTubePublishException( - userMessage: 'Downloaded video is too small or empty ('.$fileSize.' bytes), aborting upload', - category: ErrorCategory::MediaFormat, - ); - } + throw $e; + } finally { + @unlink($tempFile); + } + } - // Set up Google Client with deferred mode for resumable upload - $client = $this->createGoogleClient($account); - $client->setDefer(true); + private function downloadVideo(MediaItem $media, string $path): void + { + $response = Http::withOptions(['sink' => $path]) + ->timeout(600) + ->get($media->url); - $youtube = new YouTube($client); + if ($response->failed()) { + throw new YouTubePublishException( + userMessage: 'Failed to download video for YouTube upload: HTTP '.$response->status(), + category: ErrorCategory::ServerError, + ); + } + } - // Build video metadata - $snippet = new VideoSnippet; - $snippet->setTitle($title); - $snippet->setDescription($description); - $snippet->setCategoryId('22'); + private function uploadVideo(MediaItem $media, SocialAccount $account, string $path, Video $video): Video + { + $fileSize = filesize($path); - $status = new VideoStatus; - $status->setPrivacyStatus('public'); - $status->setSelfDeclaredMadeForKids(false); + if ($fileSize === false || $fileSize < 1024) { + throw new YouTubePublishException( + userMessage: 'Downloaded video is too small or empty ('.$fileSize.' bytes), aborting upload', + category: ErrorCategory::MediaFormat, + ); + } - $video = new Video; - $video->setSnippet($snippet); - $video->setStatus($status); + $client = $this->createGoogleClient($account); + $handle = fopen($path, 'r'); - // Initialize resumable upload request - $insertRequest = $youtube->videos->insert('snippet,status', $video); + if ($handle === false) { + throw new YouTubePublishException( + userMessage: 'Failed to open temp file for YouTube upload', + category: ErrorCategory::ServerError, + ); + } + try { + $client->setDefer(true); + $youtube = new YouTube($client); $mediaUpload = new Google_Http_MediaFileUpload( $client, - $insertRequest, + $youtube->videos->insert('snippet,status', $video), $media->mime_type ?: 'video/mp4', null, true, @@ -153,27 +177,13 @@ private function publishShort(PostPlatform $postPlatform, $media, SocialAccount ); $mediaUpload->setFileSize($fileSize); - // Upload in chunks (memory-safe for large files) $uploadStatus = false; - $handle = fopen($tempFile, 'r'); - - if ($handle === false) { - throw new YouTubePublishException( - userMessage: 'Failed to open temp file for YouTube upload', - category: ErrorCategory::ServerError, - ); - } while (! $uploadStatus && ! feof($handle)) { $chunk = fread($handle, self::CHUNK_SIZE); $uploadStatus = $mediaUpload->nextChunk($chunk); } - fclose($handle); - $handle = null; - - $client->setDefer(false); - if (! $uploadStatus instanceof Video) { throw new YouTubePublishException( userMessage: 'YouTube upload failed: no video object returned', @@ -181,28 +191,45 @@ private function publishShort(PostPlatform $postPlatform, $media, SocialAccount ); } - $videoId = $uploadStatus->getId(); - - return [ - 'id' => $videoId, - 'url' => "https://www.youtube.com/shorts/{$videoId}", - ]; - } catch (Exception $e) { - $this->handleGoogleError($e); - } catch (\Throwable $e) { - Log::error('YouTube upload failed', [ - 'error' => $e->getMessage(), - 'trace' => $e->getTraceAsString(), - ]); - - throw $e; + return $uploadStatus; } finally { - if ($handle !== null && is_resource($handle)) { - fclose($handle); - } + fclose($handle); + $client->setDefer(false); + } + } - @unlink($tempFile); + private function resolveDescription(PostPlatform $postPlatform, ?string $content): string + { + $description = YouTubeDescription::resolve($postPlatform->meta, $content); + $violation = YouTubeDescription::violation(data_get($postPlatform->meta, 'description')) + ?? YouTubeDescription::violation($description); + + if ($violation !== null) { + throw new YouTubePublishException( + userMessage: __($violation), + category: ErrorCategory::ContentPolicy, + ); } + + return $description; + } + + private function buildVideo(string $content, string $description): Video + { + $snippet = new VideoSnippet; + $snippet->setTitle($this->buildTitle($content)); + $snippet->setDescription($description); + $snippet->setCategoryId('22'); + + $status = new VideoStatus; + $status->setPrivacyStatus('public'); + $status->setSelfDeclaredMadeForKids(false); + + $video = new Video; + $video->setSnippet($snippet); + $video->setStatus($status); + + return $video; } private function buildTitle(string $content): string @@ -220,9 +247,4 @@ private function buildTitle(string $content): string return $title.$shortsTag; } - - private function handleGoogleError(Exception $e): never - { - throw YouTubePublishException::fromGoogleException($e); - } } diff --git a/app/Support/PostPlatformMetaRules.php b/app/Support/PostPlatformMetaRules.php index c904ee952..f7608db84 100644 --- a/app/Support/PostPlatformMetaRules.php +++ b/app/Support/PostPlatformMetaRules.php @@ -10,6 +10,8 @@ use App\Enums\SocialAccount\Platform; use App\Enums\TikTok\PrivacyLevel; use App\Models\Post; +use App\Models\PostPlatform; +use App\Rules\ValidYouTubeDescription; use Illuminate\Support\Str; use Illuminate\Validation\Rule; use Illuminate\Validation\ValidationException; @@ -56,6 +58,9 @@ public static function rules(): array 'platforms.*.meta.title' => ['sometimes', 'nullable', 'string', 'max:100'], 'platforms.*.meta.link' => ['sometimes', 'nullable', 'url:http,https', 'max:2048'], + // YouTube + 'platforms.*.meta.description' => ['sometimes', 'nullable', 'string', new ValidYouTubeDescription], + // Discord 'platforms.*.meta.channel_id' => ['sometimes', 'nullable', 'string'], 'platforms.*.meta.channel_name' => ['sometimes', 'nullable', 'string'], @@ -112,6 +117,7 @@ public static function attributes(): array { return [ 'platforms.*.meta.title' => __('posts.form.pinterest.title'), + 'platforms.*.meta.description' => __('posts.form.youtube.description'), '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'), @@ -135,7 +141,11 @@ public static function addRequiredOnPublishErrors(Validator $validator, array $p if ($violation !== null) { [$field, $message] = $violation; - $validator->errors()->add("platforms.{$index}.meta.{$field}", $message); + $key = "platforms.{$index}.meta.{$field}"; + + if (! $validator->errors()->has($key)) { + $validator->errors()->add($key, $message); + } } } } @@ -146,13 +156,24 @@ public static function addRequiredOnPublishErrors(Validator $validator, array $p * without resubmitting platforms (e.g. the MCP publish tool), so a misconfigured * post fails fast with a clear message instead of only at publish time. * + * @param array $platformIds Submitted order for indexed errors. + * * @throws ValidationException */ - public static function assertStoredPostPublishable(Post $post): void + public static function assertStoredPostPublishable(Post $post, array $platformIds = []): void { + $platforms = $post->postPlatforms()->enabled()->get()->values(); + + if ($platformIds !== []) { + $platformsById = $platforms->keyBy('id'); + $platforms = collect($platformIds) + ->map(fn (string $id): ?PostPlatform => $platformsById->get($id)) + ->filter(); + } + $errors = []; - foreach ($post->postPlatforms()->enabled()->get()->values() as $index => $postPlatform) { + foreach ($platforms as $index => $postPlatform) { $violation = self::requiredMetaViolation($postPlatform->platform, $postPlatform->meta); if ($violation !== null) { @@ -179,6 +200,7 @@ public static function requiredMetaViolation(?Platform $platform, mixed $meta): $needsGoogleBusinessEvent = $platform === Platform::GoogleBusiness && $topicType->requiresEvent(); return match (true) { + $platform === Platform::YouTube => self::youtubeDescriptionViolation($meta), $platform === Platform::TikTok => self::tiktokPrivacyViolation($meta), $platform === Platform::Pinterest && blank(data_get($meta, 'board_id')) => ['board_id', trans('posts.form.pinterest.board_required')], $platform === Platform::Discord && blank(data_get($meta, 'channel_id')) => ['channel_id', trans('posts.form.discord.channel_required')], @@ -208,6 +230,16 @@ public static function requiredMetaViolation(?Platform $platform, mixed $meta): }; } + /** + * @return array{0: string, 1: string}|null + */ + private static function youtubeDescriptionViolation(mixed $meta): ?array + { + $key = YouTubeDescription::violation(data_get($meta, 'description')); + + return $key === null ? null : ['description', __($key)]; + } + /** * Event/offer titles over TITLE_MAX_LENGTH fail publish even when stored * outside rules() — MCP PublishPostTool only runs requiredMetaViolation(). diff --git a/app/Support/YouTubeDescription.php b/app/Support/YouTubeDescription.php new file mode 100644 index 000000000..f9d542b98 --- /dev/null +++ b/app/Support/YouTubeDescription.php @@ -0,0 +1,38 @@ + 'posts.form.youtube.description_invalid', + strlen($description) > self::MAX_BYTES => 'posts.form.youtube.description_max', + default => null, + }; + } + + /** + * @param array|null $meta + */ + public static function resolve(?array $meta, ?string $content): string + { + $description = data_get($meta, 'description'); + + return is_string($description) && filled(Str::trim($description)) + ? $description + : ($content ?? ''); + } +} diff --git a/lang/ar/posts.php b/lang/ar/posts.php index 5bd4cb358..1005d7b03 100644 --- a/lang/ar/posts.php +++ b/lang/ar/posts.php @@ -142,6 +142,15 @@ 'document_title' => 'عنوان المستند', 'document_title_placeholder' => 'يظهر على منشور مستند PDF الخاص بك', ], + 'youtube' => [ + 'settings' => 'إعدادات YouTube', + 'posting_to' => 'النشر على', + 'description' => 'الوصف', + 'description_placeholder' => 'وصف الفيديو', + 'description_max' => 'يجب ألا يتجاوز وصف YouTube ‏5000 بايت.', + 'description_invalid' => 'يجب أن يكون وصف YouTube نصًا صالحًا.', + 'description_bytes' => ':used / :limit بايت', + ], 'pinterest' => [ 'settings' => 'إعدادات Pinterest', 'posting_to' => 'النشر إلى', diff --git a/lang/de/posts.php b/lang/de/posts.php index f8ab34c30..0bd362f66 100644 --- a/lang/de/posts.php +++ b/lang/de/posts.php @@ -144,6 +144,15 @@ 'document_title' => 'Dokumenttitel', 'document_title_placeholder' => 'Wird bei deinem PDF-Dokument-Beitrag angezeigt', ], + 'youtube' => [ + 'settings' => 'YouTube-Einstellungen', + 'posting_to' => 'Veröffentlichen auf', + 'description' => 'Beschreibung', + 'description_placeholder' => 'Videobeschreibung', + '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', + ], 'pinterest' => [ 'settings' => 'Pinterest-Einstellungen', 'posting_to' => 'Veröffentlichen auf', diff --git a/lang/el/posts.php b/lang/el/posts.php index 4da982a4f..6d8516056 100644 --- a/lang/el/posts.php +++ b/lang/el/posts.php @@ -142,6 +142,15 @@ 'document_title' => 'Τίτλος εγγράφου', 'document_title_placeholder' => 'Εμφανίζεται στη δημοσίευση εγγράφου PDF σας', ], + 'youtube' => [ + 'settings' => 'Ρυθμίσεις YouTube', + 'posting_to' => 'Δημοσίευση σε', + 'description' => 'Περιγραφή', + 'description_placeholder' => 'Περιγραφή βίντεο', + 'description_max' => 'Η περιγραφή YouTube δεν πρέπει να υπερβαίνει τα 5.000 byte.', + 'description_invalid' => 'Η περιγραφή YouTube πρέπει να είναι έγκυρο κείμενο.', + 'description_bytes' => ':used / :limit byte', + ], 'pinterest' => [ 'settings' => 'Ρυθμίσεις Pinterest', 'posting_to' => 'Δημοσίευση σε', diff --git a/lang/en/posts.php b/lang/en/posts.php index 12a91b91d..c97d9ca3e 100644 --- a/lang/en/posts.php +++ b/lang/en/posts.php @@ -142,6 +142,15 @@ 'document_title' => 'Document title', 'document_title_placeholder' => 'Shown on your PDF document post', ], + 'youtube' => [ + 'settings' => 'YouTube settings', + 'posting_to' => 'Posting to', + 'description' => 'Description', + 'description_placeholder' => 'Video description', + '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', + ], 'pinterest' => [ 'settings' => 'Pinterest Settings', 'posting_to' => 'Posting to', diff --git a/lang/es/posts.php b/lang/es/posts.php index ce9c2e387..b0f6ffd60 100644 --- a/lang/es/posts.php +++ b/lang/es/posts.php @@ -142,6 +142,15 @@ 'document_title' => 'Título del documento', 'document_title_placeholder' => 'Se muestra en tu publicación de documento PDF', ], + 'youtube' => [ + 'settings' => 'Configuración de YouTube', + 'posting_to' => 'Publicando en', + 'description' => 'Descripción', + 'description_placeholder' => 'Descripción del vídeo', + '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', + ], 'pinterest' => [ 'settings' => 'Configuración de Pinterest', 'posting_to' => 'Publicando en', diff --git a/lang/fr/posts.php b/lang/fr/posts.php index 59b72e357..4cb63393d 100644 --- a/lang/fr/posts.php +++ b/lang/fr/posts.php @@ -142,6 +142,15 @@ 'document_title' => 'Titre du document', 'document_title_placeholder' => 'Affiché sur votre publication de document PDF', ], + 'youtube' => [ + 'settings' => 'Paramètres YouTube', + 'posting_to' => 'Publication sur', + 'description' => 'Description', + 'description_placeholder' => 'Description de la vidéo', + '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', + ], 'pinterest' => [ 'settings' => 'Paramètres Pinterest', 'posting_to' => 'Publier sur', diff --git a/lang/it/posts.php b/lang/it/posts.php index c03e75e11..3f3aa3316 100644 --- a/lang/it/posts.php +++ b/lang/it/posts.php @@ -142,6 +142,15 @@ 'document_title' => 'Titolo del documento', 'document_title_placeholder' => 'Mostrato sul tuo post con documento PDF', ], + 'youtube' => [ + 'settings' => 'Impostazioni YouTube', + 'posting_to' => 'Pubblicazione su', + 'description' => 'Descrizione', + 'description_placeholder' => 'Descrizione del video', + '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', + ], 'pinterest' => [ 'settings' => 'Impostazioni Pinterest', 'posting_to' => 'Pubblicazione su', diff --git a/lang/ja/posts.php b/lang/ja/posts.php index e066e8686..c2a011a02 100644 --- a/lang/ja/posts.php +++ b/lang/ja/posts.php @@ -142,6 +142,15 @@ 'document_title' => 'ドキュメントタイトル', 'document_title_placeholder' => 'PDF ドキュメント投稿に表示されます', ], + 'youtube' => [ + 'settings' => 'YouTube設定', + 'posting_to' => '投稿先', + 'description' => '説明', + 'description_placeholder' => '動画の説明', + 'description_max' => 'YouTubeの説明は5,000バイト以内にしてください。', + 'description_invalid' => 'YouTubeの説明には有効なテキストを入力してください。', + 'description_bytes' => ':used / :limit バイト', + ], 'pinterest' => [ 'settings' => 'Pinterest 設定', 'posting_to' => '投稿先', diff --git a/lang/ko/posts.php b/lang/ko/posts.php index 13bfc4421..bf42e246a 100644 --- a/lang/ko/posts.php +++ b/lang/ko/posts.php @@ -142,6 +142,15 @@ 'document_title' => '문서 제목', 'document_title_placeholder' => 'PDF 문서 게시물에 표시됩니다', ], + 'youtube' => [ + 'settings' => 'YouTube 설정', + 'posting_to' => '게시 대상', + 'description' => '설명', + 'description_placeholder' => '동영상 설명', + 'description_max' => 'YouTube 설명은 5,000바이트를 초과할 수 없습니다.', + 'description_invalid' => 'YouTube 설명은 유효한 텍스트여야 합니다.', + 'description_bytes' => ':used / :limit 바이트', + ], 'pinterest' => [ 'settings' => 'Pinterest 설정', 'posting_to' => '게시 대상', diff --git a/lang/nl/posts.php b/lang/nl/posts.php index 3338a4118..da32a0986 100644 --- a/lang/nl/posts.php +++ b/lang/nl/posts.php @@ -142,6 +142,15 @@ 'document_title' => 'Documenttitel', 'document_title_placeholder' => 'Getoond op je PDF-documentpost', ], + 'youtube' => [ + 'settings' => 'YouTube-instellingen', + 'posting_to' => 'Publiceren op', + 'description' => 'Beschrijving', + 'description_placeholder' => 'Videobeschrijving', + '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', + ], 'pinterest' => [ 'settings' => 'Pinterest-instellingen', 'posting_to' => 'Posten naar', diff --git a/lang/pl/posts.php b/lang/pl/posts.php index 8deb478fd..3393ecca7 100644 --- a/lang/pl/posts.php +++ b/lang/pl/posts.php @@ -142,6 +142,15 @@ 'document_title' => 'Tytuł dokumentu', 'document_title_placeholder' => 'Wyświetlany w Twoim poście z dokumentem PDF', ], + 'youtube' => [ + 'settings' => 'Ustawienia YouTube', + 'posting_to' => 'Publikowanie na', + 'description' => 'Opis', + 'description_placeholder' => 'Opis filmu', + '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', + ], 'pinterest' => [ 'settings' => 'Ustawienia Pinterest', 'posting_to' => 'Publikowanie na', diff --git a/lang/pt-BR/posts.php b/lang/pt-BR/posts.php index c6d04ee18..5cb562e66 100644 --- a/lang/pt-BR/posts.php +++ b/lang/pt-BR/posts.php @@ -142,6 +142,15 @@ 'document_title' => 'Título do documento', 'document_title_placeholder' => 'Aparece no seu post de documento PDF', ], + 'youtube' => [ + 'settings' => 'Configurações do YouTube', + 'posting_to' => 'Publicando em', + 'description' => 'Descrição', + 'description_placeholder' => 'Descrição do vídeo', + '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', + ], 'pinterest' => [ 'settings' => 'Configurações do Pinterest', 'posting_to' => 'Publicando em', diff --git a/lang/ru/posts.php b/lang/ru/posts.php index 4d557f7f4..b64e8164b 100644 --- a/lang/ru/posts.php +++ b/lang/ru/posts.php @@ -142,6 +142,15 @@ 'document_title' => 'Название документа', 'document_title_placeholder' => 'Отображается в вашем посте с PDF-документом', ], + 'youtube' => [ + 'settings' => 'Настройки YouTube', + 'posting_to' => 'Публикация в', + 'description' => 'Описание', + 'description_placeholder' => 'Описание видео', + 'description_max' => 'Описание YouTube не должно превышать 5000 байт.', + 'description_invalid' => 'Описание YouTube должно быть корректным текстом.', + 'description_bytes' => ':used / :limit байт', + ], 'pinterest' => [ 'settings' => 'Настройки Pinterest', 'posting_to' => 'Публикация в', diff --git a/lang/tr/posts.php b/lang/tr/posts.php index b47e9c531..52f68be2d 100644 --- a/lang/tr/posts.php +++ b/lang/tr/posts.php @@ -144,6 +144,15 @@ 'document_title' => 'Belge başlığı', 'document_title_placeholder' => 'PDF belge gönderinizde gösterilir', ], + 'youtube' => [ + 'settings' => 'YouTube ayarları', + 'posting_to' => 'Yayınlanacak hesap', + 'description' => 'Açıklama', + 'description_placeholder' => 'Video açıklaması', + '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', + ], 'pinterest' => [ 'settings' => 'Pinterest Ayarları', 'posting_to' => 'Şuraya paylaşılıyor', diff --git a/lang/uk/posts.php b/lang/uk/posts.php index 5fc6a6cd2..293a047a7 100644 --- a/lang/uk/posts.php +++ b/lang/uk/posts.php @@ -142,6 +142,15 @@ 'document_title' => 'Назва документа', 'document_title_placeholder' => 'Відображається у вашому пості з PDF-документом', ], + 'youtube' => [ + 'settings' => 'Налаштування YouTube', + 'posting_to' => 'Публікація в', + 'description' => 'Опис', + 'description_placeholder' => 'Опис відео', + 'description_max' => 'Опис YouTube не повинен перевищувати 5000 байтів.', + 'description_invalid' => 'Опис YouTube має бути коректним текстом.', + 'description_bytes' => ':used / :limit байтів', + ], 'pinterest' => [ 'settings' => 'Налаштування Pinterest', 'posting_to' => 'Публікація в', diff --git a/lang/zh/posts.php b/lang/zh/posts.php index 271cfeff9..f42ea1bcb 100644 --- a/lang/zh/posts.php +++ b/lang/zh/posts.php @@ -142,6 +142,15 @@ 'document_title' => '文档标题', 'document_title_placeholder' => '显示在你的 PDF 文档帖子上', ], + 'youtube' => [ + 'settings' => 'YouTube 设置', + 'posting_to' => '发布到', + 'description' => '描述', + 'description_placeholder' => '视频描述', + 'description_max' => 'YouTube 描述不得超过 5,000 个字节。', + 'description_invalid' => 'YouTube 描述必须是有效文本。', + 'description_bytes' => ':used / :limit 字节', + ], 'pinterest' => [ 'settings' => 'Pinterest 设置', 'posting_to' => '发布到', diff --git a/resources/js/components/ChannelConfigurator.vue b/resources/js/components/ChannelConfigurator.vue index 69134079a..1c8706b4f 100644 --- a/resources/js/components/ChannelConfigurator.vue +++ b/resources/js/components/ChannelConfigurator.vue @@ -9,6 +9,7 @@ import InstagramSettings from '@/components/posts/editor/InstagramSettings.vue'; import LinkedInSettings from '@/components/posts/editor/LinkedInSettings.vue'; import PinterestSettings from '@/components/posts/editor/PinterestSettings.vue'; import TikTokSettings from '@/components/posts/editor/TikTokSettings.vue'; +import YouTubeSettings from '@/components/posts/editor/YouTubeSettings.vue'; import { Avatar } from '@/components/ui/avatar'; import { Badge } from '@/components/ui/badge'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; @@ -24,12 +25,10 @@ const props = withDefaults(defineProps<{ media?: MediaItem[]; videoDurationSec?: number | null; disabled?: boolean; - previewOnly?: boolean; }>(), { media: () => [], videoDurationSec: null, disabled: false, - previewOnly: false, }); const emit = defineEmits<{ @@ -172,13 +171,17 @@ const settingsProps = (channel: Channel) => ({ :platform="channel.platform" :media="media" /> + diff --git a/resources/js/components/posts/editor/GoogleBusinessSettings.vue b/resources/js/components/posts/editor/GoogleBusinessSettings.vue index 5cc1f7099..2b2c3a83f 100644 --- a/resources/js/components/posts/editor/GoogleBusinessSettings.vue +++ b/resources/js/components/posts/editor/GoogleBusinessSettings.vue @@ -30,6 +30,7 @@ import { type GoogleBusinessCtaActionValue, type GoogleBusinessTopicTypeValue, } from '@/lib/googleBusiness'; +import { toNullableText } from '@/lib/utils'; interface SocialAccount { id: string; @@ -46,12 +47,10 @@ interface Props { platformIndex: number; meta: Record; disabled?: boolean; - previewOnly?: boolean; } const props = withDefaults(defineProps(), { disabled: false, - previewOnly: false, }); const emit = defineEmits<{ @@ -59,7 +58,6 @@ const emit = defineEmits<{ }>(); const open = ref(false); -const isLocked = computed(() => props.disabled || props.previewOnly); const updateMeta = (patch: Record) => { emit('update:meta', { ...props.meta, ...patch }); @@ -104,13 +102,13 @@ const showEventFields = computed(() => GOOGLE_BUSINESS_EVENT_TOPIC_TYPES.include const ctaUrl = computed({ get: () => props.meta?.call_to_action?.url || '', set: (value: string) => updateMeta({ - call_to_action: { ...props.meta?.call_to_action, url: value.trim() === '' ? null : value }, + call_to_action: { ...props.meta?.call_to_action, url: toNullableText(value) }, }), }); const eventTitle = computed({ get: () => props.meta?.event?.title || '', - set: (value: string) => updateEvent({ title: value.trim() === '' ? null : value }), + set: (value: string) => updateEvent({ title: toNullableText(value) }), }); const eventDateTime = (dateKey: 'start_date' | 'end_date', timeKey: 'start_time' | 'end_time') => computed({ @@ -135,7 +133,7 @@ const eventTitlePlaceholderKey = computed(() => topicType.value === GoogleBusine const offerField = (key: 'coupon_code' | 'redeem_online_url' | 'terms_conditions') => computed({ get: () => props.meta?.offer?.[key] || '', set: (value: string) => updateMeta({ - offer: { ...props.meta?.offer, [key]: value.trim() === '' ? null : value }, + offer: { ...props.meta?.offer, [key]: toNullableText(value) }, }), }); @@ -201,7 +199,7 @@ const ctaUrlError = findError('call_to_action.url'); :class="topicType === type.value ? 'border-foreground bg-violet-100 text-foreground shadow-2xs' : 'border-foreground/30 text-foreground/70 hover:border-foreground hover:text-foreground'" - :disabled="isLocked" + :disabled="disabled" :data-testid="`google-business-topic-${type.value}`" @click="topicType = type.value" > @@ -213,7 +211,7 @@ const ctaUrlError = findError('call_to_action.url');
- +
@@ -222,7 +220,7 @@ const ctaUrlError = findError('call_to_action.url'); v-model="eventStart" align="start" :show-time="true" - :disabled="isLocked" + :disabled="disabled" :placeholder="$t('posts.form.google_business.event_start_date')" :class="eventStartDateError || eventStartTimeError ? 'border-rose-500' : undefined" /> @@ -234,7 +232,7 @@ const ctaUrlError = findError('call_to_action.url'); v-model="eventEnd" align="start" :show-time="true" - :disabled="isLocked" + :disabled="disabled" :placeholder="$t('posts.form.google_business.event_end_date')" :class="eventEndDateError || eventEndTimeError ? 'border-rose-500' : undefined" /> @@ -246,24 +244,24 @@ const ctaUrlError = findError('call_to_action.url');
- +
- +
- +
- @@ -282,7 +280,7 @@ const ctaUrlError = findError('call_to_action.url');
- +
diff --git a/resources/js/components/posts/editor/PinterestSettings.vue b/resources/js/components/posts/editor/PinterestSettings.vue index 11bd7f2d1..aad74d856 100644 --- a/resources/js/components/posts/editor/PinterestSettings.vue +++ b/resources/js/components/posts/editor/PinterestSettings.vue @@ -18,6 +18,7 @@ import { import { Input } from '@/components/ui/input'; import { usePageErrors } from '@/composables/usePageErrors'; import { getPlatformLogo } from '@/composables/usePlatformLogo'; +import { toNullableText } from '@/lib/utils'; import type { PinterestBoard } from '@/types'; import { ContentType } from '@/types/content-type'; import type { MediaItem } from '@/types/media'; @@ -83,16 +84,14 @@ const selectedBoard = computed({ const pinTitle = computed({ get: () => (props.meta?.title as string | undefined) || '', set: (value: string) => { - // Whitespace-only clears the field; keep interior spaces while typing. - emit('update:meta', { ...props.meta, title: value.trim() === '' ? null : value }); + emit('update:meta', { ...props.meta, title: toNullableText(value) }); }, }); const pinLink = computed({ get: () => (props.meta?.link as string | undefined) || '', set: (value: string) => { - // Whitespace-only clears the field; keep partial URLs while typing. - emit('update:meta', { ...props.meta, link: value.trim() === '' ? null : value }); + emit('update:meta', { ...props.meta, link: toNullableText(value) }); }, }); diff --git a/resources/js/components/posts/editor/PreviewTab.vue b/resources/js/components/posts/editor/PreviewTab.vue index 4d78a418e..0a8d9ce78 100644 --- a/resources/js/components/posts/editor/PreviewTab.vue +++ b/resources/js/components/posts/editor/PreviewTab.vue @@ -69,6 +69,7 @@ const activeContentType = computed((): string | undefined => { type="button" class="relative cursor-pointer transition-opacity" :class="activeId === pp.id ? 'opacity-100' : 'opacity-40 hover:opacity-70'" + :data-testid="`preview-platform-${pp.id}`" @click="activeId = pp.id" > +import { IconChevronDown, IconChevronUp } from '@tabler/icons-vue'; +import { trans } from 'laravel-vue-i18n'; +import { computed, ref } from 'vue'; + +import InputError from '@/components/InputError.vue'; +import { Avatar } from '@/components/ui/avatar'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { usePageErrors } from '@/composables/usePageErrors'; +import { getPlatformLogo } from '@/composables/usePlatformLogo'; +import { toNullableText } from '@/lib/utils'; +import { + getYouTubeDescriptionIssue, + YOUTUBE_DESCRIPTION_MAX_BYTES, + youtubeDescriptionBytes, +} from '@/lib/youtubeDescription'; +import type { ChannelAccount } from '@/types/channel'; +import { Platform } from '@/types/platform'; + +interface Props { + socialAccount: ChannelAccount | null; + platformIndex: number; + meta: Record; + disabled?: boolean; +} + +const props = withDefaults(defineProps(), { + disabled: false, +}); +const emit = defineEmits<{ + 'update:meta': [value: Record]; +}>(); + +const open = ref(false); +const errors = usePageErrors(); +const descriptionId = computed(() => `youtube-description-${props.platformIndex}`); + +const description = computed({ + get: () => toNullableText(props.meta.description) ?? '', + set: (value: string) => + emit('update:meta', { + ...props.meta, + description: toNullableText(value), + }), +}); +const usedBytes = computed(() => youtubeDescriptionBytes(description.value)); +const descriptionError = computed(() => { + const issue = getYouTubeDescriptionIssue(props.meta.description); + + return issue + ? trans(issue) + : errors.value[`platforms.${props.platformIndex}.meta.description`]; +}); + + +