diff --git a/app/Actions/Post/UpdatePost.php b/app/Actions/Post/UpdatePost.php index 3b0dd632e..5236c7c24 100644 --- a/app/Actions/Post/UpdatePost.php +++ b/app/Actions/Post/UpdatePost.php @@ -49,20 +49,20 @@ public static function execute(Workspace $workspace, Post $post, array $data): a foreach (data_get($data, 'platforms', []) as $platformData) { $updateData = ['enabled' => true]; + $needsExistingRow = data_get($platformData, 'meta') !== null || Arr::has($platformData, 'media_ids'); + $postPlatform = $needsExistingRow + ? $post->postPlatforms()->where('id', data_get($platformData, 'id'))->first() + : null; if (data_get($platformData, 'content_type') !== null) { $updateData['content_type'] = data_get($platformData, 'content_type'); } - if (data_get($platformData, 'meta') !== null) { - $postPlatform = $post->postPlatforms()->where('id', data_get($platformData, 'id'))->first(); - - if ($postPlatform) { - $updateData['meta'] = array_filter( - array_merge($postPlatform->meta ?? [], data_get($platformData, 'meta') ?? []), - fn (mixed $value): bool => $value !== null, - ); - } + if (data_get($platformData, 'meta') !== null && $postPlatform) { + $updateData['meta'] = array_filter( + array_merge($postPlatform->meta ?? [], data_get($platformData, 'meta') ?? []), + fn (mixed $value): bool => $value !== null, + ); } $post->postPlatforms() @@ -74,11 +74,11 @@ public static function execute(Workspace $workspace, Post $post, array $data): a // client that never sends it must not accidentally clear every // platform's scoping), while an explicit empty array clears it // back to "publish every media item on the post" (see - // PostPlatform::scopedMediaItems()). - if (Arr::has($platformData, 'media_ids')) { - $postPlatform = $post->postPlatforms()->where('id', data_get($platformData, 'id'))->first(); - - $postPlatform?->media()->sync(data_get($platformData, 'media_ids', [])); + // PostPlatform::scopedMediaItems()). $postPlatform is already + // resolved above via $needsExistingRow when media_ids is present, + // no need to re-fetch it here. + if (Arr::has($platformData, 'media_ids') && $postPlatform) { + $postPlatform->media()->sync(data_get($platformData, 'media_ids', [])); } } } diff --git a/app/Http/Controllers/App/PostController.php b/app/Http/Controllers/App/PostController.php index 9b0702eb8..a8f816d1a 100644 --- a/app/Http/Controllers/App/PostController.php +++ b/app/Http/Controllers/App/PostController.php @@ -248,7 +248,13 @@ public function edit(Request $request, Post $post): Response|RedirectResponse SyncPostPlatforms::execute($post); } - $post->load(['postPlatforms.socialAccount', 'labels']); + $post->load(['postPlatforms.socialAccount', 'postPlatforms.media', 'labels']); + + // `media_ids` is only appended here, not globally on the model, so the + // Index/Calendar listings (which don't eager-load `media`) don't take + // an unwanted lazy-load hit on every post_platform they render. + $post->postPlatforms->each(fn (PostPlatform $postPlatform) => $postPlatform->append('media_ids')); + $socialAccounts = $workspace->socialAccounts()->active()->get(); $labels = $workspace->labels; $signatures = $workspace->signatures; diff --git a/app/Http/Requests/App/Post/UpdatePostRequest.php b/app/Http/Requests/App/Post/UpdatePostRequest.php index 81e1e6666..da83b2f3d 100644 --- a/app/Http/Requests/App/Post/UpdatePostRequest.php +++ b/app/Http/Requests/App/Post/UpdatePostRequest.php @@ -55,6 +55,11 @@ public function rules(): array 'string', Rule::in(array_column(ContentType::cases(), 'value')), ], + // Per-platform media assignment (Edit.vue's media toggle grid). An + // id must be one of the media items this same request is saving on + // the post, never an arbitrary/unrelated media id (IDOR guard). + 'platforms.*.media_ids' => ['sometimes', 'array'], + 'platforms.*.media_ids.*' => ['string', Rule::in($this->postMediaIds())], ...PostPlatformMetaRules::rules(), 'label_ids' => ['sometimes', 'array'], 'label_ids.*' => ['uuid', Rule::exists('workspace_labels', 'id')->where('workspace_id', $this->user()->currentWorkspace->id)], @@ -150,6 +155,17 @@ private function addMediaCompatibilityErrors(Validator $validator): void } } + /** + * The media ids this request is saving on the post itself, the only ids + * a `platforms.*.media_ids` entry is allowed to reference. + * + * @return array + */ + private function postMediaIds(): array + { + return collect($this->input('media', []))->pluck('id')->filter()->all(); + } + /** * @return Collection */ diff --git a/app/Models/PostPlatform.php b/app/Models/PostPlatform.php index 28b4e977c..b8110d211 100644 --- a/app/Models/PostPlatform.php +++ b/app/Models/PostPlatform.php @@ -104,15 +104,30 @@ public function scopedMediaItems(): Collection } /** - * Only platforms still enabled for publishing — disabled ones are + * Only platforms still enabled for publishing (disabled ones are * excluded from PublishPost, so anything else that mirrors publish - * eligibility (previews, validation, proactive checks) must too. + * eligibility (previews, validation, proactive checks) must too). */ public function scopeEnabled(Builder $query): Builder { return $query->where('post_platforms.enabled', true); } + /** + * The ids of media items currently scoped to this platform. Empty means + * "no scoping, applies to every post media item", see scopedMediaItems(). + * Only used for the editor's per-platform media assignment UI, so this + * relies on `media` already being eager-loaded (or explicitly appended + * via `append('media_ids')`, see PostController::edit()) to avoid a lazy + * load nobody asked for on every other page that serializes this model. + * + * @return array + */ + public function getMediaIdsAttribute(): array + { + return $this->media->pluck('id')->all(); + } + /** * Get display name, falling back to snapshot if account was deleted. */ diff --git a/lang/ar/posts.php b/lang/ar/posts.php index 43445d185..b57bd1588 100644 --- a/lang/ar/posts.php +++ b/lang/ar/posts.php @@ -344,6 +344,11 @@ 'drop_zone_subtitle' => 'اسحب الملفات وأفلتها أو انقر للتصفح', 'add' => 'إضافة', 'publish_to' => 'النشر إلى', + 'media_assignment' => [ + 'label' => 'الوسائط لهذه القناة', + 'included' => 'مضمّن لهذه القناة', + 'excluded' => 'مستبعد من هذه القناة', + ], 'organize' => 'تنظيم', 'signatures' => 'التوقيعات', 'view_on_platform' => 'العرض على المنصة', diff --git a/lang/de/posts.php b/lang/de/posts.php index 481bc0c33..5bfde2ed6 100644 --- a/lang/de/posts.php +++ b/lang/de/posts.php @@ -346,6 +346,11 @@ 'drop_zone_subtitle' => 'Dateien ziehen & ablegen oder zum Durchsuchen klicken', 'add' => 'Hinzufügen', 'publish_to' => 'Veröffentlichen auf', + 'media_assignment' => [ + 'label' => 'Medien für diesen Kanal', + 'included' => 'Enthalten für diesen Kanal', + 'excluded' => 'Ausgeschlossen von diesem Kanal', + ], 'organize' => 'Organisieren', 'signatures' => 'Signaturen', 'view_on_platform' => 'Auf der Plattform ansehen', diff --git a/lang/el/posts.php b/lang/el/posts.php index dbf866950..dd4c7b1ff 100644 --- a/lang/el/posts.php +++ b/lang/el/posts.php @@ -344,6 +344,11 @@ 'drop_zone_subtitle' => 'Σύρετε και αποθέστε αρχεία ή κάντε κλικ για αναζήτηση', 'add' => 'Προσθήκη', 'publish_to' => 'Δημοσίευση σε', + 'media_assignment' => [ + 'label' => 'Πολυμέσα για αυτό το κανάλι', + 'included' => 'Περιλαμβάνεται σε αυτό το κανάλι', + 'excluded' => 'Εξαιρείται από αυτό το κανάλι', + ], 'organize' => 'Οργάνωση', 'signatures' => 'Υπογραφές', 'view_on_platform' => 'Προβολή στην πλατφόρμα', diff --git a/lang/en/posts.php b/lang/en/posts.php index 4add724af..00f622e33 100644 --- a/lang/en/posts.php +++ b/lang/en/posts.php @@ -344,6 +344,11 @@ 'drop_zone_subtitle' => 'Drag & drop files or click to browse', 'add' => 'Add', 'publish_to' => 'Publish to', + 'media_assignment' => [ + 'label' => 'Media for this channel', + 'included' => 'Included for this channel', + 'excluded' => 'Excluded from this channel', + ], 'organize' => 'Organize', 'signatures' => 'Signatures', 'view_on_platform' => 'View on platform', diff --git a/lang/es/posts.php b/lang/es/posts.php index 7eb08c207..0c22d09ea 100644 --- a/lang/es/posts.php +++ b/lang/es/posts.php @@ -338,6 +338,11 @@ 'drop_zone_subtitle' => 'Arrastra archivos o haz clic para seleccionar', 'add' => 'Añadir', 'publish_to' => 'Publicar en', + 'media_assignment' => [ + 'label' => 'Contenido para este canal', + 'included' => 'Incluido en este canal', + 'excluded' => 'Excluido de este canal', + ], 'organize' => 'Organizar', 'no_labels' => 'Todavía no hay etiquetas creadas', 'pick_time' => 'Elegir hora', diff --git a/lang/fr/posts.php b/lang/fr/posts.php index 183ae393e..a6a640c9c 100644 --- a/lang/fr/posts.php +++ b/lang/fr/posts.php @@ -344,6 +344,11 @@ 'drop_zone_subtitle' => 'Glissez-déposez des fichiers ou cliquez pour parcourir', 'add' => 'Ajouter', 'publish_to' => 'Publier sur', + 'media_assignment' => [ + 'label' => 'Contenu pour ce canal', + 'included' => 'Inclus pour ce canal', + 'excluded' => 'Exclu de ce canal', + ], 'organize' => 'Organiser', 'signatures' => 'Signatures', 'view_on_platform' => 'Voir sur la plateforme', diff --git a/lang/it/posts.php b/lang/it/posts.php index f6b3198e9..e093dbc52 100644 --- a/lang/it/posts.php +++ b/lang/it/posts.php @@ -344,6 +344,11 @@ 'drop_zone_subtitle' => 'Trascina e rilascia i file oppure clicca per sfogliare', 'add' => 'Aggiungi', 'publish_to' => 'Pubblica su', + 'media_assignment' => [ + 'label' => 'Contenuti per questo canale', + 'included' => 'Incluso per questo canale', + 'excluded' => 'Escluso da questo canale', + ], 'organize' => 'Organizza', 'signatures' => 'Firme', 'view_on_platform' => 'Visualizza sulla piattaforma', diff --git a/lang/ja/posts.php b/lang/ja/posts.php index 731c23ebf..aad9de7bf 100644 --- a/lang/ja/posts.php +++ b/lang/ja/posts.php @@ -344,6 +344,11 @@ 'drop_zone_subtitle' => 'ファイルをドラッグ&ドロップするか、クリックして参照', 'add' => '追加', 'publish_to' => '公開先', + 'media_assignment' => [ + 'label' => 'このチャンネルのメディア', + 'included' => 'このチャンネルに含まれています', + 'excluded' => 'このチャンネルから除外されています', + ], 'organize' => '整理', 'signatures' => '署名', 'view_on_platform' => 'プラットフォームで表示', diff --git a/lang/ko/posts.php b/lang/ko/posts.php index f93cd8564..934c59e54 100644 --- a/lang/ko/posts.php +++ b/lang/ko/posts.php @@ -344,6 +344,11 @@ 'drop_zone_subtitle' => '파일을 끌어다 놓거나 클릭하여 찾아보기', 'add' => '추가', 'publish_to' => '게시 대상', + 'media_assignment' => [ + 'label' => '이 채널의 미디어', + 'included' => '이 채널에 포함됨', + 'excluded' => '이 채널에서 제외됨', + ], 'organize' => '정리', 'signatures' => '서명', 'view_on_platform' => '플랫폼에서 보기', diff --git a/lang/nl/posts.php b/lang/nl/posts.php index 63c37c615..4a97b382c 100644 --- a/lang/nl/posts.php +++ b/lang/nl/posts.php @@ -344,6 +344,11 @@ 'drop_zone_subtitle' => 'Sleep bestanden hierheen of klik om te bladeren', 'add' => 'Toevoegen', 'publish_to' => 'Publiceren naar', + 'media_assignment' => [ + 'label' => 'Media voor dit kanaal', + 'included' => 'Inbegrepen voor dit kanaal', + 'excluded' => 'Uitgesloten van dit kanaal', + ], 'organize' => 'Organiseren', 'signatures' => 'Handtekeningen', 'view_on_platform' => 'Bekijken op platform', diff --git a/lang/pl/posts.php b/lang/pl/posts.php index e397a7474..45f5c37d9 100644 --- a/lang/pl/posts.php +++ b/lang/pl/posts.php @@ -344,6 +344,11 @@ 'drop_zone_subtitle' => 'Przeciągnij i upuść pliki lub kliknij, aby przeglądać', 'add' => 'Dodaj', 'publish_to' => 'Publikuj na', + 'media_assignment' => [ + 'label' => 'Media dla tego kanału', + 'included' => 'Uwzględnione dla tego kanału', + 'excluded' => 'Wykluczone z tego kanału', + ], 'organize' => 'Organizuj', 'signatures' => 'Sygnatury', 'view_on_platform' => 'Zobacz na platformie', diff --git a/lang/pt-BR/posts.php b/lang/pt-BR/posts.php index 37018fac1..e522bc35e 100644 --- a/lang/pt-BR/posts.php +++ b/lang/pt-BR/posts.php @@ -338,6 +338,11 @@ 'drop_zone_subtitle' => 'Arraste arquivos ou clique para selecionar', 'add' => 'Adicionar', 'publish_to' => 'Publicar em', + 'media_assignment' => [ + 'label' => 'Mídia para este canal', + 'included' => 'Incluído neste canal', + 'excluded' => 'Excluído deste canal', + ], 'organize' => 'Organizar', 'no_labels' => 'Nenhuma etiqueta criada ainda', 'pick_time' => 'Escolher horário', diff --git a/lang/ru/posts.php b/lang/ru/posts.php index 947601df7..34f664974 100644 --- a/lang/ru/posts.php +++ b/lang/ru/posts.php @@ -344,6 +344,11 @@ 'drop_zone_subtitle' => 'Перетащите файлы или нажмите для выбора', 'add' => 'Добавить', 'publish_to' => 'Опубликовать в', + 'media_assignment' => [ + 'label' => 'Медиа для этого канала', + 'included' => 'Включено для этого канала', + 'excluded' => 'Исключено из этого канала', + ], 'organize' => 'Организовать', 'signatures' => 'Подписи', 'view_on_platform' => 'Открыть на платформе', diff --git a/lang/tr/posts.php b/lang/tr/posts.php index 5faa9a22f..612b8c160 100644 --- a/lang/tr/posts.php +++ b/lang/tr/posts.php @@ -346,6 +346,11 @@ 'drop_zone_subtitle' => 'Dosyaları sürükleyip bırakın veya göz atmak için tıklayın', 'add' => 'Ekle', 'publish_to' => 'Şuraya yayınla', + 'media_assignment' => [ + 'label' => 'Bu kanal için medya', + 'included' => 'Bu kanala dahil', + 'excluded' => 'Bu kanaldan hariç', + ], 'organize' => 'Düzenle', 'signatures' => 'İmzalar', 'view_on_platform' => 'Platformda görüntüle', diff --git a/lang/uk/posts.php b/lang/uk/posts.php index 4ee02d9fe..9df90af7c 100644 --- a/lang/uk/posts.php +++ b/lang/uk/posts.php @@ -344,6 +344,11 @@ 'drop_zone_subtitle' => 'Перетягніть файли або натисніть, щоб обрати', 'add' => 'Додати', 'publish_to' => 'Опублікувати в', + 'media_assignment' => [ + 'label' => 'Медіа для цього каналу', + 'included' => 'Включено для цього каналу', + 'excluded' => 'Виключено з цього каналу', + ], 'organize' => 'Організувати', 'signatures' => 'Підписи', 'view_on_platform' => 'Переглянути на платформі', diff --git a/lang/zh/posts.php b/lang/zh/posts.php index c8e519378..47ed5487d 100644 --- a/lang/zh/posts.php +++ b/lang/zh/posts.php @@ -344,6 +344,11 @@ 'drop_zone_subtitle' => '拖放文件或点击浏览', 'add' => '添加', 'publish_to' => '发布到', + 'media_assignment' => [ + 'label' => '此频道的媒体', + 'included' => '已包含在此频道', + 'excluded' => '已从此频道排除', + ], 'organize' => '整理', 'signatures' => '签名', 'view_on_platform' => '在平台上查看', diff --git a/resources/js/components/ChannelConfigurator.vue b/resources/js/components/ChannelConfigurator.vue index 813d5dff0..99834f69e 100644 --- a/resources/js/components/ChannelConfigurator.vue +++ b/resources/js/components/ChannelConfigurator.vue @@ -6,6 +6,7 @@ import DiscordSettings from '@/components/posts/editor/DiscordSettings.vue'; import FacebookSettings from '@/components/posts/editor/FacebookSettings.vue'; import InstagramSettings from '@/components/posts/editor/InstagramSettings.vue'; import LinkedInSettings from '@/components/posts/editor/LinkedInSettings.vue'; +import MediaAssignmentGrid from '@/components/posts/editor/MediaAssignmentGrid.vue'; import PinterestSettings from '@/components/posts/editor/PinterestSettings.vue'; import TikTokSettings from '@/components/posts/editor/TikTokSettings.vue'; import { Avatar } from '@/components/ui/avatar'; @@ -33,6 +34,7 @@ const emit = defineEmits<{ toggle: [id: string]; 'update:contentType': [id: string, value: string]; 'update:meta': [id: string, value: Record]; + 'update:mediaIds': [id: string, mediaIds: string[]]; }>(); const isSelected = (id: string): boolean => props.selectedIds.includes(id); @@ -121,6 +123,13 @@ const settingsProps = (channel: Channel) => ({