From f5eaddcce98144c9e39ecb8d265b208992e38853 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Sat, 5 Sep 2026 15:33:13 -0300 Subject: [PATCH 001/114] Add repurpose schema, models and source fetchers Two tables: repurposes (source account, destinations, status, poll watermark) and repurpose_items (one row per source video seen, with the skip or failure reason). Posts gain a nullable repurpose_item_id so every generated post traces back to the video it came from. A source account maps to exactly one repurpose (unique per workspace and account, never per network), so each account is polled once per cycle. InstagramSourceFetcher and FacebookSourceFetcher list recent media for a connected account; classification of what to skip belongs to the caller. --- app/Enums/Post/CreatedVia.php | 1 + app/Enums/Repurpose/ItemReason.php | 14 +++ app/Enums/Repurpose/ItemStatus.php | 19 ++++ app/Enums/Repurpose/Status.php | 13 +++ app/Models/Repurpose.php | 66 +++++++++++++ app/Models/RepurposeItem.php | 49 ++++++++++ app/Providers/AppServiceProvider.php | 4 + .../Repurpose/FacebookSourceFetcher.php | 47 +++++++++ .../Repurpose/InstagramSourceFetcher.php | 57 +++++++++++ app/Services/Repurpose/SourceFetcher.php | 19 ++++ .../Repurpose/SourceFetcherFactory.php | 29 ++++++ app/Services/Repurpose/SourceMedia.php | 19 ++++ database/factories/RepurposeFactory.php | 57 +++++++++++ database/factories/RepurposeItemFactory.php | 32 +++++++ ...6_09_05_183105_create_repurposes_table.php | 35 +++++++ ...05_183106_create_repurpose_items_table.php | 33 +++++++ ...8_add_repurpose_item_id_to_posts_table.php | 24 +++++ .../Feature/Repurpose/RepurposeModelTest.php | 63 ++++++++++++ tests/Feature/Repurpose/SourceFetcherTest.php | 96 +++++++++++++++++++ 19 files changed, 677 insertions(+) create mode 100644 app/Enums/Repurpose/ItemReason.php create mode 100644 app/Enums/Repurpose/ItemStatus.php create mode 100644 app/Enums/Repurpose/Status.php create mode 100644 app/Models/Repurpose.php create mode 100644 app/Models/RepurposeItem.php create mode 100644 app/Services/Repurpose/FacebookSourceFetcher.php create mode 100644 app/Services/Repurpose/InstagramSourceFetcher.php create mode 100644 app/Services/Repurpose/SourceFetcher.php create mode 100644 app/Services/Repurpose/SourceFetcherFactory.php create mode 100644 app/Services/Repurpose/SourceMedia.php create mode 100644 database/factories/RepurposeFactory.php create mode 100644 database/factories/RepurposeItemFactory.php create mode 100644 database/migrations/2026_09_05_183105_create_repurposes_table.php create mode 100644 database/migrations/2026_09_05_183106_create_repurpose_items_table.php create mode 100644 database/migrations/2026_09_05_183108_add_repurpose_item_id_to_posts_table.php create mode 100644 tests/Feature/Repurpose/RepurposeModelTest.php create mode 100644 tests/Feature/Repurpose/SourceFetcherTest.php diff --git a/app/Enums/Post/CreatedVia.php b/app/Enums/Post/CreatedVia.php index 72794f514..53f61a3cd 100644 --- a/app/Enums/Post/CreatedVia.php +++ b/app/Enums/Post/CreatedVia.php @@ -9,4 +9,5 @@ enum CreatedVia: string case Web = 'web'; case Mcp = 'mcp'; case Api = 'api'; + case Repurpose = 'repurpose'; } diff --git a/app/Enums/Repurpose/ItemReason.php b/app/Enums/Repurpose/ItemReason.php new file mode 100644 index 000000000..a1d459df2 --- /dev/null +++ b/app/Enums/Repurpose/ItemReason.php @@ -0,0 +1,14 @@ + */ + use HasFactory, HasUuids; + + protected $fillable = [ + 'workspace_id', + 'user_id', + 'source_social_account_id', + 'destinations', + 'status', + 'activated_at', + 'last_polled_at', + 'next_poll_at', + 'last_error', + ]; + + protected $attributes = [ + 'destinations' => '[]', + ]; + + protected function casts(): array + { + return [ + 'destinations' => 'array', + 'status' => Status::class, + 'activated_at' => 'datetime', + 'last_polled_at' => 'datetime', + 'next_poll_at' => 'datetime', + ]; + } + + public function workspace(): BelongsTo + { + return $this->belongsTo(Workspace::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function sourceAccount(): BelongsTo + { + return $this->belongsTo(SocialAccount::class, 'source_social_account_id'); + } + + public function items(): HasMany + { + return $this->hasMany(RepurposeItem::class); + } +} diff --git a/app/Models/RepurposeItem.php b/app/Models/RepurposeItem.php new file mode 100644 index 000000000..5b0615c1e --- /dev/null +++ b/app/Models/RepurposeItem.php @@ -0,0 +1,49 @@ + */ + use HasFactory, HasUuids; + + protected $fillable = [ + 'repurpose_id', + 'source_media_id', + 'source_permalink', + 'source_created_at', + 'status', + 'reason', + 'error', + ]; + + protected function casts(): array + { + return [ + 'status' => ItemStatus::class, + 'reason' => ItemReason::class, + 'source_created_at' => 'datetime', + ]; + } + + public function repurpose(): BelongsTo + { + return $this->belongsTo(Repurpose::class); + } + + public function posts(): HasMany + { + return $this->hasMany(Post::class); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 9bcef4afe..9114a0cee 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -16,6 +16,8 @@ use App\Models\Post; use App\Models\PostComment; use App\Models\PostPlatform; +use App\Models\Repurpose; +use App\Models\RepurposeItem; use App\Models\SocialAccount; use App\Models\Subscription; use App\Models\SubscriptionItem; @@ -101,6 +103,8 @@ protected function configureMorphMap(): void 'notificationPreference' => NotificationPreference::class, 'plan' => Plan::class, 'post' => Post::class, + 'repurpose' => Repurpose::class, + 'repurposeItem' => RepurposeItem::class, 'postComment' => PostComment::class, 'postPlatform' => PostPlatform::class, 'socialAccount' => SocialAccount::class, diff --git a/app/Services/Repurpose/FacebookSourceFetcher.php b/app/Services/Repurpose/FacebookSourceFetcher.php new file mode 100644 index 000000000..a5efd3ff4 --- /dev/null +++ b/app/Services/Repurpose/FacebookSourceFetcher.php @@ -0,0 +1,47 @@ + + */ + public function fetch(SocialAccount $account, ?CarbonInterface $since): array + { + $graphApi = config('trypost.platforms.facebook.graph_api'); + + $response = Http::withToken($account->access_token) + ->get("{$graphApi}/{$account->platform_user_id}/videos", array_filter([ + 'fields' => self::FIELDS, + 'limit' => 50, + 'since' => $since?->getTimestamp(), + ])); + + if ($response->failed()) { + throw new RuntimeException((string) data_get($response->json(), 'error.message', $response->body())); + } + + return array_map( + fn (array $row): SourceMedia => new SourceMedia( + id: (string) data_get($row, 'id'), + isVideo: true, + downloadUrl: data_get($row, 'source'), + caption: (string) data_get($row, 'description', ''), + permalink: data_get($row, 'permalink_url'), + createdAt: ($createdTime = data_get($row, 'created_time')) ? Carbon::parse($createdTime) : null, + ), + (array) $response->json('data', []), + ); + } +} diff --git a/app/Services/Repurpose/InstagramSourceFetcher.php b/app/Services/Repurpose/InstagramSourceFetcher.php new file mode 100644 index 000000000..fe9b0bfa4 --- /dev/null +++ b/app/Services/Repurpose/InstagramSourceFetcher.php @@ -0,0 +1,57 @@ + + */ + public function fetch(SocialAccount $account, ?CarbonInterface $since): array + { + $response = Http::withToken($account->access_token) + ->get("{$this->graphApi($account)}/{$account->platform_user_id}/media", array_filter([ + 'fields' => self::FIELDS, + 'limit' => 50, + 'since' => $since?->getTimestamp(), + ])); + + if ($response->failed()) { + throw new RuntimeException((string) data_get($response->json(), 'error.message', $response->body())); + } + + return array_map( + fn (array $row): SourceMedia => new SourceMedia( + id: (string) data_get($row, 'id'), + isVideo: data_get($row, 'media_type') === 'VIDEO', + downloadUrl: data_get($row, 'media_url'), + caption: (string) data_get($row, 'caption', ''), + permalink: data_get($row, 'permalink'), + createdAt: ($timestamp = data_get($row, 'timestamp')) ? Carbon::parse($timestamp) : null, + ), + (array) $response->json('data', []), + ); + } + + /** + * A direct Instagram login talks to the Instagram graph host; an account + * connected through a Facebook Page talks to the Facebook one. + */ + private function graphApi(SocialAccount $account): string + { + return $account->platform === Platform::InstagramFacebook + ? config('trypost.platforms.instagram-facebook.graph_api') + : config('trypost.platforms.instagram.graph_api'); + } +} diff --git a/app/Services/Repurpose/SourceFetcher.php b/app/Services/Repurpose/SourceFetcher.php new file mode 100644 index 000000000..82a3747ce --- /dev/null +++ b/app/Services/Repurpose/SourceFetcher.php @@ -0,0 +1,19 @@ + + */ + public function fetch(SocialAccount $account, ?CarbonInterface $since): array; +} diff --git a/app/Services/Repurpose/SourceFetcherFactory.php b/app/Services/Repurpose/SourceFetcherFactory.php new file mode 100644 index 000000000..4f782fc83 --- /dev/null +++ b/app/Services/Repurpose/SourceFetcherFactory.php @@ -0,0 +1,29 @@ +platform) { + Platform::Instagram, Platform::InstagramFacebook => app(InstagramSourceFetcher::class), + Platform::Facebook => app(FacebookSourceFetcher::class), + default => throw new InvalidArgumentException("{$account->platform->value} cannot be a repurpose source."), + }; + } + + /** + * @return array + */ + public static function supportedPlatforms(): array + { + return [Platform::Instagram, Platform::InstagramFacebook, Platform::Facebook]; + } +} diff --git a/app/Services/Repurpose/SourceMedia.php b/app/Services/Repurpose/SourceMedia.php new file mode 100644 index 000000000..fcaf18ad9 --- /dev/null +++ b/app/Services/Repurpose/SourceMedia.php @@ -0,0 +1,19 @@ + + */ +class RepurposeFactory extends Factory +{ + protected $model = Repurpose::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'workspace_id' => Workspace::factory(), + 'user_id' => User::factory(), + 'source_social_account_id' => SocialAccount::factory(), + 'destinations' => [], + 'status' => Status::Draft, + ]; + } + + public function active(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => Status::Active, + 'activated_at' => now(), + ]); + } + + public function paused(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => Status::Paused, + 'activated_at' => now()->subDay(), + ]); + } + + public function disabled(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => Status::Disabled, + ]); + } +} diff --git a/database/factories/RepurposeItemFactory.php b/database/factories/RepurposeItemFactory.php new file mode 100644 index 000000000..943ea672f --- /dev/null +++ b/database/factories/RepurposeItemFactory.php @@ -0,0 +1,32 @@ + + */ +class RepurposeItemFactory extends Factory +{ + protected $model = RepurposeItem::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'repurpose_id' => Repurpose::factory(), + 'source_media_id' => fake()->uuid(), + 'source_permalink' => fake()->url(), + 'source_created_at' => now()->subMinutes(10), + 'status' => ItemStatus::Pending, + ]; + } +} diff --git a/database/migrations/2026_09_05_183105_create_repurposes_table.php b/database/migrations/2026_09_05_183105_create_repurposes_table.php new file mode 100644 index 000000000..d381964e3 --- /dev/null +++ b/database/migrations/2026_09_05_183105_create_repurposes_table.php @@ -0,0 +1,35 @@ +uuid('id')->primary(); + $table->foreignUuid('workspace_id')->constrained('workspaces')->cascadeOnDelete(); + $table->foreignUuid('user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->foreignUuid('source_social_account_id')->constrained('social_accounts')->cascadeOnDelete(); + $table->json('destinations'); + $table->string('status'); + $table->timestamp('activated_at')->nullable(); + $table->timestamp('last_polled_at')->nullable(); + $table->timestamp('next_poll_at')->nullable(); + $table->text('last_error')->nullable(); + $table->timestamps(); + + $table->unique(['workspace_id', 'source_social_account_id']); + $table->index(['status', 'next_poll_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('repurposes'); + } +}; diff --git a/database/migrations/2026_09_05_183106_create_repurpose_items_table.php b/database/migrations/2026_09_05_183106_create_repurpose_items_table.php new file mode 100644 index 000000000..5c0617de3 --- /dev/null +++ b/database/migrations/2026_09_05_183106_create_repurpose_items_table.php @@ -0,0 +1,33 @@ +uuid('id')->primary(); + $table->foreignUuid('repurpose_id')->constrained('repurposes')->cascadeOnDelete(); + $table->string('source_media_id'); + $table->text('source_permalink')->nullable(); + $table->timestamp('source_created_at')->nullable(); + $table->string('status'); + $table->string('reason')->nullable(); + $table->text('error')->nullable(); + $table->timestamps(); + + $table->unique(['repurpose_id', 'source_media_id']); + $table->index(['repurpose_id', 'status']); + }); + } + + public function down(): void + { + Schema::dropIfExists('repurpose_items'); + } +}; diff --git a/database/migrations/2026_09_05_183108_add_repurpose_item_id_to_posts_table.php b/database/migrations/2026_09_05_183108_add_repurpose_item_id_to_posts_table.php new file mode 100644 index 000000000..1759f9acb --- /dev/null +++ b/database/migrations/2026_09_05_183108_add_repurpose_item_id_to_posts_table.php @@ -0,0 +1,24 @@ +foreignUuid('repurpose_item_id')->nullable()->after('created_via')->constrained('repurpose_items')->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::table('posts', function (Blueprint $table) { + $table->dropConstrainedForeignId('repurpose_item_id'); + }); + } +}; diff --git a/tests/Feature/Repurpose/RepurposeModelTest.php b/tests/Feature/Repurpose/RepurposeModelTest.php new file mode 100644 index 000000000..786ed8f3a --- /dev/null +++ b/tests/Feature/Repurpose/RepurposeModelTest.php @@ -0,0 +1,63 @@ +create(); + $item = RepurposeItem::factory()->for($repurpose)->create(); + + expect($repurpose->status)->toBe(Status::Draft) + ->and($repurpose->workspace)->not->toBeNull() + ->and($repurpose->sourceAccount)->not->toBeNull() + ->and($repurpose->items->pluck('id')->all())->toBe([$item->id]) + ->and($item->status)->toBe(ItemStatus::Pending); +}); + +test('destinations round-trip as an array', function () { + $destinations = [ + ['social_account_id' => (string) Str::uuid(), 'content_type' => 'tiktok_video', 'meta' => ['privacy_level' => 'PUBLIC_TO_EVERYONE']], + ]; + + $repurpose = Repurpose::factory()->create(['destinations' => $destinations]); + + expect($repurpose->fresh()->destinations)->toEqual($destinations); +}); + +test('the same source media id cannot be logged twice for one repurpose', function () { + $repurpose = Repurpose::factory()->create(); + RepurposeItem::factory()->for($repurpose)->create(['source_media_id' => 'media-1']); + + expect(fn () => RepurposeItem::factory()->for($repurpose)->create(['source_media_id' => 'media-1'])) + ->toThrow(QueryException::class); +}); + +test('a workspace cannot have two repurposes for the same source account', function () { + $repurpose = Repurpose::factory()->create(); + + expect(fn () => Repurpose::factory()->create([ + 'workspace_id' => $repurpose->workspace_id, + 'source_social_account_id' => $repurpose->source_social_account_id, + ]))->toThrow(QueryException::class); +}); + +test('a workspace can have one repurpose per connected account of the same network', function () { + config()->set('trypost.allow_multiple_social_accounts', true); + + $workspace = Workspace::factory()->create(); + $first = SocialAccount::factory()->for($workspace)->create(); + $second = SocialAccount::factory()->for($workspace)->create(); + + Repurpose::factory()->create(['workspace_id' => $workspace->id, 'source_social_account_id' => $first->id]); + Repurpose::factory()->create(['workspace_id' => $workspace->id, 'source_social_account_id' => $second->id]); + + expect(Repurpose::where('workspace_id', $workspace->id)->count())->toBe(2); +}); diff --git a/tests/Feature/Repurpose/SourceFetcherTest.php b/tests/Feature/Repurpose/SourceFetcherTest.php new file mode 100644 index 000000000..fa50f4c6e --- /dev/null +++ b/tests/Feature/Repurpose/SourceFetcherTest.php @@ -0,0 +1,96 @@ + Http::response(['data' => [ + ['id' => 'm1', 'media_type' => 'VIDEO', 'media_url' => 'https://cdn.example.com/v1.mp4', 'caption' => 'Hello', 'permalink' => 'https://instagram.com/p/1', 'timestamp' => '2026-09-01T10:00:00+0000'], + ['id' => 'm2', 'media_type' => 'IMAGE', 'media_url' => 'https://cdn.example.com/i.jpg', 'caption' => 'Pic', 'permalink' => 'https://instagram.com/p/2', 'timestamp' => '2026-09-01T11:00:00+0000'], + ['id' => 'm3', 'media_type' => 'VIDEO', 'caption' => 'Copyrighted', 'permalink' => 'https://instagram.com/p/3', 'timestamp' => '2026-09-01T12:00:00+0000'], + ]]), + ]); + + $account = SocialAccount::factory()->create(['platform' => Platform::Instagram]); + + $media = app(SourceFetcherFactory::class)->for($account)->fetch($account, null); + + expect($media)->toHaveCount(3) + ->and($media[0]->id)->toBe('m1') + ->and($media[0]->isVideo)->toBeTrue() + ->and($media[0]->downloadUrl)->toBe('https://cdn.example.com/v1.mp4') + ->and($media[0]->caption)->toBe('Hello') + ->and($media[0]->permalink)->toBe('https://instagram.com/p/1') + ->and($media[0]->createdAt?->toDateString())->toBe('2026-09-01') + ->and($media[1]->isVideo)->toBeFalse() + ->and($media[2]->isVideo)->toBeTrue() + ->and($media[2]->downloadUrl)->toBeNull(); +}); + +test('an instagram account connected through facebook uses the facebook graph host', function () { + $graph = config('trypost.platforms.instagram-facebook.graph_api'); + + Http::fake(["{$graph}/*" => Http::response(['data' => []])]); + + $account = SocialAccount::factory()->create(['platform' => Platform::InstagramFacebook]); + + app(SourceFetcherFactory::class)->for($account)->fetch($account, null); + + Http::assertSent(fn ($request) => str_starts_with($request->url(), $graph)); +}); + +test('the facebook fetcher maps page videos', function () { + $graph = config('trypost.platforms.facebook.graph_api'); + + Http::fake([ + "{$graph}/*" => Http::response(['data' => [ + ['id' => 'v1', 'source' => 'https://cdn.example.com/v1.mp4', 'description' => 'Reel', 'permalink_url' => '/watch/1', 'created_time' => '2026-09-02T10:00:00+0000'], + ]]), + ]); + + $account = SocialAccount::factory()->create(['platform' => Platform::Facebook]); + + $media = app(SourceFetcherFactory::class)->for($account)->fetch($account, null); + + expect($media)->toHaveCount(1) + ->and($media[0]->id)->toBe('v1') + ->and($media[0]->isVideo)->toBeTrue() + ->and($media[0]->downloadUrl)->toBe('https://cdn.example.com/v1.mp4') + ->and($media[0]->caption)->toBe('Reel'); +}); + +test('a since timestamp is sent to the api', function () { + $graph = config('trypost.platforms.instagram.graph_api'); + Http::fake(["{$graph}/*" => Http::response(['data' => []])]); + + $account = SocialAccount::factory()->create(['platform' => Platform::Instagram]); + $since = now()->subDay(); + + app(SourceFetcherFactory::class)->for($account)->fetch($account, $since); + + Http::assertSent(fn ($request) => str_contains($request->url(), 'since='.$since->getTimestamp())); +}); + +test('a failed response throws so the caller can record it', function () { + $graph = config('trypost.platforms.instagram.graph_api'); + Http::fake(["{$graph}/*" => Http::response(['error' => ['message' => 'Invalid token']], 401)]); + + $account = SocialAccount::factory()->create(['platform' => Platform::Instagram]); + + expect(fn () => app(SourceFetcherFactory::class)->for($account)->fetch($account, null)) + ->toThrow(RuntimeException::class, 'Invalid token'); +}); + +test('an unsupported platform cannot be a source', function () { + $account = SocialAccount::factory()->create(['platform' => Platform::TikTok]); + + expect(fn () => app(SourceFetcherFactory::class)->for($account)) + ->toThrow(InvalidArgumentException::class); +}); From e5817ec683fe2ee29e7d5b289b9f699f72570251 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Sat, 5 Sep 2026 15:42:31 -0300 Subject: [PATCH 002/114] Poll repurpose sources and turn new videos into posts PollRepurposes runs every five minutes and dispatches only repurposes that are due; the real cadence is REPURPOSE_POLL_INTERVAL_MINUTES (default 15), so it can be tuned without a deploy. Meta's Instagram quota is an app-wide pool, and this feature's user configures it once and stops opening the app, so a throttled source backs off for REPURPOSE_BACKOFF_MINUTES instead of spending the pool every tick. Polling logs every media id it sees, with the reason it was skipped: not a video, already published through TryPost, or no downloadable URL (Meta omits it for copyrighted audio). Only genuinely new videos reach ProcessRepurposeItem. That job creates one post per destination rather than one post with many platforms, because a post carries a single caption every publisher reads. A Reel keeps its 2,200 characters even when a YouTube Short in the same repurpose is capped at 100. The video is downloaded once and shared. CaptionAdapter only spends AI on a real overflow; without AI access it cuts on a word boundary and the post still publishes. --- .env.example | 3 + app/Ai/Agents/PostContentShortener.php | 32 +++ .../Commands/Repurpose/PollRepurposes.php | 38 ++++ app/Jobs/Repurpose/PollRepurposeSource.php | 157 ++++++++++++++ app/Jobs/Repurpose/ProcessRepurposeItem.php | 136 ++++++++++++ app/Models/Post.php | 1 + app/Services/Repurpose/CaptionAdapter.php | 123 +++++++++++ config/trypost.php | 20 ++ .../prompts/post_content/shortener.blade.php | 23 ++ routes/console.php | 2 + .../Feature/Repurpose/CaptionAdapterTest.php | 38 ++++ tests/Feature/Repurpose/PollingTest.php | 201 ++++++++++++++++++ tests/Feature/Repurpose/ProcessItemTest.php | 172 +++++++++++++++ tests/fixtures/sample.mp4 | Bin 0 -> 2864 bytes 14 files changed, 946 insertions(+) create mode 100644 app/Ai/Agents/PostContentShortener.php create mode 100644 app/Console/Commands/Repurpose/PollRepurposes.php create mode 100644 app/Jobs/Repurpose/PollRepurposeSource.php create mode 100644 app/Jobs/Repurpose/ProcessRepurposeItem.php create mode 100644 app/Services/Repurpose/CaptionAdapter.php create mode 100644 resources/views/prompts/post_content/shortener.blade.php create mode 100644 tests/Feature/Repurpose/CaptionAdapterTest.php create mode 100644 tests/Feature/Repurpose/PollingTest.php create mode 100644 tests/Feature/Repurpose/ProcessItemTest.php create mode 100644 tests/fixtures/sample.mp4 diff --git a/.env.example b/.env.example index 8b76d274e..f1c92bc44 100644 --- a/.env.example +++ b/.env.example @@ -300,3 +300,6 @@ VITE_REVERB_SCHEME="${REVERB_SCHEME}" VITE_POSTHOG_ENABLED="${POSTHOG_ENABLED}" VITE_POSTHOG_API_KEY="${POSTHOG_API_KEY}" VITE_POSTHOG_HOST="${POSTHOG_HOST}" + +REPURPOSE_POLL_INTERVAL_MINUTES=15 +REPURPOSE_BACKOFF_MINUTES=60 diff --git a/app/Ai/Agents/PostContentShortener.php b/app/Ai/Agents/PostContentShortener.php new file mode 100644 index 000000000..de1396903 --- /dev/null +++ b/app/Ai/Agents/PostContentShortener.php @@ -0,0 +1,32 @@ + $this->workspace->name ?? '', + 'brand_voice_traits' => $this->workspace->brand_voice_traits ?? [], + 'platform_label' => $this->platformLabel, + 'limit' => $this->limit, + ])->render(); + } +} diff --git a/app/Console/Commands/Repurpose/PollRepurposes.php b/app/Console/Commands/Repurpose/PollRepurposes.php new file mode 100644 index 000000000..1cc40680d --- /dev/null +++ b/app/Console/Commands/Repurpose/PollRepurposes.php @@ -0,0 +1,38 @@ +where('status', Status::Active) + ->where(fn (Builder $query) => $query->whereNull('next_poll_at')->orWhere('next_poll_at', '<=', now())) + ->with('sourceAccount') + ->chunkById(100, function ($repurposes) use (&$dispatched): void { + foreach ($repurposes as $repurpose) { + PollRepurposeSource::dispatch($repurpose); + $dispatched++; + } + }); + + $this->info("Dispatched {$dispatched} repurpose poll(s)."); + + return self::SUCCESS; + } +} diff --git a/app/Jobs/Repurpose/PollRepurposeSource.php b/app/Jobs/Repurpose/PollRepurposeSource.php new file mode 100644 index 000000000..6ddc23b32 --- /dev/null +++ b/app/Jobs/Repurpose/PollRepurposeSource.php @@ -0,0 +1,157 @@ +onQueue($repurpose->sourceAccount->platform->queue()); + } + + public function handle(SourceFetcherFactory $fetchers): void + { + $account = $this->repurpose->sourceAccount; + + if ($account === null || $account->disconnected_at !== null) { + return; + } + + try { + $media = $fetchers->for($account)->fetch($account, $this->repurpose->activated_at); + } catch (Throwable $exception) { + $this->recordFailure($exception); + + return; + } + + $this->logMedia($media); + + $this->repurpose->update([ + 'last_error' => null, + 'last_polled_at' => now(), + 'next_poll_at' => now()->addMinutes($this->interval()), + ]); + } + + /** + * @param array $media + */ + private function logMedia(array $media): void + { + $publishedByUs = $this->idsPublishedByTryPost($media); + + foreach ($media as $entry) { + $item = RepurposeItem::firstOrCreate( + ['repurpose_id' => $this->repurpose->id, 'source_media_id' => $entry->id], + [ + 'status' => ItemStatus::Pending, + 'source_permalink' => $entry->permalink, + 'source_created_at' => $entry->createdAt, + ], + ); + + if (! $item->wasRecentlyCreated) { + continue; + } + + $reason = $this->skipReason($entry, $publishedByUs); + + if ($reason !== null) { + $item->update(['status' => ItemStatus::Skipped, 'reason' => $reason]); + + continue; + } + + ProcessRepurposeItem::dispatch($item, (string) $entry->downloadUrl, $entry->caption); + } + } + + /** + * @param array $publishedByUs + */ + private function skipReason(SourceMedia $media, array $publishedByUs): ?ItemReason + { + return match (true) { + ! $media->isVideo => ItemReason::NotVideo, + in_array($media->id, $publishedByUs, true) => ItemReason::PublishedViaTrypost, + blank($media->downloadUrl) => ItemReason::MediaUrlMissing, + default => null, + }; + } + + /** + * Media this workspace published through TryPost, which must never be + * replicated again. + * + * @param array $media + * @return array + */ + private function idsPublishedByTryPost(array $media): array + { + $ids = array_map(fn (SourceMedia $entry): string => $entry->id, $media); + + if ($ids === []) { + return []; + } + + return PostPlatform::query() + ->whereIn('platform_post_id', $ids) + ->whereHas('post', fn (Builder $query) => $query->where('workspace_id', $this->repurpose->workspace_id)) + ->pluck('platform_post_id') + ->all(); + } + + /** + * A throttled source waits longer than the usual interval, so a workspace + * that hit Meta's app-wide quota does not keep spending it. + */ + private function recordFailure(Throwable $exception): void + { + $throttled = GraphError::isTransient(['error' => ['message' => $exception->getMessage()]]) + || Str::contains($exception->getMessage(), 'request limit', ignoreCase: true); + + $this->repurpose->update([ + 'last_error' => Str::limit($exception->getMessage(), 1000), + 'last_polled_at' => now(), + 'next_poll_at' => now()->addMinutes($throttled ? $this->backoff() : $this->interval()), + ]); + + Log::warning('Repurpose polling failed', [ + 'repurpose_id' => $this->repurpose->id, + 'message' => $exception->getMessage(), + ]); + } + + private function interval(): int + { + return (int) config('trypost.repurpose.poll_interval_minutes'); + } + + private function backoff(): int + { + return (int) config('trypost.repurpose.backoff_minutes'); + } +} diff --git a/app/Jobs/Repurpose/ProcessRepurposeItem.php b/app/Jobs/Repurpose/ProcessRepurposeItem.php new file mode 100644 index 000000000..088a9f195 --- /dev/null +++ b/app/Jobs/Repurpose/ProcessRepurposeItem.php @@ -0,0 +1,136 @@ + + */ + public function backoff(): array + { + return [60, 300, 900]; + } + + public function handle(MediaAttacher $media, CaptionAdapter $captions): void + { + if ($this->item->status->isTerminal() || $this->item->posts()->exists()) { + return; + } + + $repurpose = $this->item->repurpose; + $workspace = $repurpose->workspace; + $user = $repurpose->user; + + $this->item->update(['status' => ItemStatus::Processing]); + + $posts = []; + $snapshot = null; + + foreach ($repurpose->destinations as $destination) { + $account = SocialAccount::find(data_get($destination, 'social_account_id')); + + if ($account === null || $account->disconnected_at !== null) { + continue; + } + + $post = CreatePost::execute($workspace, $user, [ + 'content' => $captions->adapt($workspace, $user, $this->caption, $account->platform, null), + 'created_via' => CreatedVia::Repurpose, + 'platforms' => [$destination], + ]); + + $post->update(['repurpose_item_id' => $this->item->id]); + + if ($snapshot === null) { + $snapshot = data_get($media->attachFromUrls($post, [['url' => $this->downloadUrl]]), 'attached', []); + + if ($snapshot === []) { + $this->discard($posts + [$post], ItemReason::DownloadFailed); + + return; + } + } else { + $post->appendMedia($snapshot); + } + + $posts[] = $post; + } + + if ($posts === []) { + $this->item->update(['status' => ItemStatus::Failed, 'reason' => ItemReason::PostCreationFailed]); + + return; + } + + foreach ($posts as $post) { + $post->update(['status' => PostStatus::Scheduled, 'scheduled_at' => now()]); + + PublishPost::dispatch($post); + } + + $this->item->update(['status' => ItemStatus::Published, 'reason' => null, 'error' => null]); + } + + public function failed(Throwable $exception): void + { + $this->item->update([ + 'status' => ItemStatus::Failed, + 'error' => Str::limit($exception->getMessage(), 1000), + ]); + } + + /** + * @param array $posts + */ + private function discard(array $posts, ItemReason $reason): void + { + foreach ($posts as $post) { + $post->forceDelete(); + } + + $this->item->update(['status' => ItemStatus::Failed, 'reason' => $reason]); + } +} diff --git a/app/Models/Post.php b/app/Models/Post.php index 4c1d764b4..ebe88db72 100644 --- a/app/Models/Post.php +++ b/app/Models/Post.php @@ -36,6 +36,7 @@ class Post extends Model 'media', 'status', 'created_via', + 'repurpose_item_id', 'scheduled_at', 'published_at', ]; diff --git a/app/Services/Repurpose/CaptionAdapter.php b/app/Services/Repurpose/CaptionAdapter.php new file mode 100644 index 000000000..43debcbfc --- /dev/null +++ b/app/Services/Repurpose/CaptionAdapter.php @@ -0,0 +1,123 @@ +contentOverflow($this->sanitizer->displayText($caption, $platform)); + + if ($overflow === 0) { + return $caption; + } + + $limit = mb_strlen($caption) - $overflow; + + if (! $this->canUseAi($workspace, $user)) { + return $this->truncate($caption, $limit); + } + + return $this->shorten($workspace, $user, $caption, $platform, $postId, $limit) + ?? $this->truncate($caption, $limit); + } + + private function shorten( + Workspace $workspace, + ?User $user, + string $caption, + Platform $platform, + ?string $postId, + int $limit, + ): ?string { + try { + $agent = new PostContentShortener( + workspace: $workspace, + platformLabel: $platform->label(), + limit: $limit, + ); + + $result = $agent->prompt($caption); + + RecordAiUsage::recordText( + workspace: $workspace, + promptTokens: $result->usage->promptTokens, + completionTokens: $result->usage->completionTokens, + provider: (string) $result->meta->provider, + model: (string) $result->meta->model, + userId: $user?->id, + postId: $postId, + metadata: ['agent' => 'post_shortener'], + ); + + $shortened = trim((string) $result->text); + + if ($shortened === '' || $platform->contentOverflow($this->sanitizer->displayText($shortened, $platform)) > 0) { + return null; + } + + return $shortened; + } catch (Throwable $exception) { + Log::warning('Caption shortening failed, falling back to truncation', [ + 'workspace_id' => $workspace->id, + 'platform' => $platform->value, + 'message' => $exception->getMessage(), + ]); + + return null; + } + } + + private function canUseAi(Workspace $workspace, ?User $user): bool + { + if ($user === null) { + return false; + } + + return Gate::forUser($user)->allows('useAi', $workspace->account); + } + + /** + * Cuts on the last word boundary that fits, so the caption never ends + * mid-word. + */ + private function truncate(string $caption, int $limit): string + { + $trimmed = rtrim(mb_substr($caption, 0, $limit)); + + $lastSpace = mb_strrpos($trimmed, ' '); + + if ($lastSpace !== false && $lastSpace > 0) { + $trimmed = mb_substr($trimmed, 0, $lastSpace); + } + + return rtrim($trimmed); + } +} diff --git a/config/trypost.php b/config/trypost.php index 5eff1009e..90b50ee01 100644 --- a/config/trypost.php +++ b/config/trypost.php @@ -153,6 +153,26 @@ 'user_agent' => env('TRYPOST_USER_AGENT', 'TryPost.it/1.0 (+https://trypost.it)'), + /* + |-------------------------------------------------------------------------- + | Repurpose + |-------------------------------------------------------------------------- + | + | How often an active repurpose polls its source network for videos the + | workspace published outside TryPost. The scheduler ticks every five + | minutes and each repurpose is polled when it is due, so the interval is + | a runtime knob rather than a cron expression. Meta's Instagram quota is + | an app-wide pool (200 calls per hour per daily active user), so raise + | the interval before the pool tightens. `backoff_minutes` is used instead + | when the source answers with a rate-limit error. + | + */ + + 'repurpose' => [ + 'poll_interval_minutes' => (int) env('REPURPOSE_POLL_INTERVAL_MINUTES', 15), + 'backoff_minutes' => (int) env('REPURPOSE_BACKOFF_MINUTES', 60), + ], + 'google_auth_enabled' => env('GOOGLE_AUTH_ENABLED', false), 'github_auth_enabled' => env('GITHUB_AUTH_ENABLED', false), diff --git a/resources/views/prompts/post_content/shortener.blade.php b/resources/views/prompts/post_content/shortener.blade.php new file mode 100644 index 000000000..4d824e119 --- /dev/null +++ b/resources/views/prompts/post_content/shortener.blade.php @@ -0,0 +1,23 @@ +You are a social media copy editor. Your job: shorten a caption so it fits a hard character limit on {{ $platform_label }}, without losing what makes it work. + +Brand context: +- Brand: {{ $brand_name }} +@if(!empty($brand_voice_traits)) +Brand voice: +@include('prompts.post_content._voice', ['brand_voice_traits' => $brand_voice_traits]) +@endif + +Output language: the SAME language as the caption you receive. Never translate. + +Hard limit: {{ $limit }} characters. The result MUST be at or under it, counting every character including spaces, emoji, and hashtags. + +Rules: +- Return ONLY the shortened caption. No preamble, no quotes around it, no explanation. +- Keep the hook: the first sentence is what stops the scroll, so protect it. +- Keep the call to action if there is one. +- Keep at most the two most relevant hashtags; drop the rest before you cut real words. +- Drop redundancy, filler, and repeated ideas before you drop information. +- Keep the author's voice and tone. This is a trim, not a rewrite. +- Never use em dashes or en dashes (— –). Use a comma, a colon, parentheses, or a new sentence. +- Never invent facts, offers, dates, or numbers that are not in the original. +- If the caption is already at or under {{ $limit }} characters, return it unchanged. diff --git a/routes/console.php b/routes/console.php index d74db37f4..f955621a3 100644 --- a/routes/console.php +++ b/routes/console.php @@ -8,6 +8,7 @@ use App\Console\Commands\PruneWebhookLogs; use App\Console\Commands\RecoverStuckPosts; use App\Console\Commands\RefreshExpiringTokens; +use App\Console\Commands\Repurpose\PollRepurposes; use Illuminate\Support\Facades\Schedule; Schedule::command(ProcessScheduledPosts::class)->everyMinute()->withoutOverlapping()->onOneServer(); @@ -16,3 +17,4 @@ Schedule::command(RefreshExpiringTokens::class)->everyFifteenMinutes()->withoutOverlapping()->onOneServer(); Schedule::command(RecoverStuckPosts::class)->everyThirtyMinutes()->withoutOverlapping()->onOneServer(); Schedule::command(PruneWebhookLogs::class)->daily()->withoutOverlapping()->onOneServer(); +Schedule::command(PollRepurposes::class)->everyFiveMinutes()->withoutOverlapping()->onOneServer(); diff --git a/tests/Feature/Repurpose/CaptionAdapterTest.php b/tests/Feature/Repurpose/CaptionAdapterTest.php new file mode 100644 index 000000000..508281a88 --- /dev/null +++ b/tests/Feature/Repurpose/CaptionAdapterTest.php @@ -0,0 +1,38 @@ +create(); + $caption = 'Short and sweet'; + + expect(app(CaptionAdapter::class)->adapt($workspace, null, $caption, Platform::TikTok, null)) + ->toBe($caption); +}); + +test('a caption that does not fit is truncated at a word boundary when ai is unavailable', function () { + $workspace = Workspace::factory()->create(); + $caption = str_repeat('palavra ', 2000); + + expect(Platform::TikTok->contentOverflow($caption))->toBeGreaterThan(0); + + $result = app(CaptionAdapter::class)->adapt($workspace, null, $caption, Platform::TikTok, null); + + expect(Platform::TikTok->contentOverflow($result))->toBe(0) + ->and($result)->not->toEndWith('palavr') + ->and($result)->toEndWith('palavra'); +}); + +test('truncation respects the tightest limit we support', function () { + $workspace = Workspace::factory()->create(); + $caption = 'A really long YouTube Short caption that keeps going well past one hundred characters so it has to be cut somewhere sensible.'; + + $result = app(CaptionAdapter::class)->adapt($workspace, null, $caption, Platform::YouTube, null); + + expect(Platform::YouTube->contentOverflow($result))->toBe(0) + ->and($result)->toStartWith('A really long YouTube Short caption'); +}); diff --git a/tests/Feature/Repurpose/PollingTest.php b/tests/Feature/Repurpose/PollingTest.php new file mode 100644 index 000000000..8025cd330 --- /dev/null +++ b/tests/Feature/Repurpose/PollingTest.php @@ -0,0 +1,201 @@ + Http::response(['data' => $rows])]); +} + +function instagramVideoRow(string $id = 'm1', ?string $url = 'https://cdn.example.com/v.mp4'): array +{ + return array_filter([ + 'id' => $id, + 'media_type' => 'VIDEO', + 'media_url' => $url, + 'caption' => 'Hi', + 'permalink' => 'https://instagram.com/p/1', + 'timestamp' => '2026-09-04T10:00:00+0000', + ], fn ($value) => $value !== null); +} + +function activeRepurpose(): Repurpose +{ + $workspace = Workspace::factory()->create(); + $account = SocialAccount::factory()->for($workspace)->create(['platform' => Platform::Instagram]); + + return Repurpose::factory()->active()->create([ + 'workspace_id' => $workspace->id, + 'source_social_account_id' => $account->id, + ]); +} + +function poll(Repurpose $repurpose): void +{ + (new PollRepurposeSource($repurpose))->handle(app(SourceFetcherFactory::class)); +} + +test('a new video creates a pending item and dispatches processing', function () { + Bus::fake(); + fakeInstagramMedia([instagramVideoRow()]); + + $repurpose = activeRepurpose(); + poll($repurpose); + + $item = $repurpose->items()->sole(); + + expect($item->status)->toBe(ItemStatus::Pending) + ->and($item->source_media_id)->toBe('m1') + ->and($item->source_permalink)->toBe('https://instagram.com/p/1') + ->and($repurpose->fresh()->last_polled_at)->not->toBeNull() + ->and($repurpose->fresh()->next_poll_at)->not->toBeNull(); + + Bus::assertDispatched(ProcessRepurposeItem::class); +}); + +test('an image is skipped as not a video', function () { + Bus::fake(); + fakeInstagramMedia([['id' => 'm2', 'media_type' => 'IMAGE', 'media_url' => 'https://cdn.example.com/i.jpg', 'caption' => '', 'timestamp' => '2026-09-04T10:00:00+0000']]); + + $repurpose = activeRepurpose(); + poll($repurpose); + + $item = $repurpose->items()->sole(); + + expect($item->status)->toBe(ItemStatus::Skipped) + ->and($item->reason)->toBe(ItemReason::NotVideo); + + Bus::assertNotDispatched(ProcessRepurposeItem::class); +}); + +test('a video without a download url is skipped', function () { + Bus::fake(); + fakeInstagramMedia([instagramVideoRow('m3', null)]); + + $repurpose = activeRepurpose(); + poll($repurpose); + + expect($repurpose->items()->sole()->reason)->toBe(ItemReason::MediaUrlMissing); + + Bus::assertNotDispatched(ProcessRepurposeItem::class); +}); + +test('media already published through trypost is skipped', function () { + Bus::fake(); + fakeInstagramMedia([instagramVideoRow('known-1')]); + + $repurpose = activeRepurpose(); + $post = Post::factory()->create(['workspace_id' => $repurpose->workspace_id]); + PostPlatform::factory()->for($post)->create(['platform_post_id' => 'known-1']); + + poll($repurpose); + + expect($repurpose->items()->sole()->reason)->toBe(ItemReason::PublishedViaTrypost); + + Bus::assertNotDispatched(ProcessRepurposeItem::class); +}); + +test('media published by another workspace does not count as ours', function () { + Bus::fake(); + fakeInstagramMedia([instagramVideoRow('known-2')]); + + $repurpose = activeRepurpose(); + $foreignPost = Post::factory()->create(); + PostPlatform::factory()->for($foreignPost)->create(['platform_post_id' => 'known-2']); + + poll($repurpose); + + expect($repurpose->items()->sole()->status)->toBe(ItemStatus::Pending); + + Bus::assertDispatched(ProcessRepurposeItem::class); +}); + +test('polling twice logs the same media once', function () { + Bus::fake(); + fakeInstagramMedia([instagramVideoRow()]); + + $repurpose = activeRepurpose(); + poll($repurpose); + poll($repurpose->fresh()); + + expect($repurpose->items()->count())->toBe(1); + + Bus::assertDispatchedTimes(ProcessRepurposeItem::class, 1); +}); + +test('an api error is recorded without throwing', function () { + Bus::fake(); + Http::fake([config('trypost.platforms.instagram.graph_api').'/*' => Http::response(['error' => ['message' => 'Invalid token']], 401)]); + + $repurpose = activeRepurpose(); + poll($repurpose); + + expect($repurpose->fresh()->last_error)->toContain('Invalid token') + ->and($repurpose->items()->count())->toBe(0); +}); + +test('a rate limited source backs off instead of retrying next tick', function () { + Bus::fake(); + config()->set('trypost.repurpose.backoff_minutes', 60); + config()->set('trypost.repurpose.poll_interval_minutes', 15); + Http::fake([config('trypost.platforms.instagram.graph_api').'/*' => Http::response(['error' => ['code' => 4, 'message' => 'Application request limit reached']], 400)]); + + $repurpose = activeRepurpose(); + poll($repurpose); + + $repurpose = $repurpose->fresh(); + + expect($repurpose->status)->toBe(Status::Active) + ->and(now()->diffInMinutes($repurpose->next_poll_at, absolute: true))->toBeGreaterThan(30); +}); + +test('a disconnected source is not polled', function () { + Bus::fake(); + Http::fake(); + + $repurpose = activeRepurpose(); + $repurpose->sourceAccount->update(['disconnected_at' => now()]); + + poll($repurpose); + + Http::assertNothingSent(); + expect($repurpose->items()->count())->toBe(0); +}); + +test('the command only dispatches for due active repurposes', function () { + Bus::fake(); + + activeRepurpose(); + Repurpose::factory()->create(); + Repurpose::factory()->paused()->create(); + Repurpose::factory()->disabled()->create(); + + $this->artisan('repurpose:poll')->assertSuccessful(); + + Bus::assertDispatchedTimes(PollRepurposeSource::class, 1); +}); + +test('a repurpose that is not due yet is not dispatched', function () { + Bus::fake(); + + activeRepurpose()->update(['next_poll_at' => now()->addMinutes(10)]); + + $this->artisan('repurpose:poll')->assertSuccessful(); + + Bus::assertNotDispatched(PollRepurposeSource::class); +}); diff --git a/tests/Feature/Repurpose/ProcessItemTest.php b/tests/Feature/Repurpose/ProcessItemTest.php new file mode 100644 index 000000000..8257ba7b1 --- /dev/null +++ b/tests/Feature/Repurpose/ProcessItemTest.php @@ -0,0 +1,172 @@ +set('trypost.allow_multiple_social_accounts', true); + + $workspace = Workspace::factory()->create(); + $source = SocialAccount::factory()->for($workspace)->create(['platform' => Platform::Instagram]); + $tiktok = SocialAccount::factory()->for($workspace)->create(['platform' => Platform::TikTok]); + $youtube = SocialAccount::factory()->for($workspace)->create(['platform' => Platform::YouTube]); + + $repurpose = Repurpose::factory()->active()->create([ + 'workspace_id' => $workspace->id, + 'source_social_account_id' => $source->id, + 'destinations' => [ + ['social_account_id' => $tiktok->id, 'content_type' => ContentType::TikTokVideo->value, 'meta' => ['privacy_level' => 'PUBLIC_TO_EVERYONE']], + ['social_account_id' => $youtube->id, 'content_type' => ContentType::YouTubeShort->value, 'meta' => []], + ], + ]); + + return RepurposeItem::factory()->for($repurpose)->create(); +} + +function fakeVideoDownload(): void +{ + Http::fake([ + REPURPOSE_VIDEO_URL => Http::response( + file_get_contents(base_path('tests/fixtures/sample.mp4')), + 200, + ['Content-Type' => 'video/mp4'], + ), + ]); +} + +function processItem(RepurposeItem $item, string $caption = 'My caption'): void +{ + (new ProcessRepurposeItem($item, REPURPOSE_VIDEO_URL, $caption)) + ->handle(app(MediaAttacher::class), app(CaptionAdapter::class)); +} + +test('it creates one post per destination and publishes each', function () { + Bus::fake([PublishPost::class]); + fakeVideoDownload(); + + $item = repurposeWithTwoDestinations(); + + processItem($item); + + $posts = Post::where('repurpose_item_id', $item->id)->get(); + + expect($item->fresh()->status)->toBe(ItemStatus::Published) + ->and($posts)->toHaveCount(2); + + foreach ($posts as $post) { + expect($post->created_via)->toBe(CreatedVia::Repurpose) + ->and($post->status)->toBe(PostStatus::Scheduled) + ->and($post->media)->toHaveCount(1) + ->and($post->postPlatforms()->enabled()->count())->toBe(1); + } + + Bus::assertDispatchedTimes(PublishPost::class, 2); +}); + +test('the video is downloaded once and reused by every post', function () { + Bus::fake([PublishPost::class]); + fakeVideoDownload(); + + $item = repurposeWithTwoDestinations(); + + processItem($item); + + Http::assertSentCount(1); + + $paths = Post::where('repurpose_item_id', $item->id)->get() + ->map(fn (Post $post) => data_get($post->media, '0.path')); + + expect($paths->filter())->toHaveCount(2) + ->and($paths->unique())->toHaveCount(1); +}); + +test('destination meta is carried onto the post platform', function () { + Bus::fake([PublishPost::class]); + fakeVideoDownload(); + + $item = repurposeWithTwoDestinations(); + + processItem($item); + + $tiktokPlatform = PostPlatform::query() + ->enabled() + ->whereHas('post', fn ($query) => $query->where('repurpose_item_id', $item->id)) + ->where('platform', Platform::TikTok) + ->sole(); + + expect($tiktokPlatform->meta)->toEqual(['privacy_level' => 'PUBLIC_TO_EVERYONE']); +}); + +test('a caption over a destination limit is shortened for that post only', function () { + Bus::fake([PublishPost::class]); + fakeVideoDownload(); + + $item = repurposeWithTwoDestinations(); + $long = str_repeat('palavra ', 400); + + processItem($item, $long); + + $captions = PostPlatform::query() + ->enabled() + ->whereHas('post', fn ($query) => $query->where('repurpose_item_id', $item->id)) + ->with('post') + ->get() + ->mapWithKeys(fn (PostPlatform $platform) => [$platform->platform->value => $platform->post->content]); + + expect(Platform::TikTok->contentOverflow($captions[Platform::TikTok->value]))->toBe(0) + ->and(Platform::YouTube->contentOverflow($captions[Platform::YouTube->value]))->toBe(0) + ->and(mb_strlen($captions[Platform::TikTok->value])) + ->toBeGreaterThan(mb_strlen($captions[Platform::YouTube->value])); +}); + +test('a failed download marks the item failed and leaves no post', function () { + Bus::fake([PublishPost::class]); + Http::fake([REPURPOSE_VIDEO_URL => Http::response('', 404)]); + + $item = repurposeWithTwoDestinations(); + + processItem($item); + + expect($item->fresh()->status)->toBe(ItemStatus::Failed) + ->and($item->fresh()->reason)->toBe(ItemReason::DownloadFailed) + ->and(Post::where('repurpose_item_id', $item->id)->count())->toBe(0); + + Bus::assertNotDispatched(PublishPost::class); +}); + +test('running the job twice creates no extra posts', function () { + Bus::fake([PublishPost::class]); + fakeVideoDownload(); + + $item = repurposeWithTwoDestinations(); + + processItem($item); + processItem($item->fresh()); + + expect(Post::where('repurpose_item_id', $item->id)->count())->toBe(2); +}); diff --git a/tests/fixtures/sample.mp4 b/tests/fixtures/sample.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..918f1dfb0df07896250975e412e09865119b684a GIT binary patch literal 2864 zcmahL2~-qEwmC#ZQH&U6gU~7<2n=%oA~L&K6B(q;zy-@N40F`+6op~k6AfBI1f9JO-7J>fx1t`MY^;kZSr>2} zuN(TT#VijE#shJZN>GIw2}LFc?=Qjs;CEMS@@~ z%Vjc@PQ>L>sUlD=m4^URFN$1vP>{)Fl4Q_Yie(6+gymC$P!x$?G%%pUaw5$dg>alC zGz6)X;XI{NDsU~OVOTOvDGQf|OL4+TFqs0Sl$sUcQnO5^zzvjAPnmIHjs~a^I45L+ zDa>kKtCUHkU<4Cxpv{z4jXcW0Ld_G#6iO)z#YsKS8VEI*%5agV7={*r3NwdkNf8J# z-Jq003WRnpYowHNS%6H2>j*(qb3z);A&pK4>71I?=>$qt2Fh_!&x3`al;aG`rV)Br zs6X&zxWLdPP}MT%z#Py)V&nZpyAYLYc@ga~UAwn*d&+6XQ{ zktdLjjwcM10Gp*zbD2QXT8OPt6Iy~pRcO>2nh;PBny0LFnJ79%FKR%>a+Fb>!g3(( zWO87dMrDF;r94F1MXC+7Q7Mz+0!bMua*n7BmZCIy0&OWz33@Q+N%imLA~{bgNnVE= zG_bFzELbSz3W*d?N5v?ml29NyKo7wekq-Sw-Ndpm z%uYrv&Biu03#Vh#J9;JXM4M%dx7_kTWxvQ9L$w2srQOEw50UQ zIS|&n-^>J0%e--Ye(;+t!NZ<^>pR7Jm~Y#N99`(Os?tp*U%U--3(GZ++LK-38;Y=5_7;B5L|~`?pokwUk!4_3|OA$__8^r!RYiRK96h99`dkR*9@> zq4U{@-1tZj^Ukc9 zyF#S9wd`KH^14^NKJ4YhR+rt<{L>HGTJTrwczT&`XxSx?GfUggr8ggpIFnK7yfCHx z$K_KZ@;#j`{$iBR1EZE%wPrw@ ztdGE`i1yc`j+MSTf9O|LG3`>k{6DX#-G2Y>*yQx)fu=I?-c@%9?W!sqW6rfKI@o%Q zEMuA0S1DTYQ{>`vEJqm34*ZYlV-C%#nYwd*^#pt=KcU;); z*-@`soWhQ5#RKMP=gwjTXa^N%Q7xWRA1k2np4Nn)Bcrrjrs zUEWaa&)~B-e;9W(*r0)7UX)f80J~F+0IvosfCuE)u`6fW$p$S=fCM*aKd;>v*uL15 zN~>LDXA{hR^}wnqjay{WNVaZKup#3)c`b20-#& zb9e^tq;1LsQNsWYSoqKoq_Yn_i2x(`)MqDWG?X0!BpL{8`p1KQs9YislgOlSvC|s# zcIgya=YbXr;1JLnYoo=SEO)ViDS-FXMf#LS!bV~CXiA8PSvSoFbI?C&_-RP_``53L ztqVs89AK0N8m@3emw0z41KKp4(lLj!maj9y)1C}60C zWC0^UwMb!T=4eF%@C*ZG#Xw?%KRrKi=6HhRP>X+uK)UmgdhZfh Date: Sat, 5 Sep 2026 15:58:28 -0300 Subject: [PATCH 003/114] Add repurpose actions, policy, validation rules and templates --- app/Actions/Repurpose/ActivateRepurpose.php | 36 ++++ app/Actions/Repurpose/CreateRepurpose.php | 48 ++++++ app/Actions/Repurpose/DeleteRepurpose.php | 19 +++ app/Actions/Repurpose/DisableRepurpose.php | 26 +++ app/Actions/Repurpose/ListRepurposeItems.php | 23 +++ app/Actions/Repurpose/ListRepurposes.php | 25 +++ app/Actions/Repurpose/PauseRepurpose.php | 22 +++ app/Actions/Repurpose/ResumeRepurpose.php | 21 +++ app/Actions/Repurpose/UpdateRepurpose.php | 36 ++++ app/Policies/RepurposePolicy.php | 38 +++++ app/Policies/WorkspacePolicy.php | 9 + app/Support/Repurpose/RepurposeRules.php | 53 ++++++ app/Support/Repurpose/Templates.php | 49 ++++++ tests/Feature/Repurpose/ActionsTest.php | 165 +++++++++++++++++++ tests/Unit/Policies/RepurposePolicyTest.php | 53 ++++++ 15 files changed, 623 insertions(+) create mode 100644 app/Actions/Repurpose/ActivateRepurpose.php create mode 100644 app/Actions/Repurpose/CreateRepurpose.php create mode 100644 app/Actions/Repurpose/DeleteRepurpose.php create mode 100644 app/Actions/Repurpose/DisableRepurpose.php create mode 100644 app/Actions/Repurpose/ListRepurposeItems.php create mode 100644 app/Actions/Repurpose/ListRepurposes.php create mode 100644 app/Actions/Repurpose/PauseRepurpose.php create mode 100644 app/Actions/Repurpose/ResumeRepurpose.php create mode 100644 app/Actions/Repurpose/UpdateRepurpose.php create mode 100644 app/Policies/RepurposePolicy.php create mode 100644 app/Support/Repurpose/RepurposeRules.php create mode 100644 app/Support/Repurpose/Templates.php create mode 100644 tests/Feature/Repurpose/ActionsTest.php create mode 100644 tests/Unit/Policies/RepurposePolicyTest.php diff --git a/app/Actions/Repurpose/ActivateRepurpose.php b/app/Actions/Repurpose/ActivateRepurpose.php new file mode 100644 index 000000000..d4442ed70 --- /dev/null +++ b/app/Actions/Repurpose/ActivateRepurpose.php @@ -0,0 +1,36 @@ +destinations === []) { + throw ValidationException::withMessages([ + 'destinations' => __('repurposes.errors.destinations_required'), + ]); + } + + $repurpose->update([ + 'status' => Status::Active, + 'activated_at' => now(), + 'next_poll_at' => null, + 'last_error' => null, + ]); + + return $repurpose->fresh(); + } +} diff --git a/app/Actions/Repurpose/CreateRepurpose.php b/app/Actions/Repurpose/CreateRepurpose.php new file mode 100644 index 000000000..5edc9167f --- /dev/null +++ b/app/Actions/Repurpose/CreateRepurpose.php @@ -0,0 +1,48 @@ + $data + */ + public static function execute(Workspace $workspace, User $user, array $data): Repurpose + { + $sourceAccountId = (string) data_get($data, 'source_social_account_id'); + + if (self::existingFor($workspace, $sourceAccountId) !== null) { + throw ValidationException::withMessages([ + 'source_social_account_id' => __('repurposes.errors.source_already_used'), + ]); + } + + return Repurpose::query()->create([ + 'workspace_id' => $workspace->id, + 'user_id' => $user->id, + 'source_social_account_id' => $sourceAccountId, + 'destinations' => data_get($data, 'destinations', []), + 'status' => Status::Draft, + ]); + } + + public static function existingFor(Workspace $workspace, string $sourceAccountId): ?Repurpose + { + return Repurpose::query() + ->where('workspace_id', $workspace->id) + ->where('source_social_account_id', $sourceAccountId) + ->first(); + } +} diff --git a/app/Actions/Repurpose/DeleteRepurpose.php b/app/Actions/Repurpose/DeleteRepurpose.php new file mode 100644 index 000000000..7808067a6 --- /dev/null +++ b/app/Actions/Repurpose/DeleteRepurpose.php @@ -0,0 +1,19 @@ +delete(); + } +} diff --git a/app/Actions/Repurpose/DisableRepurpose.php b/app/Actions/Repurpose/DisableRepurpose.php new file mode 100644 index 000000000..66a9ccf59 --- /dev/null +++ b/app/Actions/Repurpose/DisableRepurpose.php @@ -0,0 +1,26 @@ +update([ + 'status' => Status::Disabled, + 'activated_at' => null, + 'next_poll_at' => null, + ]); + + return $repurpose->fresh(); + } +} diff --git a/app/Actions/Repurpose/ListRepurposeItems.php b/app/Actions/Repurpose/ListRepurposeItems.php new file mode 100644 index 000000000..f3e2779cc --- /dev/null +++ b/app/Actions/Repurpose/ListRepurposeItems.php @@ -0,0 +1,23 @@ + + */ + public static function execute(Repurpose $repurpose): LengthAwarePaginator + { + return $repurpose->items() + ->with('posts:id,repurpose_item_id,status') + ->latest() + ->paginate((int) config('app.pagination.default')); + } +} diff --git a/app/Actions/Repurpose/ListRepurposes.php b/app/Actions/Repurpose/ListRepurposes.php new file mode 100644 index 000000000..05498f4fc --- /dev/null +++ b/app/Actions/Repurpose/ListRepurposes.php @@ -0,0 +1,25 @@ + + */ + public static function execute(Workspace $workspace): Collection + { + return Repurpose::query() + ->where('workspace_id', $workspace->id) + ->with('sourceAccount') + ->withCount(['items as published_items_count' => fn ($query) => $query->where('status', 'published')]) + ->latest() + ->get(); + } +} diff --git a/app/Actions/Repurpose/PauseRepurpose.php b/app/Actions/Repurpose/PauseRepurpose.php new file mode 100644 index 000000000..f45c58ad8 --- /dev/null +++ b/app/Actions/Repurpose/PauseRepurpose.php @@ -0,0 +1,22 @@ +update(['status' => Status::Paused]); + + return $repurpose->fresh(); + } +} diff --git a/app/Actions/Repurpose/ResumeRepurpose.php b/app/Actions/Repurpose/ResumeRepurpose.php new file mode 100644 index 000000000..78cf7ab98 --- /dev/null +++ b/app/Actions/Repurpose/ResumeRepurpose.php @@ -0,0 +1,21 @@ +update([ + 'status' => Status::Active, + 'next_poll_at' => null, + ]); + + return $repurpose->fresh(); + } +} diff --git a/app/Actions/Repurpose/UpdateRepurpose.php b/app/Actions/Repurpose/UpdateRepurpose.php new file mode 100644 index 000000000..4020b3999 --- /dev/null +++ b/app/Actions/Repurpose/UpdateRepurpose.php @@ -0,0 +1,36 @@ + $data + */ + public static function execute(Repurpose $repurpose, array $data): Repurpose + { + $attributes = []; + + if (($sourceAccountId = data_get($data, 'source_social_account_id')) !== null + && $sourceAccountId !== $repurpose->source_social_account_id) { + $attributes['source_social_account_id'] = $sourceAccountId; + $attributes['activated_at'] = $repurpose->activated_at === null ? null : now(); + } + + if (($destinations = data_get($data, 'destinations')) !== null) { + $attributes['destinations'] = $destinations; + } + + $repurpose->update($attributes); + + return $repurpose->fresh(); + } +} diff --git a/app/Policies/RepurposePolicy.php b/app/Policies/RepurposePolicy.php new file mode 100644 index 000000000..f23e43418 --- /dev/null +++ b/app/Policies/RepurposePolicy.php @@ -0,0 +1,38 @@ +currentWorkspace !== null + && $user->can('manageRepurposes', $user->currentWorkspace); + } + + public function view(User $user, Repurpose $repurpose): bool + { + return $repurpose->workspace_id === $user->current_workspace_id + && $user->can('manageRepurposes', $user->currentWorkspace); + } + + public function create(User $user): bool + { + return $this->viewAny($user); + } + + public function update(User $user, Repurpose $repurpose): bool + { + return $this->view($user, $repurpose); + } + + public function delete(User $user, Repurpose $repurpose): bool + { + return $this->view($user, $repurpose); + } +} diff --git a/app/Policies/WorkspacePolicy.php b/app/Policies/WorkspacePolicy.php index 6de177818..8dc232c6c 100644 --- a/app/Policies/WorkspacePolicy.php +++ b/app/Policies/WorkspacePolicy.php @@ -61,6 +61,15 @@ public function manageWebhooks(User $user, Workspace $workspace): bool return $this->isOwnerOrWorkspaceAdmin($user, $workspace); } + /** + * Repurpose creates posts on the workspace's behalf, so it follows the + * post-creation role rather than the stricter integration roles. + */ + public function manageRepurposes(User $user, Workspace $workspace): bool + { + return $this->createPost($user, $workspace); + } + public function createPost(User $user, Workspace $workspace): bool { if ($this->isOwner($user, $workspace)) { diff --git a/app/Support/Repurpose/RepurposeRules.php b/app/Support/Repurpose/RepurposeRules.php new file mode 100644 index 000000000..5011b25e2 --- /dev/null +++ b/app/Support/Repurpose/RepurposeRules.php @@ -0,0 +1,53 @@ + + */ + public static function rules(): array + { + return [ + 'source_social_account_id' => ['required', 'string', 'uuid'], + 'destinations' => ['sometimes', 'array'], + 'destinations.*.social_account_id' => ['required', 'string', 'uuid'], + 'destinations.*.content_type' => ['required', 'string', Rule::enum(ContentType::class)], + ...self::destinationMetaRules(), + ]; + } + + /** + * @return array + */ + private static function destinationMetaRules(): array + { + $rules = []; + + foreach (PostPlatformMetaRules::rules() as $key => $rule) { + if (! Str::startsWith($key, 'platforms.*.meta')) { + continue; + } + + $rules[Str::replaceFirst('platforms.*.', 'destinations.*.', $key)] = $rule; + } + + return $rules; + } +} diff --git a/app/Support/Repurpose/Templates.php b/app/Support/Repurpose/Templates.php new file mode 100644 index 000000000..1052a8a98 --- /dev/null +++ b/app/Support/Repurpose/Templates.php @@ -0,0 +1,49 @@ +}> + */ + public static function all(): array + { + return [ + [ + 'key' => 'instagram_everywhere', + 'source_platform' => Platform::Instagram->value, + 'destination_platforms' => [ + Platform::TikTok->value, + Platform::YouTube->value, + Platform::Facebook->value, + ], + ], + [ + 'key' => 'facebook_everywhere', + 'source_platform' => Platform::Facebook->value, + 'destination_platforms' => [ + Platform::Instagram->value, + Platform::TikTok->value, + Platform::YouTube->value, + ], + ], + ]; + } + + public static function find(?string $key): ?array + { + return collect(self::all())->firstWhere('key', $key); + } +} diff --git a/tests/Feature/Repurpose/ActionsTest.php b/tests/Feature/Repurpose/ActionsTest.php new file mode 100644 index 000000000..0cea44c7d --- /dev/null +++ b/tests/Feature/Repurpose/ActionsTest.php @@ -0,0 +1,165 @@ +create(); + $workspace = Workspace::factory()->create(['user_id' => $user->id]); + $account = SocialAccount::factory()->for($workspace)->create(['platform' => Platform::Instagram]); + + return [$workspace, $user, $account]; +} + +function tiktokDestination(Workspace $workspace): array +{ + $account = SocialAccount::factory()->for($workspace)->create(['platform' => Platform::TikTok]); + + return [ + 'social_account_id' => $account->id, + 'content_type' => ContentType::TikTokVideo->value, + 'meta' => ['privacy_level' => 'PUBLIC_TO_EVERYONE'], + ]; +} + +test('a repurpose is created as a draft with its source account', function () { + [$workspace, $user, $account] = repurposeWorkspace(); + + $repurpose = CreateRepurpose::execute($workspace, $user, ['source_social_account_id' => $account->id]); + + expect($repurpose->status)->toBe(Status::Draft) + ->and($repurpose->source_social_account_id)->toBe($account->id) + ->and($repurpose->destinations)->toBe([]) + ->and($repurpose->activated_at)->toBeNull(); +}); + +test('a second repurpose for the same source account is rejected', function () { + [$workspace, $user, $account] = repurposeWorkspace(); + + CreateRepurpose::execute($workspace, $user, ['source_social_account_id' => $account->id]); + + expect(fn () => CreateRepurpose::execute($workspace, $user, ['source_social_account_id' => $account->id])) + ->toThrow(ValidationException::class); +}); + +test('two accounts on the same network each get their own repurpose', function () { + config()->set('trypost.allow_multiple_social_accounts', true); + + [$workspace, $user, $first] = repurposeWorkspace(); + $second = SocialAccount::factory()->for($workspace)->create(['platform' => Platform::Instagram]); + + CreateRepurpose::execute($workspace, $user, ['source_social_account_id' => $first->id]); + CreateRepurpose::execute($workspace, $user, ['source_social_account_id' => $second->id]); + + expect(Repurpose::where('workspace_id', $workspace->id)->count())->toBe(2); +}); + +test('destination meta survives create and update', function () { + [$workspace, $user, $account] = repurposeWorkspace(); + $destination = tiktokDestination($workspace); + + $repurpose = CreateRepurpose::execute($workspace, $user, [ + 'source_social_account_id' => $account->id, + 'destinations' => [$destination], + ]); + + expect($repurpose->fresh()->destinations)->toEqual([$destination]); + + $updated = UpdateRepurpose::execute($repurpose, ['destinations' => [$destination]]); + + expect($updated->destinations)->toEqual([$destination]); +}); + +test('activation requires at least one destination', function () { + $repurpose = Repurpose::factory()->create(); + + expect(fn () => ActivateRepurpose::execute($repurpose))->toThrow(ValidationException::class); +}); + +test('activation stamps the watermark', function () { + [$workspace, $user, $account] = repurposeWorkspace(); + + $repurpose = Repurpose::factory()->create([ + 'workspace_id' => $workspace->id, + 'source_social_account_id' => $account->id, + 'destinations' => [tiktokDestination($workspace)], + ]); + + $activated = ActivateRepurpose::execute($repurpose); + + expect($activated->status)->toBe(Status::Active) + ->and($activated->activated_at)->not->toBeNull(); +}); + +test('pausing keeps the watermark and resuming does not move it', function () { + $repurpose = Repurpose::factory()->active()->create(); + $watermark = $repurpose->activated_at; + + $paused = PauseRepurpose::execute($repurpose); + + expect($paused->status)->toBe(Status::Paused) + ->and($paused->activated_at->equalTo($watermark))->toBeTrue(); + + $resumed = ResumeRepurpose::execute($paused); + + expect($resumed->status)->toBe(Status::Active) + ->and($resumed->activated_at->equalTo($watermark))->toBeTrue(); +}); + +test('disabling clears the watermark so re-activation starts fresh', function () { + $repurpose = Repurpose::factory()->active()->create([ + 'destinations' => [['social_account_id' => (string) Str::uuid(), 'content_type' => ContentType::TikTokVideo->value, 'meta' => []]], + ]); + + $disabled = DisableRepurpose::execute($repurpose); + + expect($disabled->status)->toBe(Status::Disabled) + ->and($disabled->activated_at)->toBeNull(); + + $reactivated = ActivateRepurpose::execute($disabled); + + expect($reactivated->activated_at->isToday())->toBeTrue(); +}); + +test('changing the source account resets the watermark', function () { + config()->set('trypost.allow_multiple_social_accounts', true); + + $repurpose = Repurpose::factory()->active()->create(['activated_at' => now()->subMonth()]); + $newAccount = SocialAccount::factory()->create(['workspace_id' => $repurpose->workspace_id]); + + $updated = UpdateRepurpose::execute($repurpose, ['source_social_account_id' => $newAccount->id]); + + expect($updated->source_social_account_id)->toBe($newAccount->id) + ->and($updated->activated_at->isToday())->toBeTrue(); +}); + +test('deleting a repurpose removes its items but keeps the posts it created', function () { + $repurpose = Repurpose::factory()->create(); + $item = RepurposeItem::factory()->for($repurpose)->create(); + $post = Post::factory()->create(['workspace_id' => $repurpose->workspace_id, 'repurpose_item_id' => $item->id]); + + DeleteRepurpose::execute($repurpose); + + expect(RepurposeItem::whereKey($item->id)->exists())->toBeFalse() + ->and(Post::whereKey($post->id)->exists())->toBeTrue() + ->and($post->fresh()->repurpose_item_id)->toBeNull(); +}); diff --git a/tests/Unit/Policies/RepurposePolicyTest.php b/tests/Unit/Policies/RepurposePolicyTest.php new file mode 100644 index 000000000..c6fa78575 --- /dev/null +++ b/tests/Unit/Policies/RepurposePolicyTest.php @@ -0,0 +1,53 @@ +account = Account::factory()->create(); + $this->owner = User::factory()->create(['account_id' => $this->account->id]); + $this->account->update(['owner_id' => $this->owner->id]); + + $this->workspace = Workspace::factory()->create([ + 'account_id' => $this->account->id, + 'user_id' => $this->owner->id, + ]); + $this->owner->update(['current_workspace_id' => $this->workspace->id]); + + $this->member = User::factory()->create(['account_id' => $this->account->id, 'current_workspace_id' => $this->workspace->id]); + $this->viewer = User::factory()->create(['account_id' => $this->account->id, 'current_workspace_id' => $this->workspace->id]); + + $this->workspace->members()->attach($this->member->id, ['role' => Role::Member->value]); + $this->workspace->members()->attach($this->viewer->id, ['role' => Role::Viewer->value]); + + $this->repurpose = Repurpose::factory()->create(['workspace_id' => $this->workspace->id]); +}); + +test('an owner and a member can manage repurposes', function () { + foreach ([$this->owner, $this->member] as $user) { + expect($user->can('viewAny', Repurpose::class))->toBeTrue() + ->and($user->can('create', Repurpose::class))->toBeTrue() + ->and($user->can('update', $this->repurpose))->toBeTrue() + ->and($user->can('delete', $this->repurpose))->toBeTrue(); + } +}); + +test('a viewer cannot manage repurposes', function () { + expect($this->viewer->can('create', Repurpose::class))->toBeFalse() + ->and($this->viewer->can('update', $this->repurpose))->toBeFalse() + ->and($this->viewer->can('delete', $this->repurpose))->toBeFalse(); +}); + +test('a repurpose from another workspace is invisible', function () { + $stranger = User::factory()->create(); + $strangerWorkspace = Workspace::factory()->create(['user_id' => $stranger->id]); + $stranger->update(['current_workspace_id' => $strangerWorkspace->id]); + + expect($stranger->can('view', $this->repurpose))->toBeFalse() + ->and($stranger->can('update', $this->repurpose))->toBeFalse(); +}); From 3828dbff87e1e1abe359c4071ef90d1fdee622f2 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Sat, 5 Sep 2026 16:08:39 -0300 Subject: [PATCH 004/114] Add the repurpose web surface, pages and translations Creation follows the changelog pattern: a dialog that only asks for the source account, then a redirect to the full edit page where destinations, status and activity live. Only Instagram and Facebook accounts are offered as a source, since they are the only networks that let us download the video. The destination picker lists accounts rather than networks, so a workspace with two Instagram accounts can send to both. Translations for all 16 locales, plus the sidebar entry. --- .../Controllers/App/RepurposeController.php | 146 +++++++++++++++ .../App/Repurpose/StoreRepurposeRequest.php | 31 +++ .../App/Repurpose/UpdateRepurposeRequest.php | 27 +++ lang/ar/repurposes.php | 116 ++++++++++++ lang/ar/sidebar.php | 1 + lang/de/repurposes.php | 116 ++++++++++++ lang/de/sidebar.php | 1 + lang/el/repurposes.php | 116 ++++++++++++ lang/el/sidebar.php | 1 + lang/en/repurposes.php | 116 ++++++++++++ lang/en/sidebar.php | 1 + lang/es/repurposes.php | 116 ++++++++++++ lang/es/sidebar.php | 1 + lang/fr/repurposes.php | 116 ++++++++++++ lang/fr/sidebar.php | 1 + lang/it/repurposes.php | 116 ++++++++++++ lang/it/sidebar.php | 1 + lang/ja/repurposes.php | 116 ++++++++++++ lang/ja/sidebar.php | 1 + lang/ko/repurposes.php | 116 ++++++++++++ lang/ko/sidebar.php | 1 + lang/nl/repurposes.php | 116 ++++++++++++ lang/nl/sidebar.php | 1 + lang/pl/repurposes.php | 116 ++++++++++++ lang/pl/sidebar.php | 1 + lang/pt-BR/repurposes.php | 116 ++++++++++++ lang/pt-BR/sidebar.php | 1 + lang/ru/repurposes.php | 116 ++++++++++++ lang/ru/sidebar.php | 1 + lang/tr/repurposes.php | 116 ++++++++++++ lang/tr/sidebar.php | 1 + lang/uk/repurposes.php | 116 ++++++++++++ lang/uk/sidebar.php | 1 + lang/zh/repurposes.php | 116 ++++++++++++ lang/zh/sidebar.php | 1 + resources/js/components/AppSidebar.vue | 12 ++ .../repurpose/CreateRepurposeDialog.vue | 115 ++++++++++++ .../repurpose/DestinationPicker.vue | 73 ++++++++ .../repurpose/RepurposeItemList.vue | 86 +++++++++ .../repurpose/RepurposeStatusCard.vue | 98 ++++++++++ .../repurpose/RepurposeTemplateCard.vue | 41 ++++ resources/js/composables/useWorkspaceRole.ts | 1 + resources/js/pages/repurposes/Index.vue | 146 +++++++++++++++ resources/js/pages/repurposes/Show.vue | 122 ++++++++++++ resources/js/types/repurpose-status.ts | 22 +++ resources/js/types/repurpose.ts | 46 +++++ routes/app.php | 12 ++ tests/Feature/Repurpose/WebTest.php | 177 ++++++++++++++++++ 48 files changed, 3027 insertions(+) create mode 100644 app/Http/Controllers/App/RepurposeController.php create mode 100644 app/Http/Requests/App/Repurpose/StoreRepurposeRequest.php create mode 100644 app/Http/Requests/App/Repurpose/UpdateRepurposeRequest.php create mode 100644 lang/ar/repurposes.php create mode 100644 lang/de/repurposes.php create mode 100644 lang/el/repurposes.php create mode 100644 lang/en/repurposes.php create mode 100644 lang/es/repurposes.php create mode 100644 lang/fr/repurposes.php create mode 100644 lang/it/repurposes.php create mode 100644 lang/ja/repurposes.php create mode 100644 lang/ko/repurposes.php create mode 100644 lang/nl/repurposes.php create mode 100644 lang/pl/repurposes.php create mode 100644 lang/pt-BR/repurposes.php create mode 100644 lang/ru/repurposes.php create mode 100644 lang/tr/repurposes.php create mode 100644 lang/uk/repurposes.php create mode 100644 lang/zh/repurposes.php create mode 100644 resources/js/components/repurpose/CreateRepurposeDialog.vue create mode 100644 resources/js/components/repurpose/DestinationPicker.vue create mode 100644 resources/js/components/repurpose/RepurposeItemList.vue create mode 100644 resources/js/components/repurpose/RepurposeStatusCard.vue create mode 100644 resources/js/components/repurpose/RepurposeTemplateCard.vue create mode 100644 resources/js/pages/repurposes/Index.vue create mode 100644 resources/js/pages/repurposes/Show.vue create mode 100644 resources/js/types/repurpose-status.ts create mode 100644 resources/js/types/repurpose.ts create mode 100644 tests/Feature/Repurpose/WebTest.php diff --git a/app/Http/Controllers/App/RepurposeController.php b/app/Http/Controllers/App/RepurposeController.php new file mode 100644 index 000000000..0cd4e1acb --- /dev/null +++ b/app/Http/Controllers/App/RepurposeController.php @@ -0,0 +1,146 @@ +authorize('viewAny', Repurpose::class); + + $workspace = $request->user()->currentWorkspace; + + return Inertia::render('repurposes/Index', [ + 'repurposes' => ListRepurposes::execute($workspace), + 'templates' => Templates::all(), + 'sourceAccounts' => SocialAccountResource::collection($this->sourceAccounts($request)), + ]); + } + + public function show(Request $request, Repurpose $repurpose): Response + { + $this->authorize('view', $repurpose); + + return Inertia::render('repurposes/Show', [ + 'repurpose' => $repurpose->load('sourceAccount'), + 'sourceAccounts' => SocialAccountResource::collection($this->sourceAccounts($request)), + 'destinationAccounts' => SocialAccountResource::collection($this->destinationAccounts($request, $repurpose)), + 'items' => Inertia::scroll(fn () => ListRepurposeItems::execute($repurpose)), + ]); + } + + public function store(StoreRepurposeRequest $request): RedirectResponse + { + $workspace = $request->user()->currentWorkspace; + $sourceAccountId = (string) $request->validated('source_social_account_id'); + + $existing = CreateRepurpose::existingFor($workspace, $sourceAccountId); + + if ($existing !== null) { + return redirect()->route('app.repurposes.show', $existing); + } + + $repurpose = CreateRepurpose::execute($workspace, $request->user(), $request->validated()); + + return redirect()->route('app.repurposes.show', $repurpose); + } + + public function update(UpdateRepurposeRequest $request, Repurpose $repurpose): RedirectResponse + { + UpdateRepurpose::execute($repurpose, $request->validated()); + + return back(); + } + + public function activate(Request $request, Repurpose $repurpose): RedirectResponse + { + $this->authorize('update', $repurpose); + + ActivateRepurpose::execute($repurpose); + + return back(); + } + + public function pause(Request $request, Repurpose $repurpose): RedirectResponse + { + $this->authorize('update', $repurpose); + + PauseRepurpose::execute($repurpose); + + return back(); + } + + public function resume(Request $request, Repurpose $repurpose): RedirectResponse + { + $this->authorize('update', $repurpose); + + ResumeRepurpose::execute($repurpose); + + return back(); + } + + public function disable(Request $request, Repurpose $repurpose): RedirectResponse + { + $this->authorize('update', $repurpose); + + DisableRepurpose::execute($repurpose); + + return back(); + } + + public function destroy(Request $request, Repurpose $repurpose): RedirectResponse + { + $this->authorize('delete', $repurpose); + + DeleteRepurpose::execute($repurpose); + + return redirect()->route('app.repurposes.index'); + } + + /** + * Only networks TryPost can both list and download from can be a source. + */ + private function sourceAccounts(Request $request) + { + return $request->user()->currentWorkspace + ->socialAccounts() + ->active() + ->whereIn('platform', array_map(fn ($platform) => $platform->value, SourceFetcherFactory::supportedPlatforms())) + ->get(); + } + + /** + * Accounts, not networks: a workspace may hold two Instagram accounts and + * both are valid destinations. + */ + private function destinationAccounts(Request $request, Repurpose $repurpose) + { + return $request->user()->currentWorkspace + ->socialAccounts() + ->active() + ->whereKeyNot($repurpose->source_social_account_id) + ->get(); + } +} diff --git a/app/Http/Requests/App/Repurpose/StoreRepurposeRequest.php b/app/Http/Requests/App/Repurpose/StoreRepurposeRequest.php new file mode 100644 index 000000000..2e75e4c94 --- /dev/null +++ b/app/Http/Requests/App/Repurpose/StoreRepurposeRequest.php @@ -0,0 +1,31 @@ +user()->can('create', Repurpose::class); + } + + /** + * Creation only asks for the source account; destinations are chosen on the + * edit screen, where there is room for each network's options. + * + * @return array + */ + public function rules(): array + { + return [ + 'source_social_account_id' => RepurposeRules::rules()['source_social_account_id'], + 'template' => ['sometimes', 'nullable', 'string'], + ]; + } +} diff --git a/app/Http/Requests/App/Repurpose/UpdateRepurposeRequest.php b/app/Http/Requests/App/Repurpose/UpdateRepurposeRequest.php new file mode 100644 index 000000000..b0eb3b482 --- /dev/null +++ b/app/Http/Requests/App/Repurpose/UpdateRepurposeRequest.php @@ -0,0 +1,27 @@ +user()->can('update', $this->route('repurpose')); + } + + /** + * @return array + */ + public function rules(): array + { + return [ + ...RepurposeRules::rules(), + 'source_social_account_id' => ['sometimes', 'string', 'uuid'], + ]; + } +} diff --git a/lang/ar/repurposes.php b/lang/ar/repurposes.php new file mode 100644 index 000000000..f70270e6e --- /dev/null +++ b/lang/ar/repurposes.php @@ -0,0 +1,116 @@ + 'Repurpose', + 'description' => 'أعد نشر مقاطع الفيديو التي تنشرها خارج TryPost على شبكاتك الأخرى تلقائيًا.', + 'new' => 'repurpose جديد', + + 'empty' => [ + 'title' => 'لم يتم إعداد أي repurpose بعد', + 'description' => 'اختر نقطة بداية بالأسفل. يراقب TryPost الحساب الذي تختاره ويعيد نشر كل فيديو جديد على الشبكات التي تحددها.', + ], + + 'table' => [ + 'source' => 'المصدر', + 'destinations' => 'الوجهات', + 'status' => 'الحالة', + 'published' => 'تم النسخ', + 'last_polled' => 'آخر فحص', + ], + + 'status' => [ + 'draft' => 'مسودة', + 'active' => 'نشط', + 'paused' => 'متوقف مؤقتًا', + 'disabled' => 'معطّل', + ], + + 'templates' => [ + 'use' => 'استخدام هذا القالب', + 'instagram_everywhere' => [ + 'title' => 'Instagram في كل مكان', + 'description' => 'انشر Reel على Instagram وسيعيد TryPost نشره على TikTok وYouTube Shorts وFacebook.', + ], + 'facebook_everywhere' => [ + 'title' => 'Facebook في كل مكان', + 'description' => 'انشر فيديو على صفحتك في Facebook وسيعيد TryPost نشره على Instagram وTikTok وYouTube Shorts.', + ], + ], + + 'create' => [ + 'title' => 'repurpose جديد', + 'description' => 'اختر الحساب الذي يجب أن يراقبه TryPost. تختار الوجهات في الشاشة التالية.', + 'source_label' => 'حساب المصدر', + 'source_placeholder' => 'اختر حسابًا', + 'no_accounts' => 'اربط أولًا حساب Instagram أو Facebook. هذان فقط يصلحان كمصدر، لأنهما الشبكتان الوحيدتان اللتان تسمحان بتنزيل الفيديو.', + 'submit' => 'إنشاء', + ], + + 'show' => [ + 'title' => 'Repurpose', + 'description' => 'تُنسخ مقاطع الفيديو المنشورة على هذا الحساب خارج TryPost إلى الوجهات أدناه.', + ], + + 'tabs' => [ + 'configuration' => 'الإعداد', + 'activity' => 'النشاط', + ], + + 'destinations' => [ + 'title' => 'الوجهات', + 'description' => 'يُنشر كل فيديو جديد من المصدر على كل حساب تختاره هنا.', + 'hint' => 'يُعدَّل النص لكل شبكة فقط عندما يتجاوز حد تلك الشبكة.', + 'none_available' => 'لا يوجد حساب آخر متصل في مساحة العمل هذه بعد.', + ], + + 'status_card' => [ + 'title' => 'الحالة', + 'activate' => 'تفعيل', + 'pause' => 'إيقاف مؤقت', + 'resume' => 'استئناف', + 'disable' => 'تعطيل', + 'watermark' => 'المراقبة منذ', + 'last_polled' => 'آخر فحص', + 'draft_hint' => 'اختر وجهة واحدة على الأقل ثم فعّل. تُنسخ فقط مقاطع الفيديو المنشورة بعد التفعيل.', + 'active_hint' => 'يفحص TryPost هذا الحساب بانتظام وينسخ كل فيديو جديد.', + 'paused_hint' => 'الفحوصات متوقفة. الاستئناف يكمل من حيث توقف ولا يضيع شيء نُشر في الأثناء.', + 'disabled_hint' => 'معطّل. التفعيل من جديد يبدأ من الصفر: ما نشرته أثناء التعطيل يبقى خارجًا.', + ], + + 'items' => [ + 'source' => 'الأصل', + 'published_at' => 'نُشر', + 'status' => 'الحالة', + 'detail' => 'التفاصيل', + 'posts' => 'نُسخ إلى', + 'view_original' => 'عرض الأصل', + 'open_post' => 'فتح المنشور', + 'statuses' => [ + 'pending' => 'في الانتظار', + 'processing' => 'قيد المعالجة', + 'published' => 'تم النسخ', + 'skipped' => 'تم التخطي', + 'failed' => 'فشل', + ], + 'reasons' => [ + 'published_via_trypost' => 'تم نشره بالفعل عبر TryPost', + 'not_video' => 'ليس فيديو', + 'media_url_missing' => 'لم توفّر الشبكة ملفًا قابلًا للتنزيل، عادةً بسبب صوت محمي بحقوق النشر', + 'download_failed' => 'تعذّر تنزيل الفيديو', + 'post_creation_failed' => 'لا توجد وجهة متاحة', + ], + ], + + 'danger' => [ + 'title' => 'حذف هذا الـ repurpose', + 'description' => 'تتوقف الفحوصات فورًا. تبقى المنشورات التي أُنشئت في تقويمك.', + 'delete' => 'حذف الـ repurpose', + ], + + 'errors' => [ + 'source_already_used' => 'هذا الحساب يغذّي بالفعل repurpose آخر. عدّل ذلك بدلًا منه.', + 'destinations_required' => 'اختر وجهة واحدة على الأقل قبل التفعيل.', + ], +]; diff --git a/lang/ar/sidebar.php b/lang/ar/sidebar.php index 75f34366b..a424c1c10 100644 --- a/lang/ar/sidebar.php +++ b/lang/ar/sidebar.php @@ -26,6 +26,7 @@ 'others' => 'أخرى', ], 'analytics' => 'التحليلات', + 'repurposes' => 'Repurpose', 'onboarding' => 'البدء', 'onboarding_hint' => 'أكمل الإعداد', 'posts' => [ diff --git a/lang/de/repurposes.php b/lang/de/repurposes.php new file mode 100644 index 000000000..3751da7a8 --- /dev/null +++ b/lang/de/repurposes.php @@ -0,0 +1,116 @@ + 'Repurpose', + 'description' => 'Videos, die du außerhalb von TryPost postest, automatisch auf deinen anderen Netzwerken wiederveröffentlichen.', + 'new' => 'Neues Repurpose', + + 'empty' => [ + 'title' => 'Noch kein Repurpose eingerichtet', + 'description' => 'Wähle unten einen Startpunkt. TryPost beobachtet das gewählte Konto und veröffentlicht jedes neue Video erneut auf den Netzwerken deiner Wahl.', + ], + + 'table' => [ + 'source' => 'Quelle', + 'destinations' => 'Ziele', + 'status' => 'Status', + 'published' => 'Repliziert', + 'last_polled' => 'Zuletzt geprüft', + ], + + 'status' => [ + 'draft' => 'Entwurf', + 'active' => 'Aktiv', + 'paused' => 'Pausiert', + 'disabled' => 'Deaktiviert', + ], + + 'templates' => [ + 'use' => 'Diese Vorlage verwenden', + 'instagram_everywhere' => [ + 'title' => 'Instagram überall', + 'description' => 'Poste ein Reel auf Instagram und TryPost veröffentlicht es erneut auf TikTok, YouTube Shorts und Facebook.', + ], + 'facebook_everywhere' => [ + 'title' => 'Facebook überall', + 'description' => 'Poste ein Video auf deiner Facebook-Seite und TryPost veröffentlicht es erneut auf Instagram, TikTok und YouTube Shorts.', + ], + ], + + 'create' => [ + 'title' => 'Neues Repurpose', + 'description' => 'Wähle das Konto, das TryPost beobachten soll. Die Ziele wählst du im nächsten Schritt.', + 'source_label' => 'Quellkonto', + 'source_placeholder' => 'Konto auswählen', + 'no_accounts' => 'Verbinde zuerst ein Instagram- oder Facebook-Konto. Nur diese können Quelle sein, weil nur sie den Download des Videos erlauben.', + 'submit' => 'Erstellen', + ], + + 'show' => [ + 'title' => 'Repurpose', + 'description' => 'Videos, die außerhalb von TryPost auf diesem Konto erscheinen, werden auf die Ziele unten repliziert.', + ], + + 'tabs' => [ + 'configuration' => 'Konfiguration', + 'activity' => 'Aktivität', + ], + + 'destinations' => [ + 'title' => 'Ziele', + 'description' => 'Jedes neue Video der Quelle wird auf jedem hier gewählten Konto veröffentlicht.', + 'hint' => 'Der Text wird nur dann pro Netzwerk angepasst, wenn er dessen Limit überschreitet.', + 'none_available' => 'In diesem Workspace ist noch kein weiteres Konto verbunden.', + ], + + 'status_card' => [ + 'title' => 'Status', + 'activate' => 'Aktivieren', + 'pause' => 'Pausieren', + 'resume' => 'Fortsetzen', + 'disable' => 'Deaktivieren', + 'watermark' => 'Beobachtet seit', + 'last_polled' => 'Zuletzt geprüft', + 'draft_hint' => 'Wähle mindestens ein Ziel und aktiviere dann. Nur Videos nach der Aktivierung werden repliziert.', + 'active_hint' => 'TryPost prüft dieses Konto regelmäßig und repliziert jedes neue Video.', + 'paused_hint' => 'Die Prüfungen pausieren. Beim Fortsetzen geht es dort weiter, wo es aufgehört hat, nichts geht verloren.', + 'disabled_hint' => 'Ausgeschaltet. Beim erneuten Aktivieren beginnt es von vorn: Was du währenddessen gepostet hast, bleibt außen vor.', + ], + + 'items' => [ + 'source' => 'Original', + 'published_at' => 'Gepostet', + 'status' => 'Status', + 'detail' => 'Detail', + 'posts' => 'Repliziert auf', + 'view_original' => 'Original ansehen', + 'open_post' => 'Beitrag öffnen', + 'statuses' => [ + 'pending' => 'In Warteschlange', + 'processing' => 'Wird verarbeitet', + 'published' => 'Repliziert', + 'skipped' => 'Übersprungen', + 'failed' => 'Fehlgeschlagen', + ], + 'reasons' => [ + 'published_via_trypost' => 'Bereits über TryPost veröffentlicht', + 'not_video' => 'Kein Video', + 'media_url_missing' => 'Das Netzwerk hat keine herunterladbare Datei bereitgestellt, meist wegen urheberrechtlich geschütztem Audio', + 'download_failed' => 'Das Video konnte nicht heruntergeladen werden', + 'post_creation_failed' => 'Kein Ziel verfügbar', + ], + ], + + 'danger' => [ + 'title' => 'Dieses Repurpose löschen', + 'description' => 'Die Prüfungen stoppen sofort. Bereits erstellte Beiträge bleiben in deinem Kalender.', + 'delete' => 'Repurpose löschen', + ], + + 'errors' => [ + 'source_already_used' => 'Dieses Konto speist bereits ein anderes Repurpose. Bearbeite stattdessen jenes.', + 'destinations_required' => 'Wähle vor dem Aktivieren mindestens ein Ziel.', + ], +]; diff --git a/lang/de/sidebar.php b/lang/de/sidebar.php index 73160a271..0807a91a6 100644 --- a/lang/de/sidebar.php +++ b/lang/de/sidebar.php @@ -26,6 +26,7 @@ 'others' => 'Sonstiges', ], 'analytics' => 'Analytics', + 'repurposes' => 'Repurpose', 'onboarding' => 'Erste Schritte', 'onboarding_hint' => 'Einrichtung abschließen', 'posts' => [ diff --git a/lang/el/repurposes.php b/lang/el/repurposes.php new file mode 100644 index 000000000..7f90c5cf3 --- /dev/null +++ b/lang/el/repurposes.php @@ -0,0 +1,116 @@ + 'Repurpose', + 'description' => 'Αναδημοσίευσε αυτόματα στα άλλα σου δίκτυα τα βίντεο που ανεβάζεις εκτός TryPost.', + 'new' => 'Νέο repurpose', + + 'empty' => [ + 'title' => 'Δεν έχει ρυθμιστεί repurpose ακόμη', + 'description' => 'Διάλεξε ένα σημείο εκκίνησης παρακάτω. Το TryPost παρακολουθεί τον λογαριασμό που επιλέγεις και αναδημοσιεύει κάθε νέο βίντεο στα δίκτυα που σημειώνεις.', + ], + + 'table' => [ + 'source' => 'Πηγή', + 'destinations' => 'Προορισμοί', + 'status' => 'Κατάσταση', + 'published' => 'Αναπαράχθηκαν', + 'last_polled' => 'Τελευταίος έλεγχος', + ], + + 'status' => [ + 'draft' => 'Πρόχειρο', + 'active' => 'Ενεργό', + 'paused' => 'Σε παύση', + 'disabled' => 'Απενεργοποιημένο', + ], + + 'templates' => [ + 'use' => 'Χρήση προτύπου', + 'instagram_everywhere' => [ + 'title' => 'Instagram παντού', + 'description' => 'Ανέβασε ένα Reel στο Instagram και το TryPost το αναδημοσιεύει σε TikTok, YouTube Shorts και Facebook.', + ], + 'facebook_everywhere' => [ + 'title' => 'Facebook παντού', + 'description' => 'Ανέβασε ένα βίντεο στη σελίδα σου στο Facebook και το TryPost το αναδημοσιεύει σε Instagram, TikTok και YouTube Shorts.', + ], + ], + + 'create' => [ + 'title' => 'Νέο repurpose', + 'description' => 'Διάλεξε τον λογαριασμό που θα παρακολουθεί το TryPost. Τους προορισμούς τους επιλέγεις στην επόμενη οθόνη.', + 'source_label' => 'Λογαριασμός πηγής', + 'source_placeholder' => 'Επίλεξε λογαριασμό', + 'no_accounts' => 'Σύνδεσε πρώτα έναν λογαριασμό Instagram ή Facebook. Μόνο αυτοί μπορούν να είναι πηγή, γιατί μόνο αυτά τα δίκτυα επιτρέπουν τη λήψη του βίντεο.', + 'submit' => 'Δημιουργία', + ], + + 'show' => [ + 'title' => 'Repurpose', + 'description' => 'Τα βίντεο που δημοσιεύονται σε αυτόν τον λογαριασμό εκτός TryPost αναπαράγονται στους παρακάτω προορισμούς.', + ], + + 'tabs' => [ + 'configuration' => 'Ρύθμιση', + 'activity' => 'Δραστηριότητα', + ], + + 'destinations' => [ + 'title' => 'Προορισμοί', + 'description' => 'Κάθε νέο βίντεο της πηγής δημοσιεύεται σε κάθε λογαριασμό που επιλέγεις εδώ.', + 'hint' => 'Η λεζάντα προσαρμόζεται ανά δίκτυο μόνο όταν ξεπερνά το όριο εκείνου του δικτύου.', + 'none_available' => 'Δεν υπάρχει άλλος συνδεδεμένος λογαριασμός σε αυτόν τον χώρο εργασίας.', + ], + + 'status_card' => [ + 'title' => 'Κατάσταση', + 'activate' => 'Ενεργοποίηση', + 'pause' => 'Παύση', + 'resume' => 'Συνέχιση', + 'disable' => 'Απενεργοποίηση', + 'watermark' => 'Παρακολούθηση από', + 'last_polled' => 'Τελευταίος έλεγχος', + 'draft_hint' => 'Διάλεξε τουλάχιστον έναν προορισμό και ενεργοποίησε. Αναπαράγονται μόνο βίντεο μετά την ενεργοποίηση.', + 'active_hint' => 'Το TryPost ελέγχει τακτικά αυτόν τον λογαριασμό και αναπαράγει κάθε νέο βίντεο.', + 'paused_hint' => 'Οι έλεγχοι είναι σε αναμονή. Η συνέχιση ξεκινά από εκεί που σταμάτησε και δεν χάνεται τίποτα.', + 'disabled_hint' => 'Απενεργοποιημένο. Η εκ νέου ενεργοποίηση ξεκινά από την αρχή: ό,τι ανέβασες όσο ήταν κλειστό μένει εκτός.', + ], + + 'items' => [ + 'source' => 'Πρωτότυπο', + 'published_at' => 'Δημοσιεύτηκε', + 'status' => 'Κατάσταση', + 'detail' => 'Λεπτομέρεια', + 'posts' => 'Αναπαράχθηκε σε', + 'view_original' => 'Δες το πρωτότυπο', + 'open_post' => 'Άνοιγμα ανάρτησης', + 'statuses' => [ + 'pending' => 'Σε αναμονή', + 'processing' => 'Σε επεξεργασία', + 'published' => 'Αναπαράχθηκε', + 'skipped' => 'Παραλείφθηκε', + 'failed' => 'Απέτυχε', + ], + 'reasons' => [ + 'published_via_trypost' => 'Δημοσιεύτηκε ήδη μέσω TryPost', + 'not_video' => 'Δεν είναι βίντεο', + 'media_url_missing' => 'Το δίκτυο δεν έδωσε αρχείο για λήψη, συνήθως λόγω ήχου με πνευματικά δικαιώματα', + 'download_failed' => 'Δεν ήταν δυνατή η λήψη του βίντεο', + 'post_creation_failed' => 'Δεν υπάρχει διαθέσιμος προορισμός', + ], + ], + + 'danger' => [ + 'title' => 'Διαγραφή αυτού του repurpose', + 'description' => 'Οι έλεγχοι σταματούν αμέσως. Οι αναρτήσεις που δημιουργήθηκαν παραμένουν στο ημερολόγιό σου.', + 'delete' => 'Διαγραφή repurpose', + ], + + 'errors' => [ + 'source_already_used' => 'Αυτός ο λογαριασμός τροφοδοτεί ήδη άλλο repurpose. Επεξεργάσου εκείνο.', + 'destinations_required' => 'Διάλεξε τουλάχιστον έναν προορισμό πριν την ενεργοποίηση.', + ], +]; diff --git a/lang/el/sidebar.php b/lang/el/sidebar.php index dcb51f2f0..9e3db2be6 100644 --- a/lang/el/sidebar.php +++ b/lang/el/sidebar.php @@ -26,6 +26,7 @@ 'others' => 'Άλλα', ], 'analytics' => 'Στατιστικά', + 'repurposes' => 'Repurpose', 'onboarding' => 'Ξεκινώντας', 'onboarding_hint' => 'Ολοκλήρωση ρύθμισης', 'posts' => [ diff --git a/lang/en/repurposes.php b/lang/en/repurposes.php new file mode 100644 index 000000000..1cd0982b3 --- /dev/null +++ b/lang/en/repurposes.php @@ -0,0 +1,116 @@ + 'Repurpose', + 'description' => 'Replicate videos you post outside TryPost to your other networks, automatically.', + 'new' => 'New repurpose', + + 'empty' => [ + 'title' => 'No repurpose set up yet', + 'description' => 'Pick a starting point below. TryPost watches the account you choose and republishes every new video to the networks you pick.', + ], + + 'table' => [ + 'source' => 'Source', + 'destinations' => 'Destinations', + 'status' => 'Status', + 'published' => 'Replicated', + 'last_polled' => 'Last checked', + ], + + 'status' => [ + 'draft' => 'Draft', + 'active' => 'Active', + 'paused' => 'Paused', + 'disabled' => 'Disabled', + ], + + 'templates' => [ + 'use' => 'Use this template', + 'instagram_everywhere' => [ + 'title' => 'Instagram everywhere', + 'description' => 'Post a Reel on Instagram and TryPost republishes it to TikTok, YouTube Shorts and Facebook.', + ], + 'facebook_everywhere' => [ + 'title' => 'Facebook everywhere', + 'description' => 'Post a video on your Facebook Page and TryPost republishes it to Instagram, TikTok and YouTube Shorts.', + ], + ], + + 'create' => [ + 'title' => 'New repurpose', + 'description' => 'Choose the account TryPost should watch. You pick the destinations on the next screen.', + 'source_label' => 'Source account', + 'source_placeholder' => 'Select an account', + 'no_accounts' => 'Connect an Instagram or Facebook account first. Only these can be a source, because they are the only networks that let us download the video.', + 'submit' => 'Create', + ], + + 'show' => [ + 'title' => 'Repurpose', + 'description' => 'Videos published on this account outside TryPost are replicated to the destinations below.', + ], + + 'tabs' => [ + 'configuration' => 'Configuration', + 'activity' => 'Activity', + ], + + 'destinations' => [ + 'title' => 'Destinations', + 'description' => 'Every new video from the source is published to each account you select here.', + 'hint' => 'Captions are adapted per network only when they exceed that network\'s limit.', + 'none_available' => 'No other account is connected in this workspace yet.', + ], + + 'status_card' => [ + 'title' => 'Status', + 'activate' => 'Activate', + 'pause' => 'Pause', + 'resume' => 'Resume', + 'disable' => 'Disable', + 'watermark' => 'Watching since', + 'last_polled' => 'Last checked', + 'draft_hint' => 'Pick at least one destination, then activate. Only videos posted after you activate are replicated.', + 'active_hint' => 'TryPost checks this account regularly and replicates every new video.', + 'paused_hint' => 'Checks are on hold. Resuming picks up where it stopped, so nothing posted meanwhile is lost.', + 'disabled_hint' => 'Turned off. Activating again starts fresh: whatever you posted while it was off stays off.', + ], + + 'items' => [ + 'source' => 'Original', + 'published_at' => 'Posted', + 'status' => 'Status', + 'detail' => 'Detail', + 'posts' => 'Replicated to', + 'view_original' => 'View original', + 'open_post' => 'Open post', + 'statuses' => [ + 'pending' => 'Queued', + 'processing' => 'Processing', + 'published' => 'Replicated', + 'skipped' => 'Skipped', + 'failed' => 'Failed', + ], + 'reasons' => [ + 'published_via_trypost' => 'Already published through TryPost', + 'not_video' => 'Not a video', + 'media_url_missing' => 'The network did not share a downloadable file, usually because of copyrighted audio', + 'download_failed' => 'The video could not be downloaded', + 'post_creation_failed' => 'No destination was available', + ], + ], + + 'danger' => [ + 'title' => 'Delete this repurpose', + 'description' => 'Checks stop immediately. Posts already created stay in your calendar.', + 'delete' => 'Delete repurpose', + ], + + 'errors' => [ + 'source_already_used' => 'This account already feeds another repurpose. Edit that one instead.', + 'destinations_required' => 'Pick at least one destination before activating.', + ], +]; diff --git a/lang/en/sidebar.php b/lang/en/sidebar.php index 0365d9981..4a37c1eda 100644 --- a/lang/en/sidebar.php +++ b/lang/en/sidebar.php @@ -26,6 +26,7 @@ 'others' => 'Others', ], 'analytics' => 'Analytics', + 'repurposes' => 'Repurpose', 'onboarding' => 'Getting started', 'onboarding_hint' => 'Finish setup', 'posts' => [ diff --git a/lang/es/repurposes.php b/lang/es/repurposes.php new file mode 100644 index 000000000..769abbbf7 --- /dev/null +++ b/lang/es/repurposes.php @@ -0,0 +1,116 @@ + 'Repurpose', + 'description' => 'Replica automáticamente en tus otras redes los vídeos que publicas fuera de TryPost.', + 'new' => 'Nuevo repurpose', + + 'empty' => [ + 'title' => 'Aún no hay ningún repurpose', + 'description' => 'Elige un punto de partida abajo. TryPost vigila la cuenta que elijas y republica cada vídeo nuevo en las redes que marques.', + ], + + 'table' => [ + 'source' => 'Origen', + 'destinations' => 'Destinos', + 'status' => 'Estado', + 'published' => 'Replicados', + 'last_polled' => 'Última comprobación', + ], + + 'status' => [ + 'draft' => 'Borrador', + 'active' => 'Activo', + 'paused' => 'En pausa', + 'disabled' => 'Desactivado', + ], + + 'templates' => [ + 'use' => 'Usar esta plantilla', + 'instagram_everywhere' => [ + 'title' => 'Instagram en todas partes', + 'description' => 'Publica un Reel en Instagram y TryPost lo republica en TikTok, YouTube Shorts y Facebook.', + ], + 'facebook_everywhere' => [ + 'title' => 'Facebook en todas partes', + 'description' => 'Publica un vídeo en tu página de Facebook y TryPost lo republica en Instagram, TikTok y YouTube Shorts.', + ], + ], + + 'create' => [ + 'title' => 'Nuevo repurpose', + 'description' => 'Elige la cuenta que TryPost debe vigilar. Los destinos se eligen en la siguiente pantalla.', + 'source_label' => 'Cuenta de origen', + 'source_placeholder' => 'Selecciona una cuenta', + 'no_accounts' => 'Conecta antes una cuenta de Instagram o Facebook. Solo ellas pueden ser origen, porque son las únicas redes que permiten descargar el vídeo.', + 'submit' => 'Crear', + ], + + 'show' => [ + 'title' => 'Repurpose', + 'description' => 'Los vídeos publicados en esta cuenta fuera de TryPost se replican en los destinos de abajo.', + ], + + 'tabs' => [ + 'configuration' => 'Configuración', + 'activity' => 'Actividad', + ], + + 'destinations' => [ + 'title' => 'Destinos', + 'description' => 'Cada vídeo nuevo del origen se publica en todas las cuentas que selecciones aquí.', + 'hint' => 'El texto solo se adapta por red cuando supera el límite de esa red.', + 'none_available' => 'No hay ninguna otra cuenta conectada en este espacio de trabajo.', + ], + + 'status_card' => [ + 'title' => 'Estado', + 'activate' => 'Activar', + 'pause' => 'Pausar', + 'resume' => 'Reanudar', + 'disable' => 'Desactivar', + 'watermark' => 'Vigilando desde', + 'last_polled' => 'Última comprobación', + 'draft_hint' => 'Elige al menos un destino y actívalo. Solo se replican los vídeos publicados después de activarlo.', + 'active_hint' => 'TryPost comprueba esta cuenta con regularidad y replica cada vídeo nuevo.', + 'paused_hint' => 'Las comprobaciones están detenidas. Al reanudar, continúa donde lo dejó y no se pierde nada publicado mientras tanto.', + 'disabled_hint' => 'Apagado. Al activarlo de nuevo empieza desde cero: lo que publicaste mientras estaba apagado se queda fuera.', + ], + + 'items' => [ + 'source' => 'Original', + 'published_at' => 'Publicado', + 'status' => 'Estado', + 'detail' => 'Detalle', + 'posts' => 'Replicado en', + 'view_original' => 'Ver original', + 'open_post' => 'Abrir publicación', + 'statuses' => [ + 'pending' => 'En cola', + 'processing' => 'Procesando', + 'published' => 'Replicado', + 'skipped' => 'Omitido', + 'failed' => 'Falló', + ], + 'reasons' => [ + 'published_via_trypost' => 'Ya publicado con TryPost', + 'not_video' => 'No es un vídeo', + 'media_url_missing' => 'La red no compartió un archivo descargable, normalmente por audio con derechos de autor', + 'download_failed' => 'No se pudo descargar el vídeo', + 'post_creation_failed' => 'No había ningún destino disponible', + ], + ], + + 'danger' => [ + 'title' => 'Eliminar este repurpose', + 'description' => 'Las comprobaciones se detienen de inmediato. Las publicaciones ya creadas siguen en tu calendario.', + 'delete' => 'Eliminar repurpose', + ], + + 'errors' => [ + 'source_already_used' => 'Esta cuenta ya alimenta otro repurpose. Edita ese.', + 'destinations_required' => 'Elige al menos un destino antes de activar.', + ], +]; diff --git a/lang/es/sidebar.php b/lang/es/sidebar.php index 185bc85c5..15d5061d5 100644 --- a/lang/es/sidebar.php +++ b/lang/es/sidebar.php @@ -26,6 +26,7 @@ 'others' => 'Otros', ], 'analytics' => 'Analytics', + 'repurposes' => 'Repurpose', 'onboarding' => 'Primeros pasos', 'onboarding_hint' => 'Termina la configuración', 'posts' => [ diff --git a/lang/fr/repurposes.php b/lang/fr/repurposes.php new file mode 100644 index 000000000..4e3475a9f --- /dev/null +++ b/lang/fr/repurposes.php @@ -0,0 +1,116 @@ + 'Repurpose', + 'description' => 'Republiez automatiquement sur vos autres réseaux les vidéos que vous postez en dehors de TryPost.', + 'new' => 'Nouveau repurpose', + + 'empty' => [ + 'title' => 'Aucun repurpose configuré', + 'description' => 'Choisissez un point de départ ci-dessous. TryPost surveille le compte choisi et republie chaque nouvelle vidéo sur les réseaux que vous sélectionnez.', + ], + + 'table' => [ + 'source' => 'Source', + 'destinations' => 'Destinations', + 'status' => 'Statut', + 'published' => 'Répliquées', + 'last_polled' => 'Dernière vérification', + ], + + 'status' => [ + 'draft' => 'Brouillon', + 'active' => 'Actif', + 'paused' => 'En pause', + 'disabled' => 'Désactivé', + ], + + 'templates' => [ + 'use' => 'Utiliser ce modèle', + 'instagram_everywhere' => [ + 'title' => 'Instagram partout', + 'description' => 'Publiez un Reel sur Instagram et TryPost le republie sur TikTok, YouTube Shorts et Facebook.', + ], + 'facebook_everywhere' => [ + 'title' => 'Facebook partout', + 'description' => 'Publiez une vidéo sur votre Page Facebook et TryPost la republie sur Instagram, TikTok et YouTube Shorts.', + ], + ], + + 'create' => [ + 'title' => 'Nouveau repurpose', + 'description' => 'Choisissez le compte que TryPost doit surveiller. Les destinations se choisissent à l\'écran suivant.', + 'source_label' => 'Compte source', + 'source_placeholder' => 'Sélectionner un compte', + 'no_accounts' => 'Connectez d\'abord un compte Instagram ou Facebook. Seuls ces réseaux peuvent être source, car ce sont les seuls qui permettent de télécharger la vidéo.', + 'submit' => 'Créer', + ], + + 'show' => [ + 'title' => 'Repurpose', + 'description' => 'Les vidéos publiées sur ce compte en dehors de TryPost sont répliquées vers les destinations ci-dessous.', + ], + + 'tabs' => [ + 'configuration' => 'Configuration', + 'activity' => 'Activité', + ], + + 'destinations' => [ + 'title' => 'Destinations', + 'description' => 'Chaque nouvelle vidéo de la source est publiée sur chaque compte sélectionné ici.', + 'hint' => 'La légende n\'est adaptée par réseau que lorsqu\'elle dépasse la limite de ce réseau.', + 'none_available' => 'Aucun autre compte n\'est connecté dans cet espace de travail.', + ], + + 'status_card' => [ + 'title' => 'Statut', + 'activate' => 'Activer', + 'pause' => 'Mettre en pause', + 'resume' => 'Reprendre', + 'disable' => 'Désactiver', + 'watermark' => 'Surveillé depuis', + 'last_polled' => 'Dernière vérification', + 'draft_hint' => 'Choisissez au moins une destination, puis activez. Seules les vidéos publiées après l\'activation sont répliquées.', + 'active_hint' => 'TryPost vérifie ce compte régulièrement et réplique chaque nouvelle vidéo.', + 'paused_hint' => 'Les vérifications sont suspendues. La reprise repart là où elle s\'est arrêtée, rien n\'est perdu.', + 'disabled_hint' => 'Désactivé. Une nouvelle activation repart de zéro : ce que vous avez publié entre-temps reste de côté.', + ], + + 'items' => [ + 'source' => 'Original', + 'published_at' => 'Publié', + 'status' => 'Statut', + 'detail' => 'Détail', + 'posts' => 'Répliqué sur', + 'view_original' => 'Voir l\'original', + 'open_post' => 'Ouvrir la publication', + 'statuses' => [ + 'pending' => 'En file d\'attente', + 'processing' => 'Traitement', + 'published' => 'Répliqué', + 'skipped' => 'Ignoré', + 'failed' => 'Échec', + ], + 'reasons' => [ + 'published_via_trypost' => 'Déjà publié via TryPost', + 'not_video' => 'Ce n\'est pas une vidéo', + 'media_url_missing' => 'Le réseau n\'a pas fourni de fichier téléchargeable, généralement à cause d\'un audio protégé par le droit d\'auteur', + 'download_failed' => 'La vidéo n\'a pas pu être téléchargée', + 'post_creation_failed' => 'Aucune destination disponible', + ], + ], + + 'danger' => [ + 'title' => 'Supprimer ce repurpose', + 'description' => 'Les vérifications s\'arrêtent immédiatement. Les publications déjà créées restent dans votre calendrier.', + 'delete' => 'Supprimer le repurpose', + ], + + 'errors' => [ + 'source_already_used' => 'Ce compte alimente déjà un autre repurpose. Modifiez celui-là.', + 'destinations_required' => 'Choisissez au moins une destination avant d\'activer.', + ], +]; diff --git a/lang/fr/sidebar.php b/lang/fr/sidebar.php index a5eac5465..ba4c6f791 100644 --- a/lang/fr/sidebar.php +++ b/lang/fr/sidebar.php @@ -26,6 +26,7 @@ 'others' => 'Autres', ], 'analytics' => 'Statistiques', + 'repurposes' => 'Repurpose', 'onboarding' => 'Premiers pas', 'onboarding_hint' => 'Terminer la configuration', 'posts' => [ diff --git a/lang/it/repurposes.php b/lang/it/repurposes.php new file mode 100644 index 000000000..2a4b581db --- /dev/null +++ b/lang/it/repurposes.php @@ -0,0 +1,116 @@ + 'Repurpose', + 'description' => 'Ripubblica automaticamente sulle altre reti i video che pubblichi fuori da TryPost.', + 'new' => 'Nuovo repurpose', + + 'empty' => [ + 'title' => 'Nessun repurpose configurato', + 'description' => 'Scegli un punto di partenza qui sotto. TryPost tiene d\'occhio l\'account scelto e ripubblica ogni nuovo video sulle reti che selezioni.', + ], + + 'table' => [ + 'source' => 'Origine', + 'destinations' => 'Destinazioni', + 'status' => 'Stato', + 'published' => 'Replicati', + 'last_polled' => 'Ultimo controllo', + ], + + 'status' => [ + 'draft' => 'Bozza', + 'active' => 'Attivo', + 'paused' => 'In pausa', + 'disabled' => 'Disattivato', + ], + + 'templates' => [ + 'use' => 'Usa questo modello', + 'instagram_everywhere' => [ + 'title' => 'Instagram ovunque', + 'description' => 'Pubblica un Reel su Instagram e TryPost lo ripubblica su TikTok, YouTube Shorts e Facebook.', + ], + 'facebook_everywhere' => [ + 'title' => 'Facebook ovunque', + 'description' => 'Pubblica un video sulla tua Pagina Facebook e TryPost lo ripubblica su Instagram, TikTok e YouTube Shorts.', + ], + ], + + 'create' => [ + 'title' => 'Nuovo repurpose', + 'description' => 'Scegli l\'account che TryPost deve seguire. Le destinazioni si scelgono nella schermata successiva.', + 'source_label' => 'Account di origine', + 'source_placeholder' => 'Seleziona un account', + 'no_accounts' => 'Collega prima un account Instagram o Facebook. Solo questi possono essere origine, perché sono le uniche reti che permettono di scaricare il video.', + 'submit' => 'Crea', + ], + + 'show' => [ + 'title' => 'Repurpose', + 'description' => 'I video pubblicati su questo account fuori da TryPost vengono replicati sulle destinazioni qui sotto.', + ], + + 'tabs' => [ + 'configuration' => 'Configurazione', + 'activity' => 'Attività', + ], + + 'destinations' => [ + 'title' => 'Destinazioni', + 'description' => 'Ogni nuovo video dell\'origine viene pubblicato su tutti gli account selezionati qui.', + 'hint' => 'La didascalia viene adattata per rete solo quando supera il limite di quella rete.', + 'none_available' => 'Nessun altro account è collegato in questo workspace.', + ], + + 'status_card' => [ + 'title' => 'Stato', + 'activate' => 'Attiva', + 'pause' => 'Metti in pausa', + 'resume' => 'Riprendi', + 'disable' => 'Disattiva', + 'watermark' => 'In ascolto da', + 'last_polled' => 'Ultimo controllo', + 'draft_hint' => 'Scegli almeno una destinazione, poi attiva. Vengono replicati solo i video pubblicati dopo l\'attivazione.', + 'active_hint' => 'TryPost controlla questo account con regolarità e replica ogni nuovo video.', + 'paused_hint' => 'I controlli sono sospesi. Riprendendo si riparte da dove si era fermato e non si perde nulla.', + 'disabled_hint' => 'Disattivato. Riattivandolo si riparte da zero: ciò che hai pubblicato mentre era spento resta fuori.', + ], + + 'items' => [ + 'source' => 'Originale', + 'published_at' => 'Pubblicato', + 'status' => 'Stato', + 'detail' => 'Dettaglio', + 'posts' => 'Replicato su', + 'view_original' => 'Vedi originale', + 'open_post' => 'Apri post', + 'statuses' => [ + 'pending' => 'In coda', + 'processing' => 'In elaborazione', + 'published' => 'Replicato', + 'skipped' => 'Ignorato', + 'failed' => 'Non riuscito', + ], + 'reasons' => [ + 'published_via_trypost' => 'Già pubblicato tramite TryPost', + 'not_video' => 'Non è un video', + 'media_url_missing' => 'La rete non ha fornito un file scaricabile, di solito per audio protetto da copyright', + 'download_failed' => 'Non è stato possibile scaricare il video', + 'post_creation_failed' => 'Nessuna destinazione disponibile', + ], + ], + + 'danger' => [ + 'title' => 'Elimina questo repurpose', + 'description' => 'I controlli si fermano subito. I post già creati restano nel tuo calendario.', + 'delete' => 'Elimina repurpose', + ], + + 'errors' => [ + 'source_already_used' => 'Questo account alimenta già un altro repurpose. Modifica quello.', + 'destinations_required' => 'Scegli almeno una destinazione prima di attivare.', + ], +]; diff --git a/lang/it/sidebar.php b/lang/it/sidebar.php index 55c1e47aa..ce611fe30 100644 --- a/lang/it/sidebar.php +++ b/lang/it/sidebar.php @@ -26,6 +26,7 @@ 'others' => 'Altro', ], 'analytics' => 'Statistiche', + 'repurposes' => 'Repurpose', 'onboarding' => 'Primi passi', 'onboarding_hint' => 'Completa la configurazione', 'posts' => [ diff --git a/lang/ja/repurposes.php b/lang/ja/repurposes.php new file mode 100644 index 000000000..517cfa1ee --- /dev/null +++ b/lang/ja/repurposes.php @@ -0,0 +1,116 @@ + 'Repurpose', + 'description' => 'TryPost の外で投稿した動画を、他のネットワークへ自動で再投稿します。', + 'new' => '新しい Repurpose', + + 'empty' => [ + 'title' => 'Repurpose はまだ設定されていません', + 'description' => '下から出発点を選んでください。TryPost が選んだアカウントを見張り、新しい動画をチェックしたネットワークへ再投稿します。', + ], + + 'table' => [ + 'source' => 'ソース', + 'destinations' => '配信先', + 'status' => 'ステータス', + 'published' => '再投稿済み', + 'last_polled' => '最終チェック', + ], + + 'status' => [ + 'draft' => '下書き', + 'active' => '有効', + 'paused' => '一時停止', + 'disabled' => '無効', + ], + + 'templates' => [ + 'use' => 'このテンプレートを使う', + 'instagram_everywhere' => [ + 'title' => 'Instagram をどこへでも', + 'description' => 'Instagram にリールを投稿すると、TryPost が TikTok・YouTube ショート・Facebook へ再投稿します。', + ], + 'facebook_everywhere' => [ + 'title' => 'Facebook をどこへでも', + 'description' => 'Facebook ページに動画を投稿すると、TryPost が Instagram・TikTok・YouTube ショートへ再投稿します。', + ], + ], + + 'create' => [ + 'title' => '新しい Repurpose', + 'description' => 'TryPost が見張るアカウントを選んでください。配信先は次の画面で選びます。', + 'source_label' => 'ソースアカウント', + 'source_placeholder' => 'アカウントを選択', + 'no_accounts' => '先に Instagram か Facebook のアカウントを接続してください。動画をダウンロードできるのはこの 2 つだけなので、ソースになれるのもこの 2 つだけです。', + 'submit' => '作成', + ], + + 'show' => [ + 'title' => 'Repurpose', + 'description' => 'このアカウントで TryPost 以外から投稿された動画が、下の配信先へ再投稿されます。', + ], + + 'tabs' => [ + 'configuration' => '設定', + 'activity' => 'アクティビティ', + ], + + 'destinations' => [ + 'title' => '配信先', + 'description' => 'ソースの新しい動画は、ここで選んだすべてのアカウントに投稿されます。', + 'hint' => 'キャプションは、そのネットワークの上限を超えたときだけ調整されます。', + 'none_available' => 'このワークスペースにはまだ他のアカウントが接続されていません。', + ], + + 'status_card' => [ + 'title' => 'ステータス', + 'activate' => '有効にする', + 'pause' => '一時停止', + 'resume' => '再開', + 'disable' => '無効にする', + 'watermark' => '監視開始', + 'last_polled' => '最終チェック', + 'draft_hint' => '配信先を 1 つ以上選んでから有効にしてください。再投稿されるのは有効化より後の動画だけです。', + 'active_hint' => 'TryPost はこのアカウントを定期的に確認し、新しい動画をすべて再投稿します。', + 'paused_hint' => 'チェックを停止中です。再開すると止まった時点から続き、その間の投稿も失われません。', + 'disabled_hint' => 'オフです。もう一度有効にすると最初からになり、オフの間に投稿したものは対象外のままです。', + ], + + 'items' => [ + 'source' => 'オリジナル', + 'published_at' => '投稿日', + 'status' => 'ステータス', + 'detail' => '詳細', + 'posts' => '再投稿先', + 'view_original' => 'オリジナルを見る', + 'open_post' => '投稿を開く', + 'statuses' => [ + 'pending' => '待機中', + 'processing' => '処理中', + 'published' => '再投稿済み', + 'skipped' => 'スキップ', + 'failed' => '失敗', + ], + 'reasons' => [ + 'published_via_trypost' => 'すでに TryPost から投稿済み', + 'not_video' => '動画ではありません', + 'media_url_missing' => 'ネットワークがダウンロード可能なファイルを返しませんでした。多くは著作権付き音源が原因です', + 'download_failed' => '動画をダウンロードできませんでした', + 'post_creation_failed' => '利用できる配信先がありません', + ], + ], + + 'danger' => [ + 'title' => 'この Repurpose を削除', + 'description' => 'チェックはすぐに止まります。作成済みの投稿はカレンダーに残ります。', + 'delete' => 'Repurpose を削除', + ], + + 'errors' => [ + 'source_already_used' => 'このアカウントはすでに別の Repurpose で使われています。そちらを編集してください。', + 'destinations_required' => '有効にする前に配信先を 1 つ以上選んでください。', + ], +]; diff --git a/lang/ja/sidebar.php b/lang/ja/sidebar.php index a333f21a3..2613a93e5 100644 --- a/lang/ja/sidebar.php +++ b/lang/ja/sidebar.php @@ -26,6 +26,7 @@ 'others' => 'その他', ], 'analytics' => 'アナリティクス', + 'repurposes' => 'Repurpose', 'onboarding' => 'はじめに', 'onboarding_hint' => 'セットアップを完了', 'posts' => [ diff --git a/lang/ko/repurposes.php b/lang/ko/repurposes.php new file mode 100644 index 000000000..5d1bce646 --- /dev/null +++ b/lang/ko/repurposes.php @@ -0,0 +1,116 @@ + 'Repurpose', + 'description' => 'TryPost 외부에서 올린 영상을 다른 네트워크에 자동으로 다시 게시합니다.', + 'new' => '새 Repurpose', + + 'empty' => [ + 'title' => '아직 설정된 Repurpose가 없습니다', + 'description' => '아래에서 시작점을 고르세요. TryPost가 선택한 계정을 지켜보다가 새 영상을 선택한 네트워크에 다시 게시합니다.', + ], + + 'table' => [ + 'source' => '소스', + 'destinations' => '대상', + 'status' => '상태', + 'published' => '복제됨', + 'last_polled' => '마지막 확인', + ], + + 'status' => [ + 'draft' => '초안', + 'active' => '활성', + 'paused' => '일시중지', + 'disabled' => '비활성', + ], + + 'templates' => [ + 'use' => '이 템플릿 사용', + 'instagram_everywhere' => [ + 'title' => '어디서나 Instagram', + 'description' => 'Instagram에 릴스를 올리면 TryPost가 TikTok, YouTube Shorts, Facebook에 다시 게시합니다.', + ], + 'facebook_everywhere' => [ + 'title' => '어디서나 Facebook', + 'description' => 'Facebook 페이지에 영상을 올리면 TryPost가 Instagram, TikTok, YouTube Shorts에 다시 게시합니다.', + ], + ], + + 'create' => [ + 'title' => '새 Repurpose', + 'description' => 'TryPost가 지켜볼 계정을 고르세요. 대상은 다음 화면에서 선택합니다.', + 'source_label' => '소스 계정', + 'source_placeholder' => '계정 선택', + 'no_accounts' => '먼저 Instagram이나 Facebook 계정을 연결하세요. 영상을 내려받을 수 있는 네트워크는 이 둘뿐이라 소스도 이 둘만 가능합니다.', + 'submit' => '만들기', + ], + + 'show' => [ + 'title' => 'Repurpose', + 'description' => '이 계정에서 TryPost 외부로 게시된 영상이 아래 대상으로 복제됩니다.', + ], + + 'tabs' => [ + 'configuration' => '설정', + 'activity' => '활동', + ], + + 'destinations' => [ + 'title' => '대상', + 'description' => '소스의 새 영상은 여기서 선택한 모든 계정에 게시됩니다.', + 'hint' => '캡션은 해당 네트워크의 한도를 넘을 때만 조정됩니다.', + 'none_available' => '이 워크스페이스에 연결된 다른 계정이 아직 없습니다.', + ], + + 'status_card' => [ + 'title' => '상태', + 'activate' => '활성화', + 'pause' => '일시중지', + 'resume' => '재개', + 'disable' => '비활성화', + 'watermark' => '확인 시작', + 'last_polled' => '마지막 확인', + 'draft_hint' => '대상을 하나 이상 고른 뒤 활성화하세요. 활성화 이후에 올린 영상만 복제됩니다.', + 'active_hint' => 'TryPost가 이 계정을 주기적으로 확인하고 새 영상을 모두 복제합니다.', + 'paused_hint' => '확인이 멈춰 있습니다. 재개하면 멈춘 지점부터 이어지며 그동안 올린 것도 잃지 않습니다.', + 'disabled_hint' => '꺼져 있습니다. 다시 활성화하면 처음부터 시작하며, 꺼져 있는 동안 올린 것은 제외됩니다.', + ], + + 'items' => [ + 'source' => '원본', + 'published_at' => '게시일', + 'status' => '상태', + 'detail' => '상세', + 'posts' => '복제 대상', + 'view_original' => '원본 보기', + 'open_post' => '게시물 열기', + 'statuses' => [ + 'pending' => '대기 중', + 'processing' => '처리 중', + 'published' => '복제됨', + 'skipped' => '건너뜀', + 'failed' => '실패', + ], + 'reasons' => [ + 'published_via_trypost' => '이미 TryPost로 게시됨', + 'not_video' => '영상이 아닙니다', + 'media_url_missing' => '네트워크가 내려받을 수 있는 파일을 제공하지 않았습니다. 보통 저작권 오디오 때문입니다', + 'download_failed' => '영상을 내려받지 못했습니다', + 'post_creation_failed' => '사용할 수 있는 대상이 없습니다', + ], + ], + + 'danger' => [ + 'title' => '이 Repurpose 삭제', + 'description' => '확인이 즉시 중단됩니다. 이미 만들어진 게시물은 캘린더에 남습니다.', + 'delete' => 'Repurpose 삭제', + ], + + 'errors' => [ + 'source_already_used' => '이 계정은 이미 다른 Repurpose에 쓰이고 있습니다. 그것을 수정하세요.', + 'destinations_required' => '활성화하기 전에 대상을 하나 이상 고르세요.', + ], +]; diff --git a/lang/ko/sidebar.php b/lang/ko/sidebar.php index 2fcb2cbc3..89f83b9ed 100644 --- a/lang/ko/sidebar.php +++ b/lang/ko/sidebar.php @@ -26,6 +26,7 @@ 'others' => '기타', ], 'analytics' => '분석', + 'repurposes' => 'Repurpose', 'onboarding' => '시작하기', 'onboarding_hint' => '설정 마치기', 'posts' => [ diff --git a/lang/nl/repurposes.php b/lang/nl/repurposes.php new file mode 100644 index 000000000..95ce59285 --- /dev/null +++ b/lang/nl/repurposes.php @@ -0,0 +1,116 @@ + 'Repurpose', + 'description' => 'Publiceer video\'s die je buiten TryPost post automatisch opnieuw op je andere netwerken.', + 'new' => 'Nieuwe repurpose', + + 'empty' => [ + 'title' => 'Nog geen repurpose ingesteld', + 'description' => 'Kies hieronder een startpunt. TryPost volgt het gekozen account en plaatst elke nieuwe video opnieuw op de netwerken die je aanvinkt.', + ], + + 'table' => [ + 'source' => 'Bron', + 'destinations' => 'Bestemmingen', + 'status' => 'Status', + 'published' => 'Gerepliceerd', + 'last_polled' => 'Laatst gecontroleerd', + ], + + 'status' => [ + 'draft' => 'Concept', + 'active' => 'Actief', + 'paused' => 'Gepauzeerd', + 'disabled' => 'Uitgeschakeld', + ], + + 'templates' => [ + 'use' => 'Dit sjabloon gebruiken', + 'instagram_everywhere' => [ + 'title' => 'Instagram overal', + 'description' => 'Plaats een Reel op Instagram en TryPost publiceert die opnieuw op TikTok, YouTube Shorts en Facebook.', + ], + 'facebook_everywhere' => [ + 'title' => 'Facebook overal', + 'description' => 'Plaats een video op je Facebook-pagina en TryPost publiceert die opnieuw op Instagram, TikTok en YouTube Shorts.', + ], + ], + + 'create' => [ + 'title' => 'Nieuwe repurpose', + 'description' => 'Kies het account dat TryPost moet volgen. De bestemmingen kies je in het volgende scherm.', + 'source_label' => 'Bronaccount', + 'source_placeholder' => 'Selecteer een account', + 'no_accounts' => 'Koppel eerst een Instagram- of Facebook-account. Alleen die kunnen bron zijn, want alleen zij laten ons de video downloaden.', + 'submit' => 'Aanmaken', + ], + + 'show' => [ + 'title' => 'Repurpose', + 'description' => 'Video\'s die buiten TryPost op dit account verschijnen, worden gerepliceerd naar de bestemmingen hieronder.', + ], + + 'tabs' => [ + 'configuration' => 'Configuratie', + 'activity' => 'Activiteit', + ], + + 'destinations' => [ + 'title' => 'Bestemmingen', + 'description' => 'Elke nieuwe video van de bron wordt geplaatst op elk account dat je hier selecteert.', + 'hint' => 'Het bijschrift wordt alleen per netwerk aangepast als het de limiet van dat netwerk overschrijdt.', + 'none_available' => 'Er is nog geen ander account gekoppeld in deze workspace.', + ], + + 'status_card' => [ + 'title' => 'Status', + 'activate' => 'Activeren', + 'pause' => 'Pauzeren', + 'resume' => 'Hervatten', + 'disable' => 'Uitschakelen', + 'watermark' => 'Gevolgd sinds', + 'last_polled' => 'Laatst gecontroleerd', + 'draft_hint' => 'Kies minstens één bestemming en activeer daarna. Alleen video\'s van na de activering worden gerepliceerd.', + 'active_hint' => 'TryPost controleert dit account regelmatig en repliceert elke nieuwe video.', + 'paused_hint' => 'De controles liggen stil. Bij hervatten gaat het verder waar het stopte, er gaat niets verloren.', + 'disabled_hint' => 'Uitgeschakeld. Opnieuw activeren begint schoon: wat je plaatste terwijl het uit stond, blijft buiten beschouwing.', + ], + + 'items' => [ + 'source' => 'Origineel', + 'published_at' => 'Geplaatst', + 'status' => 'Status', + 'detail' => 'Detail', + 'posts' => 'Gerepliceerd naar', + 'view_original' => 'Origineel bekijken', + 'open_post' => 'Post openen', + 'statuses' => [ + 'pending' => 'In wachtrij', + 'processing' => 'Bezig', + 'published' => 'Gerepliceerd', + 'skipped' => 'Overgeslagen', + 'failed' => 'Mislukt', + ], + 'reasons' => [ + 'published_via_trypost' => 'Al gepubliceerd via TryPost', + 'not_video' => 'Geen video', + 'media_url_missing' => 'Het netwerk deelde geen downloadbaar bestand, meestal door auteursrechtelijk beschermde audio', + 'download_failed' => 'De video kon niet worden gedownload', + 'post_creation_failed' => 'Geen bestemming beschikbaar', + ], + ], + + 'danger' => [ + 'title' => 'Deze repurpose verwijderen', + 'description' => 'De controles stoppen onmiddellijk. Al gemaakte posts blijven in je kalender staan.', + 'delete' => 'Repurpose verwijderen', + ], + + 'errors' => [ + 'source_already_used' => 'Dit account voedt al een andere repurpose. Bewerk die in plaats daarvan.', + 'destinations_required' => 'Kies minstens één bestemming voordat je activeert.', + ], +]; diff --git a/lang/nl/sidebar.php b/lang/nl/sidebar.php index adce46db0..5a59e747a 100644 --- a/lang/nl/sidebar.php +++ b/lang/nl/sidebar.php @@ -26,6 +26,7 @@ 'others' => 'Overige', ], 'analytics' => 'Statistieken', + 'repurposes' => 'Repurpose', 'onboarding' => 'Aan de slag', 'onboarding_hint' => 'Setup afronden', 'posts' => [ diff --git a/lang/pl/repurposes.php b/lang/pl/repurposes.php new file mode 100644 index 000000000..cdf4a290f --- /dev/null +++ b/lang/pl/repurposes.php @@ -0,0 +1,116 @@ + 'Repurpose', + 'description' => 'Automatycznie publikuj w pozostałych sieciach filmy, które wrzucasz poza TryPost.', + 'new' => 'Nowy repurpose', + + 'empty' => [ + 'title' => 'Nie skonfigurowano jeszcze repurpose', + 'description' => 'Wybierz punkt startowy poniżej. TryPost obserwuje wybrane konto i publikuje każdy nowy film w sieciach, które zaznaczysz.', + ], + + 'table' => [ + 'source' => 'Źródło', + 'destinations' => 'Cele', + 'status' => 'Status', + 'published' => 'Zreplikowane', + 'last_polled' => 'Ostatnie sprawdzenie', + ], + + 'status' => [ + 'draft' => 'Szkic', + 'active' => 'Aktywny', + 'paused' => 'Wstrzymany', + 'disabled' => 'Wyłączony', + ], + + 'templates' => [ + 'use' => 'Użyj tego szablonu', + 'instagram_everywhere' => [ + 'title' => 'Instagram wszędzie', + 'description' => 'Opublikuj Reel na Instagramie, a TryPost powtórzy go na TikToku, YouTube Shorts i Facebooku.', + ], + 'facebook_everywhere' => [ + 'title' => 'Facebook wszędzie', + 'description' => 'Opublikuj film na swojej stronie na Facebooku, a TryPost powtórzy go na Instagramie, TikToku i YouTube Shorts.', + ], + ], + + 'create' => [ + 'title' => 'Nowy repurpose', + 'description' => 'Wybierz konto, które TryPost ma obserwować. Cele wybierzesz na następnym ekranie.', + 'source_label' => 'Konto źródłowe', + 'source_placeholder' => 'Wybierz konto', + 'no_accounts' => 'Najpierw połącz konto Instagrama lub Facebooka. Tylko one mogą być źródłem, bo tylko te sieci pozwalają pobrać film.', + 'submit' => 'Utwórz', + ], + + 'show' => [ + 'title' => 'Repurpose', + 'description' => 'Filmy opublikowane na tym koncie poza TryPost są replikowane do celów poniżej.', + ], + + 'tabs' => [ + 'configuration' => 'Konfiguracja', + 'activity' => 'Aktywność', + ], + + 'destinations' => [ + 'title' => 'Cele', + 'description' => 'Każdy nowy film ze źródła trafia na wszystkie zaznaczone tu konta.', + 'hint' => 'Opis jest dostosowywany do sieci tylko wtedy, gdy przekracza jej limit.', + 'none_available' => 'W tym obszarze roboczym nie ma jeszcze innego połączonego konta.', + ], + + 'status_card' => [ + 'title' => 'Status', + 'activate' => 'Aktywuj', + 'pause' => 'Wstrzymaj', + 'resume' => 'Wznów', + 'disable' => 'Wyłącz', + 'watermark' => 'Obserwuje od', + 'last_polled' => 'Ostatnie sprawdzenie', + 'draft_hint' => 'Wybierz co najmniej jeden cel i aktywuj. Replikowane są tylko filmy opublikowane po aktywacji.', + 'active_hint' => 'TryPost regularnie sprawdza to konto i replikuje każdy nowy film.', + 'paused_hint' => 'Sprawdzanie jest wstrzymane. Wznowienie kontynuuje od miejsca zatrzymania i nic nie ginie.', + 'disabled_hint' => 'Wyłączone. Ponowna aktywacja zaczyna od zera: to, co opublikowałeś w międzyczasie, zostaje pominięte.', + ], + + 'items' => [ + 'source' => 'Oryginał', + 'published_at' => 'Opublikowano', + 'status' => 'Status', + 'detail' => 'Szczegół', + 'posts' => 'Zreplikowano do', + 'view_original' => 'Zobacz oryginał', + 'open_post' => 'Otwórz post', + 'statuses' => [ + 'pending' => 'W kolejce', + 'processing' => 'Przetwarzanie', + 'published' => 'Zreplikowano', + 'skipped' => 'Pominięto', + 'failed' => 'Niepowodzenie', + ], + 'reasons' => [ + 'published_via_trypost' => 'Już opublikowane przez TryPost', + 'not_video' => 'To nie jest film', + 'media_url_missing' => 'Sieć nie udostępniła pliku do pobrania, zwykle z powodu dźwięku chronionego prawem autorskim', + 'download_failed' => 'Nie udało się pobrać filmu', + 'post_creation_failed' => 'Brak dostępnego celu', + ], + ], + + 'danger' => [ + 'title' => 'Usuń ten repurpose', + 'description' => 'Sprawdzanie zatrzyma się natychmiast. Utworzone już posty pozostaną w kalendarzu.', + 'delete' => 'Usuń repurpose', + ], + + 'errors' => [ + 'source_already_used' => 'To konto zasila już inny repurpose. Edytuj tamten.', + 'destinations_required' => 'Wybierz co najmniej jeden cel przed aktywacją.', + ], +]; diff --git a/lang/pl/sidebar.php b/lang/pl/sidebar.php index 213feb272..3d682ef8a 100644 --- a/lang/pl/sidebar.php +++ b/lang/pl/sidebar.php @@ -26,6 +26,7 @@ 'others' => 'Inne', ], 'analytics' => 'Analityka', + 'repurposes' => 'Repurpose', 'onboarding' => 'Pierwsze kroki', 'onboarding_hint' => 'Dokończ konfigurację', 'posts' => [ diff --git a/lang/pt-BR/repurposes.php b/lang/pt-BR/repurposes.php new file mode 100644 index 000000000..34e4b2ed5 --- /dev/null +++ b/lang/pt-BR/repurposes.php @@ -0,0 +1,116 @@ + 'Repurpose', + 'description' => 'Replique automaticamente nas suas outras redes os vídeos que você publica fora do TryPost.', + 'new' => 'Novo repurpose', + + 'empty' => [ + 'title' => 'Nenhum repurpose configurado', + 'description' => 'Escolha um ponto de partida abaixo. O TryPost acompanha a conta que você escolher e republica cada novo vídeo nas redes que você marcar.', + ], + + 'table' => [ + 'source' => 'Origem', + 'destinations' => 'Destinos', + 'status' => 'Status', + 'published' => 'Replicados', + 'last_polled' => 'Última verificação', + ], + + 'status' => [ + 'draft' => 'Rascunho', + 'active' => 'Ativo', + 'paused' => 'Pausado', + 'disabled' => 'Desativado', + ], + + 'templates' => [ + 'use' => 'Usar este template', + 'instagram_everywhere' => [ + 'title' => 'Instagram em todo lugar', + 'description' => 'Poste um Reel no Instagram e o TryPost republica no TikTok, no YouTube Shorts e no Facebook.', + ], + 'facebook_everywhere' => [ + 'title' => 'Facebook em todo lugar', + 'description' => 'Poste um vídeo na sua Página do Facebook e o TryPost republica no Instagram, no TikTok e no YouTube Shorts.', + ], + ], + + 'create' => [ + 'title' => 'Novo repurpose', + 'description' => 'Escolha a conta que o TryPost deve acompanhar. Os destinos você escolhe na próxima tela.', + 'source_label' => 'Conta de origem', + 'source_placeholder' => 'Selecione uma conta', + 'no_accounts' => 'Conecte antes uma conta do Instagram ou do Facebook. Só elas podem ser origem, porque são as únicas redes que permitem baixar o vídeo.', + 'submit' => 'Criar', + ], + + 'show' => [ + 'title' => 'Repurpose', + 'description' => 'Os vídeos publicados nesta conta fora do TryPost são replicados nos destinos abaixo.', + ], + + 'tabs' => [ + 'configuration' => 'Configuração', + 'activity' => 'Atividade', + ], + + 'destinations' => [ + 'title' => 'Destinos', + 'description' => 'Cada novo vídeo da origem é publicado em todas as contas selecionadas aqui.', + 'hint' => 'A legenda só é adaptada por rede quando ultrapassa o limite daquela rede.', + 'none_available' => 'Nenhuma outra conta está conectada neste workspace.', + ], + + 'status_card' => [ + 'title' => 'Status', + 'activate' => 'Ativar', + 'pause' => 'Pausar', + 'resume' => 'Retomar', + 'disable' => 'Desativar', + 'watermark' => 'Acompanhando desde', + 'last_polled' => 'Última verificação', + 'draft_hint' => 'Escolha ao menos um destino e ative. Só vídeos publicados depois da ativação são replicados.', + 'active_hint' => 'O TryPost verifica esta conta com frequência e replica cada novo vídeo.', + 'paused_hint' => 'As verificações estão suspensas. Ao retomar, continua de onde parou e nada publicado nesse meio-tempo se perde.', + 'disabled_hint' => 'Desligado. Ao ativar de novo, começa do zero: o que você publicou enquanto estava desligado continua de fora.', + ], + + 'items' => [ + 'source' => 'Original', + 'published_at' => 'Publicado', + 'status' => 'Status', + 'detail' => 'Detalhe', + 'posts' => 'Replicado em', + 'view_original' => 'Ver original', + 'open_post' => 'Abrir post', + 'statuses' => [ + 'pending' => 'Na fila', + 'processing' => 'Processando', + 'published' => 'Replicado', + 'skipped' => 'Ignorado', + 'failed' => 'Falhou', + ], + 'reasons' => [ + 'published_via_trypost' => 'Já publicado pelo TryPost', + 'not_video' => 'Não é um vídeo', + 'media_url_missing' => 'A rede não disponibilizou o arquivo para download, normalmente por causa de áudio com direitos autorais', + 'download_failed' => 'Não foi possível baixar o vídeo', + 'post_creation_failed' => 'Nenhum destino disponível', + ], + ], + + 'danger' => [ + 'title' => 'Excluir este repurpose', + 'description' => 'As verificações param na hora. Os posts já criados continuam no seu calendário.', + 'delete' => 'Excluir repurpose', + ], + + 'errors' => [ + 'source_already_used' => 'Esta conta já alimenta outro repurpose. Edite aquele.', + 'destinations_required' => 'Escolha ao menos um destino antes de ativar.', + ], +]; diff --git a/lang/pt-BR/sidebar.php b/lang/pt-BR/sidebar.php index b82d527ef..c25178b76 100644 --- a/lang/pt-BR/sidebar.php +++ b/lang/pt-BR/sidebar.php @@ -26,6 +26,7 @@ 'others' => 'Outros', ], 'analytics' => 'Analytics', + 'repurposes' => 'Repurpose', 'onboarding' => 'Primeiros passos', 'onboarding_hint' => 'Complete a configuração', 'posts' => [ diff --git a/lang/ru/repurposes.php b/lang/ru/repurposes.php new file mode 100644 index 000000000..e225f7ca9 --- /dev/null +++ b/lang/ru/repurposes.php @@ -0,0 +1,116 @@ + 'Repurpose', + 'description' => 'Автоматически публикуйте в других сетях видео, которые вы выкладываете вне TryPost.', + 'new' => 'Новый repurpose', + + 'empty' => [ + 'title' => 'Repurpose ещё не настроен', + 'description' => 'Выберите отправную точку ниже. TryPost следит за выбранным аккаунтом и заново публикует каждое новое видео в отмеченных сетях.', + ], + + 'table' => [ + 'source' => 'Источник', + 'destinations' => 'Назначения', + 'status' => 'Статус', + 'published' => 'Скопировано', + 'last_polled' => 'Последняя проверка', + ], + + 'status' => [ + 'draft' => 'Черновик', + 'active' => 'Активен', + 'paused' => 'На паузе', + 'disabled' => 'Отключён', + ], + + 'templates' => [ + 'use' => 'Использовать шаблон', + 'instagram_everywhere' => [ + 'title' => 'Instagram везде', + 'description' => 'Опубликуйте Reel в Instagram, и TryPost повторит его в TikTok, YouTube Shorts и Facebook.', + ], + 'facebook_everywhere' => [ + 'title' => 'Facebook везде', + 'description' => 'Опубликуйте видео на своей странице Facebook, и TryPost повторит его в Instagram, TikTok и YouTube Shorts.', + ], + ], + + 'create' => [ + 'title' => 'Новый repurpose', + 'description' => 'Выберите аккаунт, за которым будет следить TryPost. Назначения выбираются на следующем экране.', + 'source_label' => 'Аккаунт-источник', + 'source_placeholder' => 'Выберите аккаунт', + 'no_accounts' => 'Сначала подключите аккаунт Instagram или Facebook. Только они могут быть источником, потому что только эти сети позволяют скачать видео.', + 'submit' => 'Создать', + ], + + 'show' => [ + 'title' => 'Repurpose', + 'description' => 'Видео, опубликованные на этом аккаунте вне TryPost, копируются в назначения ниже.', + ], + + 'tabs' => [ + 'configuration' => 'Настройка', + 'activity' => 'Активность', + ], + + 'destinations' => [ + 'title' => 'Назначения', + 'description' => 'Каждое новое видео из источника публикуется во всех выбранных здесь аккаунтах.', + 'hint' => 'Подпись адаптируется под сеть только тогда, когда превышает её лимит.', + 'none_available' => 'В этом рабочем пространстве пока нет других подключённых аккаунтов.', + ], + + 'status_card' => [ + 'title' => 'Статус', + 'activate' => 'Активировать', + 'pause' => 'Пауза', + 'resume' => 'Возобновить', + 'disable' => 'Отключить', + 'watermark' => 'Отслеживается с', + 'last_polled' => 'Последняя проверка', + 'draft_hint' => 'Выберите хотя бы одно назначение и активируйте. Копируются только видео, опубликованные после активации.', + 'active_hint' => 'TryPost регулярно проверяет этот аккаунт и копирует каждое новое видео.', + 'paused_hint' => 'Проверки приостановлены. Возобновление продолжит с места остановки, ничего не потеряется.', + 'disabled_hint' => 'Выключено. Повторная активация начнёт с нуля: опубликованное в это время останется в стороне.', + ], + + 'items' => [ + 'source' => 'Оригинал', + 'published_at' => 'Опубликовано', + 'status' => 'Статус', + 'detail' => 'Детали', + 'posts' => 'Скопировано в', + 'view_original' => 'Открыть оригинал', + 'open_post' => 'Открыть пост', + 'statuses' => [ + 'pending' => 'В очереди', + 'processing' => 'Обработка', + 'published' => 'Скопировано', + 'skipped' => 'Пропущено', + 'failed' => 'Ошибка', + ], + 'reasons' => [ + 'published_via_trypost' => 'Уже опубликовано через TryPost', + 'not_video' => 'Это не видео', + 'media_url_missing' => 'Сеть не предоставила файл для скачивания, обычно из-за защищённого авторским правом аудио', + 'download_failed' => 'Не удалось скачать видео', + 'post_creation_failed' => 'Нет доступного назначения', + ], + ], + + 'danger' => [ + 'title' => 'Удалить этот repurpose', + 'description' => 'Проверки прекратятся сразу. Уже созданные посты останутся в календаре.', + 'delete' => 'Удалить repurpose', + ], + + 'errors' => [ + 'source_already_used' => 'Этот аккаунт уже используется в другом repurpose. Отредактируйте его.', + 'destinations_required' => 'Выберите хотя бы одно назначение перед активацией.', + ], +]; diff --git a/lang/ru/sidebar.php b/lang/ru/sidebar.php index 358d1a302..45b5ce970 100644 --- a/lang/ru/sidebar.php +++ b/lang/ru/sidebar.php @@ -26,6 +26,7 @@ 'others' => 'Прочее', ], 'analytics' => 'Аналитика', + 'repurposes' => 'Repurpose', 'onboarding' => 'Начало работы', 'onboarding_hint' => 'Завершите настройку', 'posts' => [ diff --git a/lang/tr/repurposes.php b/lang/tr/repurposes.php new file mode 100644 index 000000000..f3ab85a3e --- /dev/null +++ b/lang/tr/repurposes.php @@ -0,0 +1,116 @@ + 'Repurpose', + 'description' => 'TryPost dışında paylaştığın videoları diğer ağlarında otomatik olarak yeniden yayınla.', + 'new' => 'Yeni repurpose', + + 'empty' => [ + 'title' => 'Henüz repurpose kurulmadı', + 'description' => 'Aşağıdan bir başlangıç noktası seç. TryPost seçtiğin hesabı izler ve her yeni videoyu işaretlediğin ağlarda yeniden yayınlar.', + ], + + 'table' => [ + 'source' => 'Kaynak', + 'destinations' => 'Hedefler', + 'status' => 'Durum', + 'published' => 'Kopyalanan', + 'last_polled' => 'Son kontrol', + ], + + 'status' => [ + 'draft' => 'Taslak', + 'active' => 'Etkin', + 'paused' => 'Duraklatıldı', + 'disabled' => 'Devre dışı', + ], + + 'templates' => [ + 'use' => 'Bu şablonu kullan', + 'instagram_everywhere' => [ + 'title' => 'Her yerde Instagram', + 'description' => 'Instagram\'da bir Reel paylaş, TryPost onu TikTok, YouTube Shorts ve Facebook\'ta yeniden yayınlasın.', + ], + 'facebook_everywhere' => [ + 'title' => 'Her yerde Facebook', + 'description' => 'Facebook Sayfanda bir video paylaş, TryPost onu Instagram, TikTok ve YouTube Shorts\'ta yeniden yayınlasın.', + ], + ], + + 'create' => [ + 'title' => 'Yeni repurpose', + 'description' => 'TryPost\'un izlemesi gereken hesabı seç. Hedefleri bir sonraki ekranda seçeceksin.', + 'source_label' => 'Kaynak hesap', + 'source_placeholder' => 'Bir hesap seç', + 'no_accounts' => 'Önce bir Instagram veya Facebook hesabı bağla. Yalnızca bunlar kaynak olabilir, çünkü videoyu indirmemize izin veren tek ağlar bunlar.', + 'submit' => 'Oluştur', + ], + + 'show' => [ + 'title' => 'Repurpose', + 'description' => 'Bu hesapta TryPost dışında yayınlanan videolar aşağıdaki hedeflere kopyalanır.', + ], + + 'tabs' => [ + 'configuration' => 'Yapılandırma', + 'activity' => 'Etkinlik', + ], + + 'destinations' => [ + 'title' => 'Hedefler', + 'description' => 'Kaynaktaki her yeni video, burada seçtiğin tüm hesaplarda yayınlanır.', + 'hint' => 'Açıklama yalnızca o ağın sınırını aştığında ağa göre uyarlanır.', + 'none_available' => 'Bu çalışma alanında bağlı başka hesap yok.', + ], + + 'status_card' => [ + 'title' => 'Durum', + 'activate' => 'Etkinleştir', + 'pause' => 'Duraklat', + 'resume' => 'Sürdür', + 'disable' => 'Devre dışı bırak', + 'watermark' => 'İzleme başlangıcı', + 'last_polled' => 'Son kontrol', + 'draft_hint' => 'En az bir hedef seç ve etkinleştir. Yalnızca etkinleştirmeden sonra paylaşılan videolar kopyalanır.', + 'active_hint' => 'TryPost bu hesabı düzenli olarak kontrol eder ve her yeni videoyu kopyalar.', + 'paused_hint' => 'Kontroller beklemede. Sürdürdüğünde kaldığı yerden devam eder, bu arada paylaşılan hiçbir şey kaybolmaz.', + 'disabled_hint' => 'Kapalı. Yeniden etkinleştirmek sıfırdan başlar: kapalıyken paylaştıkların dışarıda kalır.', + ], + + 'items' => [ + 'source' => 'Orijinal', + 'published_at' => 'Paylaşıldı', + 'status' => 'Durum', + 'detail' => 'Ayrıntı', + 'posts' => 'Kopyalandığı yer', + 'view_original' => 'Orijinali gör', + 'open_post' => 'Gönderiyi aç', + 'statuses' => [ + 'pending' => 'Sırada', + 'processing' => 'İşleniyor', + 'published' => 'Kopyalandı', + 'skipped' => 'Atlandı', + 'failed' => 'Başarısız', + ], + 'reasons' => [ + 'published_via_trypost' => 'Zaten TryPost ile yayınlandı', + 'not_video' => 'Video değil', + 'media_url_missing' => 'Ağ indirilebilir bir dosya paylaşmadı, genellikle telif hakkı korumalı ses nedeniyle', + 'download_failed' => 'Video indirilemedi', + 'post_creation_failed' => 'Uygun hedef yok', + ], + ], + + 'danger' => [ + 'title' => 'Bu repurpose\'u sil', + 'description' => 'Kontroller hemen durur. Oluşturulmuş gönderiler takviminde kalır.', + 'delete' => 'Repurpose\'u sil', + ], + + 'errors' => [ + 'source_already_used' => 'Bu hesap zaten başka bir repurpose\'u besliyor. Onu düzenle.', + 'destinations_required' => 'Etkinleştirmeden önce en az bir hedef seç.', + ], +]; diff --git a/lang/tr/sidebar.php b/lang/tr/sidebar.php index 4142f3c21..6769e4eb7 100644 --- a/lang/tr/sidebar.php +++ b/lang/tr/sidebar.php @@ -26,6 +26,7 @@ 'others' => 'Diğerleri', ], 'analytics' => 'Analitik', + 'repurposes' => 'Repurpose', 'onboarding' => 'Başlarken', 'onboarding_hint' => 'Kurulumu bitir', 'posts' => [ diff --git a/lang/uk/repurposes.php b/lang/uk/repurposes.php new file mode 100644 index 000000000..7f349e4b7 --- /dev/null +++ b/lang/uk/repurposes.php @@ -0,0 +1,116 @@ + 'Repurpose', + 'description' => 'Автоматично публікуйте в інших мережах відео, які ви викладаєте поза TryPost.', + 'new' => 'Новий repurpose', + + 'empty' => [ + 'title' => 'Repurpose ще не налаштовано', + 'description' => 'Оберіть відправну точку нижче. TryPost стежить за обраним акаунтом і повторно публікує кожне нове відео в позначених мережах.', + ], + + 'table' => [ + 'source' => 'Джерело', + 'destinations' => 'Призначення', + 'status' => 'Статус', + 'published' => 'Скопійовано', + 'last_polled' => 'Остання перевірка', + ], + + 'status' => [ + 'draft' => 'Чернетка', + 'active' => 'Активний', + 'paused' => 'Призупинено', + 'disabled' => 'Вимкнено', + ], + + 'templates' => [ + 'use' => 'Використати шаблон', + 'instagram_everywhere' => [ + 'title' => 'Instagram усюди', + 'description' => 'Опублікуйте Reel в Instagram, і TryPost повторить його в TikTok, YouTube Shorts і Facebook.', + ], + 'facebook_everywhere' => [ + 'title' => 'Facebook усюди', + 'description' => 'Опублікуйте відео на своїй сторінці Facebook, і TryPost повторить його в Instagram, TikTok і YouTube Shorts.', + ], + ], + + 'create' => [ + 'title' => 'Новий repurpose', + 'description' => 'Оберіть акаунт, за яким стежитиме TryPost. Призначення обираються на наступному екрані.', + 'source_label' => 'Акаунт-джерело', + 'source_placeholder' => 'Оберіть акаунт', + 'no_accounts' => 'Спершу підключіть акаунт Instagram або Facebook. Лише вони можуть бути джерелом, бо тільки ці мережі дозволяють завантажити відео.', + 'submit' => 'Створити', + ], + + 'show' => [ + 'title' => 'Repurpose', + 'description' => 'Відео, опубліковані на цьому акаунті поза TryPost, копіюються в призначення нижче.', + ], + + 'tabs' => [ + 'configuration' => 'Налаштування', + 'activity' => 'Активність', + ], + + 'destinations' => [ + 'title' => 'Призначення', + 'description' => 'Кожне нове відео з джерела публікується в усіх обраних тут акаунтах.', + 'hint' => 'Підпис адаптується під мережу лише тоді, коли перевищує її ліміт.', + 'none_available' => 'У цьому робочому просторі поки немає інших підключених акаунтів.', + ], + + 'status_card' => [ + 'title' => 'Статус', + 'activate' => 'Активувати', + 'pause' => 'Призупинити', + 'resume' => 'Відновити', + 'disable' => 'Вимкнути', + 'watermark' => 'Відстежується з', + 'last_polled' => 'Остання перевірка', + 'draft_hint' => 'Оберіть щонайменше одне призначення та активуйте. Копіюються лише відео, опубліковані після активації.', + 'active_hint' => 'TryPost регулярно перевіряє цей акаунт і копіює кожне нове відео.', + 'paused_hint' => 'Перевірки призупинено. Відновлення продовжить з місця зупинки, нічого не втратиться.', + 'disabled_hint' => 'Вимкнено. Повторна активація почне з нуля: опубліковане за цей час залишиться осторонь.', + ], + + 'items' => [ + 'source' => 'Оригінал', + 'published_at' => 'Опубліковано', + 'status' => 'Статус', + 'detail' => 'Деталі', + 'posts' => 'Скопійовано в', + 'view_original' => 'Відкрити оригінал', + 'open_post' => 'Відкрити допис', + 'statuses' => [ + 'pending' => 'У черзі', + 'processing' => 'Обробка', + 'published' => 'Скопійовано', + 'skipped' => 'Пропущено', + 'failed' => 'Помилка', + ], + 'reasons' => [ + 'published_via_trypost' => 'Уже опубліковано через TryPost', + 'not_video' => 'Це не відео', + 'media_url_missing' => 'Мережа не надала файл для завантаження, зазвичай через захищене авторським правом аудіо', + 'download_failed' => 'Не вдалося завантажити відео', + 'post_creation_failed' => 'Немає доступного призначення', + ], + ], + + 'danger' => [ + 'title' => 'Видалити цей repurpose', + 'description' => 'Перевірки припиняться одразу. Уже створені дописи залишаться в календарі.', + 'delete' => 'Видалити repurpose', + ], + + 'errors' => [ + 'source_already_used' => 'Цей акаунт уже живить інший repurpose. Відредагуйте його.', + 'destinations_required' => 'Оберіть щонайменше одне призначення перед активацією.', + ], +]; diff --git a/lang/uk/sidebar.php b/lang/uk/sidebar.php index 6a91ddcd4..f7ded40b1 100644 --- a/lang/uk/sidebar.php +++ b/lang/uk/sidebar.php @@ -26,6 +26,7 @@ 'others' => 'Інше', ], 'analytics' => 'Аналітика', + 'repurposes' => 'Repurpose', 'onboarding' => 'Початок роботи', 'onboarding_hint' => 'Завершіть налаштування', 'posts' => [ diff --git a/lang/zh/repurposes.php b/lang/zh/repurposes.php new file mode 100644 index 000000000..3a3743fac --- /dev/null +++ b/lang/zh/repurposes.php @@ -0,0 +1,116 @@ + 'Repurpose', + 'description' => '把你在 TryPost 之外发布的视频,自动同步到其他平台。', + 'new' => '新建 Repurpose', + + 'empty' => [ + 'title' => '还没有设置 Repurpose', + 'description' => '在下面选一个起点。TryPost 会盯着你选的账号,把每条新视频转发到你勾选的平台。', + ], + + 'table' => [ + 'source' => '来源', + 'destinations' => '目标', + 'status' => '状态', + 'published' => '已同步', + 'last_polled' => '上次检查', + ], + + 'status' => [ + 'draft' => '草稿', + 'active' => '启用中', + 'paused' => '已暂停', + 'disabled' => '已停用', + ], + + 'templates' => [ + 'use' => '使用此模板', + 'instagram_everywhere' => [ + 'title' => 'Instagram 全平台', + 'description' => '在 Instagram 发一条 Reels,TryPost 就同步到 TikTok、YouTube Shorts 和 Facebook。', + ], + 'facebook_everywhere' => [ + 'title' => 'Facebook 全平台', + 'description' => '在 Facebook 主页发一条视频,TryPost 就同步到 Instagram、TikTok 和 YouTube Shorts。', + ], + ], + + 'create' => [ + 'title' => '新建 Repurpose', + 'description' => '选择 TryPost 要盯着的账号。目标平台在下一屏选择。', + 'source_label' => '来源账号', + 'source_placeholder' => '选择账号', + 'no_accounts' => '请先连接 Instagram 或 Facebook 账号。只有它们能作为来源,因为只有这两个平台允许我们下载视频。', + 'submit' => '创建', + ], + + 'show' => [ + 'title' => 'Repurpose', + 'description' => '这个账号在 TryPost 之外发布的视频,会同步到下面的目标。', + ], + + 'tabs' => [ + 'configuration' => '配置', + 'activity' => '动态', + ], + + 'destinations' => [ + 'title' => '目标', + 'description' => '来源的每条新视频都会发布到你在这里选中的所有账号。', + 'hint' => '只有当文案超出该平台上限时,才会按平台调整。', + 'none_available' => '这个工作区还没有连接其他账号。', + ], + + 'status_card' => [ + 'title' => '状态', + 'activate' => '启用', + 'pause' => '暂停', + 'resume' => '继续', + 'disable' => '停用', + 'watermark' => '开始监控于', + 'last_polled' => '上次检查', + 'draft_hint' => '至少选一个目标再启用。只有启用之后发布的视频才会被同步。', + 'active_hint' => 'TryPost 会定期检查这个账号,并同步每条新视频。', + 'paused_hint' => '检查已暂停。继续后会从停下的地方接着走,期间发布的内容不会丢失。', + 'disabled_hint' => '已关闭。再次启用会重新开始:关闭期间发布的内容不会被同步。', + ], + + 'items' => [ + 'source' => '原视频', + 'published_at' => '发布于', + 'status' => '状态', + 'detail' => '详情', + 'posts' => '已同步到', + 'view_original' => '查看原视频', + 'open_post' => '打开帖子', + 'statuses' => [ + 'pending' => '排队中', + 'processing' => '处理中', + 'published' => '已同步', + 'skipped' => '已跳过', + 'failed' => '失败', + ], + 'reasons' => [ + 'published_via_trypost' => '已通过 TryPost 发布', + 'not_video' => '不是视频', + 'media_url_missing' => '平台没有提供可下载的文件,通常是因为音频有版权', + 'download_failed' => '视频下载失败', + 'post_creation_failed' => '没有可用的目标', + ], + ], + + 'danger' => [ + 'title' => '删除这个 Repurpose', + 'description' => '检查会立即停止。已创建的帖子会保留在日历中。', + 'delete' => '删除 Repurpose', + ], + + 'errors' => [ + 'source_already_used' => '这个账号已经用于另一个 Repurpose,请去编辑那一个。', + 'destinations_required' => '启用前请至少选择一个目标。', + ], +]; diff --git a/lang/zh/sidebar.php b/lang/zh/sidebar.php index 0cfb68160..56d3a1808 100644 --- a/lang/zh/sidebar.php +++ b/lang/zh/sidebar.php @@ -26,6 +26,7 @@ 'others' => '其他', ], 'analytics' => '分析', + 'repurposes' => 'Repurpose', 'onboarding' => '开始使用', 'onboarding_hint' => '完成设置', 'posts' => [ diff --git a/resources/js/components/AppSidebar.vue b/resources/js/components/AppSidebar.vue index cdef840c4..cd5cebbcf 100644 --- a/resources/js/components/AppSidebar.vue +++ b/resources/js/components/AppSidebar.vue @@ -6,6 +6,7 @@ import { IconBrandDiscord, IconCalendar, IconChartBar, + IconRepeat, IconChevronRight, IconClock, IconFileCheck, @@ -55,6 +56,7 @@ import { index as assets } from '@/routes/app/assets'; import { portal } from '@/routes/app/billing'; import { index as labels } from '@/routes/app/labels'; import { index as mcp } from '@/routes/app/mcp'; +import { index as repurposes } from '@/routes/app/repurposes'; import { index as signatures } from '@/routes/app/signatures'; import { index as webhooks } from '@/routes/app/webhooks'; import type { NavItem, User } from '@/types'; @@ -79,6 +81,7 @@ const subscriptionPastDue = computed(() => const { canCreatePost, + canManageRepurposes, canManageAccounts, canManageWebhooks, canCreateWorkspace, @@ -96,6 +99,15 @@ const mainNavItems = computed(() => [ href: analytics.url(), icon: IconChartBar, }, + ...(canManageRepurposes.value + ? [ + { + title: trans('sidebar.repurposes'), + href: repurposes.url(), + icon: IconRepeat, + }, + ] + : []), ]); const postsNavItems = computed(() => [ diff --git a/resources/js/components/repurpose/CreateRepurposeDialog.vue b/resources/js/components/repurpose/CreateRepurposeDialog.vue new file mode 100644 index 000000000..5b0029f1e --- /dev/null +++ b/resources/js/components/repurpose/CreateRepurposeDialog.vue @@ -0,0 +1,115 @@ + + + diff --git a/resources/js/components/repurpose/DestinationPicker.vue b/resources/js/components/repurpose/DestinationPicker.vue new file mode 100644 index 000000000..30fb9d780 --- /dev/null +++ b/resources/js/components/repurpose/DestinationPicker.vue @@ -0,0 +1,73 @@ + + + diff --git a/resources/js/components/repurpose/RepurposeItemList.vue b/resources/js/components/repurpose/RepurposeItemList.vue new file mode 100644 index 000000000..10bd8fd82 --- /dev/null +++ b/resources/js/components/repurpose/RepurposeItemList.vue @@ -0,0 +1,86 @@ + + + diff --git a/resources/js/components/repurpose/RepurposeStatusCard.vue b/resources/js/components/repurpose/RepurposeStatusCard.vue new file mode 100644 index 000000000..7e637da57 --- /dev/null +++ b/resources/js/components/repurpose/RepurposeStatusCard.vue @@ -0,0 +1,98 @@ + + + diff --git a/resources/js/components/repurpose/RepurposeTemplateCard.vue b/resources/js/components/repurpose/RepurposeTemplateCard.vue new file mode 100644 index 000000000..2c36ee6fb --- /dev/null +++ b/resources/js/components/repurpose/RepurposeTemplateCard.vue @@ -0,0 +1,41 @@ + + + diff --git a/resources/js/composables/useWorkspaceRole.ts b/resources/js/composables/useWorkspaceRole.ts index 84b766e7d..ef86de1e5 100644 --- a/resources/js/composables/useWorkspaceRole.ts +++ b/resources/js/composables/useWorkspaceRole.ts @@ -29,6 +29,7 @@ export const useWorkspaceRole = () => { isAdminOrAbove, isMemberOrAbove, canCreatePost: isMemberOrAbove, + canManageRepurposes: isMemberOrAbove, canManageAccounts: isAdminOrAbove, canManageWebhooks: isAdminOrAbove, canManageTeam: isAdminOrAbove, diff --git a/resources/js/pages/repurposes/Index.vue b/resources/js/pages/repurposes/Index.vue new file mode 100644 index 000000000..02c93b7fa --- /dev/null +++ b/resources/js/pages/repurposes/Index.vue @@ -0,0 +1,146 @@ + + + diff --git a/resources/js/pages/repurposes/Show.vue b/resources/js/pages/repurposes/Show.vue new file mode 100644 index 000000000..ee2ee9dcf --- /dev/null +++ b/resources/js/pages/repurposes/Show.vue @@ -0,0 +1,122 @@ + + + diff --git a/resources/js/types/repurpose-status.ts b/resources/js/types/repurpose-status.ts new file mode 100644 index 000000000..8d98cbd5a --- /dev/null +++ b/resources/js/types/repurpose-status.ts @@ -0,0 +1,22 @@ +export type RepurposeStatus = 'draft' | 'active' | 'paused' | 'disabled'; + +export type RepurposeItemStatus = 'pending' | 'processing' | 'published' | 'skipped' | 'failed'; + +export const repurposeStatusVariant = (status: RepurposeStatus): 'default' | 'secondary' | 'warning' | 'outline' => + ({ + draft: 'outline', + active: 'default', + paused: 'warning', + disabled: 'secondary', + })[status] ?? 'outline'; + +export const repurposeItemStatusVariant = ( + status: RepurposeItemStatus, +): 'default' | 'secondary' | 'warning' | 'destructive' | 'outline' => + ({ + pending: 'outline', + processing: 'outline', + published: 'default', + skipped: 'secondary', + failed: 'destructive', + })[status] ?? 'outline'; diff --git a/resources/js/types/repurpose.ts b/resources/js/types/repurpose.ts new file mode 100644 index 000000000..2d61307db --- /dev/null +++ b/resources/js/types/repurpose.ts @@ -0,0 +1,46 @@ +import type { ChannelAccount } from '@/types/channel'; +import type { RepurposeItemStatus, RepurposeStatus } from '@/types/repurpose-status'; + +export interface RepurposeDestination { + social_account_id: string; + content_type: string; + meta: Record; +} + +export interface Repurpose { + id: string; + source_social_account_id: string; + source_account?: ChannelAccount | null; + destinations: RepurposeDestination[]; + status: RepurposeStatus; + activated_at: string | null; + last_polled_at: string | null; + next_poll_at: string | null; + last_error: string | null; + published_items_count?: number; + created_at: string; + updated_at: string; +} + +export interface RepurposeItemPost { + id: string; + status: string; +} + +export interface RepurposeItem { + id: string; + source_media_id: string; + source_permalink: string | null; + source_created_at: string | null; + status: RepurposeItemStatus; + reason: string | null; + error: string | null; + posts?: RepurposeItemPost[]; + created_at: string; +} + +export interface RepurposeTemplate { + key: string; + source_platform: string; + destination_platforms: string[]; +} diff --git a/routes/app.php b/routes/app.php index f44d887e1..eb8c92b13 100644 --- a/routes/app.php +++ b/routes/app.php @@ -19,6 +19,7 @@ use App\Http\Controllers\App\PostCommentController; use App\Http\Controllers\App\PostController; use App\Http\Controllers\App\PresenceController; +use App\Http\Controllers\App\RepurposeController; use App\Http\Controllers\App\Settings\AccountController; use App\Http\Controllers\App\Settings\AuthenticationController; use App\Http\Controllers\App\Settings\NotificationPreferenceController; @@ -257,6 +258,17 @@ Route::get('settings/workspace/mcp', [McpSettingsController::class, 'index'])->name('app.mcp.index'); Route::delete('settings/workspace/mcp/{client}', [McpSettingsController::class, 'disconnect'])->name('app.mcp.disconnect'); + // Repurpose + Route::get('repurposes', [RepurposeController::class, 'index'])->name('app.repurposes.index'); + Route::post('repurposes', [RepurposeController::class, 'store'])->name('app.repurposes.store'); + Route::get('repurposes/{repurpose}', [RepurposeController::class, 'show'])->name('app.repurposes.show'); + Route::put('repurposes/{repurpose}', [RepurposeController::class, 'update'])->name('app.repurposes.update'); + Route::post('repurposes/{repurpose}/activate', [RepurposeController::class, 'activate'])->name('app.repurposes.activate'); + Route::post('repurposes/{repurpose}/pause', [RepurposeController::class, 'pause'])->name('app.repurposes.pause'); + Route::post('repurposes/{repurpose}/resume', [RepurposeController::class, 'resume'])->name('app.repurposes.resume'); + Route::post('repurposes/{repurpose}/disable', [RepurposeController::class, 'disable'])->name('app.repurposes.disable'); + Route::delete('repurposes/{repurpose}', [RepurposeController::class, 'destroy'])->name('app.repurposes.destroy'); + // Webhooks Route::get('webhooks', [WebhookController::class, 'index'])->name('app.webhooks.index'); Route::post('webhooks', [WebhookController::class, 'store'])->name('app.webhooks.store'); diff --git a/tests/Feature/Repurpose/WebTest.php b/tests/Feature/Repurpose/WebTest.php new file mode 100644 index 000000000..601c90ac3 --- /dev/null +++ b/tests/Feature/Repurpose/WebTest.php @@ -0,0 +1,177 @@ +user = User::factory()->create(); + $this->workspace = Workspace::factory()->create([ + 'account_id' => $this->user->account_id, + 'user_id' => $this->user->id, + ]); + $this->user->update(['current_workspace_id' => $this->workspace->id]); + + $this->source = SocialAccount::factory()->for($this->workspace)->create(['platform' => Platform::Instagram]); + $this->tiktok = SocialAccount::factory()->for($this->workspace)->create(['platform' => Platform::TikTok]); +}); + +function destinationPayload(SocialAccount $account): array +{ + return [ + 'social_account_id' => $account->id, + 'content_type' => ContentType::TikTokVideo->value, + 'meta' => ['privacy_level' => 'PUBLIC_TO_EVERYONE'], + ]; +} + +test('the index lists repurposes and the ready-made templates', function () { + Repurpose::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'source_social_account_id' => $this->source->id, + ]); + + $this->actingAs($this->user) + ->get(route('app.repurposes.index')) + ->assertOk() + ->assertInertia(fn (AssertableInertia $page) => $page + ->component('repurposes/Index') + ->has('repurposes', 1) + ->has('templates', 2) + ->has('sourceAccounts', 1)); +}); + +test('only networks we can download from are offered as a source', function () { + $this->actingAs($this->user) + ->get(route('app.repurposes.index')) + ->assertInertia(fn (AssertableInertia $page) => $page + ->has('sourceAccounts', 1) + ->where('sourceAccounts.0.id', $this->source->id)); +}); + +test('storing creates a draft and redirects to its page', function () { + $response = $this->actingAs($this->user) + ->post(route('app.repurposes.store'), ['source_social_account_id' => $this->source->id]); + + $repurpose = Repurpose::sole(); + + $response->assertRedirect(route('app.repurposes.show', $repurpose)); + + expect($repurpose->status)->toBe(Status::Draft); +}); + +test('storing for an account that already has a repurpose redirects to the existing one', function () { + $existing = Repurpose::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'source_social_account_id' => $this->source->id, + ]); + + $this->actingAs($this->user) + ->post(route('app.repurposes.store'), ['source_social_account_id' => $this->source->id]) + ->assertRedirect(route('app.repurposes.show', $existing)); + + expect(Repurpose::count())->toBe(1); +}); + +test('the show page renders the repurpose, its destinations and its items', function () { + $repurpose = Repurpose::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'source_social_account_id' => $this->source->id, + ]); + + $this->actingAs($this->user) + ->get(route('app.repurposes.show', $repurpose)) + ->assertOk() + ->assertInertia(fn (AssertableInertia $page) => $page + ->component('repurposes/Show') + ->where('repurpose.id', $repurpose->id) + ->has('destinationAccounts', 1) + ->has('items')); +}); + +test('updating saves destinations with their platform meta', function () { + $repurpose = Repurpose::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'source_social_account_id' => $this->source->id, + ]); + + $destination = destinationPayload($this->tiktok); + + $this->actingAs($this->user) + ->put(route('app.repurposes.update', $repurpose), ['destinations' => [$destination]]) + ->assertRedirect(); + + expect($repurpose->fresh()->destinations)->toEqual([$destination]); +}); + +test('the status transitions are exposed', function () { + $repurpose = Repurpose::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'source_social_account_id' => $this->source->id, + 'destinations' => [destinationPayload($this->tiktok)], + ]); + + $this->actingAs($this->user)->post(route('app.repurposes.activate', $repurpose))->assertRedirect(); + expect($repurpose->fresh()->status)->toBe(Status::Active); + + $this->actingAs($this->user)->post(route('app.repurposes.pause', $repurpose))->assertRedirect(); + expect($repurpose->fresh()->status)->toBe(Status::Paused); + + $this->actingAs($this->user)->post(route('app.repurposes.resume', $repurpose))->assertRedirect(); + expect($repurpose->fresh()->status)->toBe(Status::Active); + + $this->actingAs($this->user)->post(route('app.repurposes.disable', $repurpose))->assertRedirect(); + expect($repurpose->fresh()->status)->toBe(Status::Disabled); +}); + +test('activating without a destination fails validation', function () { + $repurpose = Repurpose::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'source_social_account_id' => $this->source->id, + ]); + + $this->actingAs($this->user) + ->post(route('app.repurposes.activate', $repurpose)) + ->assertSessionHasErrors('destinations'); +}); + +test('deleting removes the repurpose', function () { + $repurpose = Repurpose::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'source_social_account_id' => $this->source->id, + ]); + + $this->actingAs($this->user) + ->delete(route('app.repurposes.destroy', $repurpose)) + ->assertRedirect(route('app.repurposes.index')); + + expect(Repurpose::count())->toBe(0); +}); + +test('a viewer cannot create a repurpose', function () { + $viewer = User::factory()->create([ + 'account_id' => $this->user->account_id, + 'current_workspace_id' => $this->workspace->id, + ]); + $this->workspace->members()->attach($viewer->id, ['role' => Role::Viewer->value]); + + $this->actingAs($viewer) + ->post(route('app.repurposes.store'), ['source_social_account_id' => $this->source->id]) + ->assertForbidden(); +}); + +test('a repurpose from another workspace is not reachable', function () { + $stranger = Repurpose::factory()->create(); + + $this->actingAs($this->user) + ->get(route('app.repurposes.show', $stranger)) + ->assertForbidden(); +}); From 39b1ca616f1d573cd1ad55c9511cfb0c6ae714d9 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Sat, 5 Sep 2026 16:25:15 -0300 Subject: [PATCH 005/114] Match the accounts screen design on the repurpose pages Network logos now use the same tile as the accounts grid: the network's colour, the slight tilt that straightens on hover, and a hard border. Source and destination pickers are tiles rather than a select, so the network is visible at a glance, and every screen leads with the flow from source to destinations. The empty state no longer duplicates the templates below it, the create dialog links to the accounts page when nothing can be a source, and the save button uses a real translation key instead of the missing common.save. --- .../Controllers/App/RepurposeController.php | 8 +- lang/ar/repurposes.php | 7 ++ lang/de/repurposes.php | 7 ++ lang/el/repurposes.php | 7 ++ lang/en/repurposes.php | 7 ++ lang/es/repurposes.php | 7 ++ lang/fr/repurposes.php | 7 ++ lang/it/repurposes.php | 7 ++ lang/ja/repurposes.php | 7 ++ lang/ko/repurposes.php | 7 ++ lang/nl/repurposes.php | 7 ++ lang/pl/repurposes.php | 7 ++ lang/pt-BR/repurposes.php | 7 ++ lang/ru/repurposes.php | 7 ++ lang/tr/repurposes.php | 7 ++ lang/uk/repurposes.php | 7 ++ lang/zh/repurposes.php | 7 ++ .../repurpose/CreateRepurposeDialog.vue | 86 ++++++++++++------- .../repurpose/DestinationPicker.vue | 45 ++++++---- .../js/components/repurpose/PlatformLogo.vue | 52 +++++++++++ .../js/components/repurpose/RepurposeFlow.vue | 40 +++++++++ .../repurpose/RepurposeTemplateCard.vue | 49 +++++------ resources/js/pages/repurposes/Index.vue | 40 ++++++--- resources/js/pages/repurposes/Show.vue | 22 ++++- 24 files changed, 365 insertions(+), 89 deletions(-) create mode 100644 resources/js/components/repurpose/PlatformLogo.vue create mode 100644 resources/js/components/repurpose/RepurposeFlow.vue diff --git a/app/Http/Controllers/App/RepurposeController.php b/app/Http/Controllers/App/RepurposeController.php index 0cd4e1acb..9a77057e0 100644 --- a/app/Http/Controllers/App/RepurposeController.php +++ b/app/Http/Controllers/App/RepurposeController.php @@ -36,6 +36,7 @@ public function index(Request $request): Response 'repurposes' => ListRepurposes::execute($workspace), 'templates' => Templates::all(), 'sourceAccounts' => SocialAccountResource::collection($this->sourceAccounts($request)), + 'destinationAccounts' => SocialAccountResource::collection($this->connectedAccounts($request)), ]); } @@ -46,7 +47,9 @@ public function show(Request $request, Repurpose $repurpose): Response return Inertia::render('repurposes/Show', [ 'repurpose' => $repurpose->load('sourceAccount'), 'sourceAccounts' => SocialAccountResource::collection($this->sourceAccounts($request)), - 'destinationAccounts' => SocialAccountResource::collection($this->destinationAccounts($request, $repurpose)), + 'destinationAccounts' => SocialAccountResource::collection( + $this->connectedAccounts($request)->whereNotIn('id', [$repurpose->source_social_account_id])->values(), + ), 'items' => Inertia::scroll(fn () => ListRepurposeItems::execute($repurpose)), ]); } @@ -135,12 +138,11 @@ private function sourceAccounts(Request $request) * Accounts, not networks: a workspace may hold two Instagram accounts and * both are valid destinations. */ - private function destinationAccounts(Request $request, Repurpose $repurpose) + private function connectedAccounts(Request $request) { return $request->user()->currentWorkspace ->socialAccounts() ->active() - ->whereKeyNot($repurpose->source_social_account_id) ->get(); } } diff --git a/lang/ar/repurposes.php b/lang/ar/repurposes.php index f70270e6e..acc9f9c46 100644 --- a/lang/ar/repurposes.php +++ b/lang/ar/repurposes.php @@ -7,12 +7,17 @@ 'description' => 'أعد نشر مقاطع الفيديو التي تنشرها خارج TryPost على شبكاتك الأخرى تلقائيًا.', 'new' => 'repurpose جديد', + 'flow' => [ + 'no_destinations' => 'لا توجد وجهة بعد', + ], + 'empty' => [ 'title' => 'لم يتم إعداد أي repurpose بعد', 'description' => 'اختر نقطة بداية بالأسفل. يراقب TryPost الحساب الذي تختاره ويعيد نشر كل فيديو جديد على الشبكات التي تحددها.', ], 'table' => [ + 'flow' => 'التدفق', 'source' => 'المصدر', 'destinations' => 'الوجهات', 'status' => 'الحالة', @@ -46,6 +51,7 @@ 'source_placeholder' => 'اختر حسابًا', 'no_accounts' => 'اربط أولًا حساب Instagram أو Facebook. هذان فقط يصلحان كمصدر، لأنهما الشبكتان الوحيدتان اللتان تسمحان بتنزيل الفيديو.', 'submit' => 'إنشاء', + 'connect' => 'ربط حساب', ], 'show' => [ @@ -63,6 +69,7 @@ 'description' => 'يُنشر كل فيديو جديد من المصدر على كل حساب تختاره هنا.', 'hint' => 'يُعدَّل النص لكل شبكة فقط عندما يتجاوز حد تلك الشبكة.', 'none_available' => 'لا يوجد حساب آخر متصل في مساحة العمل هذه بعد.', + 'save' => 'حفظ الوجهات', ], 'status_card' => [ diff --git a/lang/de/repurposes.php b/lang/de/repurposes.php index 3751da7a8..b3489a17c 100644 --- a/lang/de/repurposes.php +++ b/lang/de/repurposes.php @@ -7,12 +7,17 @@ 'description' => 'Videos, die du außerhalb von TryPost postest, automatisch auf deinen anderen Netzwerken wiederveröffentlichen.', 'new' => 'Neues Repurpose', + 'flow' => [ + 'no_destinations' => 'Noch kein Ziel', + ], + 'empty' => [ 'title' => 'Noch kein Repurpose eingerichtet', 'description' => 'Wähle unten einen Startpunkt. TryPost beobachtet das gewählte Konto und veröffentlicht jedes neue Video erneut auf den Netzwerken deiner Wahl.', ], 'table' => [ + 'flow' => 'Ablauf', 'source' => 'Quelle', 'destinations' => 'Ziele', 'status' => 'Status', @@ -46,6 +51,7 @@ 'source_placeholder' => 'Konto auswählen', 'no_accounts' => 'Verbinde zuerst ein Instagram- oder Facebook-Konto. Nur diese können Quelle sein, weil nur sie den Download des Videos erlauben.', 'submit' => 'Erstellen', + 'connect' => 'Konto verbinden', ], 'show' => [ @@ -63,6 +69,7 @@ 'description' => 'Jedes neue Video der Quelle wird auf jedem hier gewählten Konto veröffentlicht.', 'hint' => 'Der Text wird nur dann pro Netzwerk angepasst, wenn er dessen Limit überschreitet.', 'none_available' => 'In diesem Workspace ist noch kein weiteres Konto verbunden.', + 'save' => 'Ziele speichern', ], 'status_card' => [ diff --git a/lang/el/repurposes.php b/lang/el/repurposes.php index 7f90c5cf3..0980628ba 100644 --- a/lang/el/repurposes.php +++ b/lang/el/repurposes.php @@ -7,12 +7,17 @@ 'description' => 'Αναδημοσίευσε αυτόματα στα άλλα σου δίκτυα τα βίντεο που ανεβάζεις εκτός TryPost.', 'new' => 'Νέο repurpose', + 'flow' => [ + 'no_destinations' => 'Κανένας προορισμός ακόμη', + ], + 'empty' => [ 'title' => 'Δεν έχει ρυθμιστεί repurpose ακόμη', 'description' => 'Διάλεξε ένα σημείο εκκίνησης παρακάτω. Το TryPost παρακολουθεί τον λογαριασμό που επιλέγεις και αναδημοσιεύει κάθε νέο βίντεο στα δίκτυα που σημειώνεις.', ], 'table' => [ + 'flow' => 'Ροή', 'source' => 'Πηγή', 'destinations' => 'Προορισμοί', 'status' => 'Κατάσταση', @@ -46,6 +51,7 @@ 'source_placeholder' => 'Επίλεξε λογαριασμό', 'no_accounts' => 'Σύνδεσε πρώτα έναν λογαριασμό Instagram ή Facebook. Μόνο αυτοί μπορούν να είναι πηγή, γιατί μόνο αυτά τα δίκτυα επιτρέπουν τη λήψη του βίντεο.', 'submit' => 'Δημιουργία', + 'connect' => 'Σύνδεση λογαριασμού', ], 'show' => [ @@ -63,6 +69,7 @@ 'description' => 'Κάθε νέο βίντεο της πηγής δημοσιεύεται σε κάθε λογαριασμό που επιλέγεις εδώ.', 'hint' => 'Η λεζάντα προσαρμόζεται ανά δίκτυο μόνο όταν ξεπερνά το όριο εκείνου του δικτύου.', 'none_available' => 'Δεν υπάρχει άλλος συνδεδεμένος λογαριασμός σε αυτόν τον χώρο εργασίας.', + 'save' => 'Αποθήκευση προορισμών', ], 'status_card' => [ diff --git a/lang/en/repurposes.php b/lang/en/repurposes.php index 1cd0982b3..1437e700d 100644 --- a/lang/en/repurposes.php +++ b/lang/en/repurposes.php @@ -7,12 +7,17 @@ 'description' => 'Replicate videos you post outside TryPost to your other networks, automatically.', 'new' => 'New repurpose', + 'flow' => [ + 'no_destinations' => 'No destination yet', + ], + 'empty' => [ 'title' => 'No repurpose set up yet', 'description' => 'Pick a starting point below. TryPost watches the account you choose and republishes every new video to the networks you pick.', ], 'table' => [ + 'flow' => 'Flow', 'source' => 'Source', 'destinations' => 'Destinations', 'status' => 'Status', @@ -46,6 +51,7 @@ 'source_placeholder' => 'Select an account', 'no_accounts' => 'Connect an Instagram or Facebook account first. Only these can be a source, because they are the only networks that let us download the video.', 'submit' => 'Create', + 'connect' => 'Connect an account', ], 'show' => [ @@ -63,6 +69,7 @@ 'description' => 'Every new video from the source is published to each account you select here.', 'hint' => 'Captions are adapted per network only when they exceed that network\'s limit.', 'none_available' => 'No other account is connected in this workspace yet.', + 'save' => 'Save destinations', ], 'status_card' => [ diff --git a/lang/es/repurposes.php b/lang/es/repurposes.php index 769abbbf7..433ab9658 100644 --- a/lang/es/repurposes.php +++ b/lang/es/repurposes.php @@ -7,12 +7,17 @@ 'description' => 'Replica automáticamente en tus otras redes los vídeos que publicas fuera de TryPost.', 'new' => 'Nuevo repurpose', + 'flow' => [ + 'no_destinations' => 'Aún sin destino', + ], + 'empty' => [ 'title' => 'Aún no hay ningún repurpose', 'description' => 'Elige un punto de partida abajo. TryPost vigila la cuenta que elijas y republica cada vídeo nuevo en las redes que marques.', ], 'table' => [ + 'flow' => 'Flujo', 'source' => 'Origen', 'destinations' => 'Destinos', 'status' => 'Estado', @@ -46,6 +51,7 @@ 'source_placeholder' => 'Selecciona una cuenta', 'no_accounts' => 'Conecta antes una cuenta de Instagram o Facebook. Solo ellas pueden ser origen, porque son las únicas redes que permiten descargar el vídeo.', 'submit' => 'Crear', + 'connect' => 'Conectar una cuenta', ], 'show' => [ @@ -63,6 +69,7 @@ 'description' => 'Cada vídeo nuevo del origen se publica en todas las cuentas que selecciones aquí.', 'hint' => 'El texto solo se adapta por red cuando supera el límite de esa red.', 'none_available' => 'No hay ninguna otra cuenta conectada en este espacio de trabajo.', + 'save' => 'Guardar destinos', ], 'status_card' => [ diff --git a/lang/fr/repurposes.php b/lang/fr/repurposes.php index 4e3475a9f..957fe6f80 100644 --- a/lang/fr/repurposes.php +++ b/lang/fr/repurposes.php @@ -7,12 +7,17 @@ 'description' => 'Republiez automatiquement sur vos autres réseaux les vidéos que vous postez en dehors de TryPost.', 'new' => 'Nouveau repurpose', + 'flow' => [ + 'no_destinations' => 'Aucune destination', + ], + 'empty' => [ 'title' => 'Aucun repurpose configuré', 'description' => 'Choisissez un point de départ ci-dessous. TryPost surveille le compte choisi et republie chaque nouvelle vidéo sur les réseaux que vous sélectionnez.', ], 'table' => [ + 'flow' => 'Flux', 'source' => 'Source', 'destinations' => 'Destinations', 'status' => 'Statut', @@ -46,6 +51,7 @@ 'source_placeholder' => 'Sélectionner un compte', 'no_accounts' => 'Connectez d\'abord un compte Instagram ou Facebook. Seuls ces réseaux peuvent être source, car ce sont les seuls qui permettent de télécharger la vidéo.', 'submit' => 'Créer', + 'connect' => 'Connecter un compte', ], 'show' => [ @@ -63,6 +69,7 @@ 'description' => 'Chaque nouvelle vidéo de la source est publiée sur chaque compte sélectionné ici.', 'hint' => 'La légende n\'est adaptée par réseau que lorsqu\'elle dépasse la limite de ce réseau.', 'none_available' => 'Aucun autre compte n\'est connecté dans cet espace de travail.', + 'save' => 'Enregistrer les destinations', ], 'status_card' => [ diff --git a/lang/it/repurposes.php b/lang/it/repurposes.php index 2a4b581db..18815827b 100644 --- a/lang/it/repurposes.php +++ b/lang/it/repurposes.php @@ -7,12 +7,17 @@ 'description' => 'Ripubblica automaticamente sulle altre reti i video che pubblichi fuori da TryPost.', 'new' => 'Nuovo repurpose', + 'flow' => [ + 'no_destinations' => 'Nessuna destinazione', + ], + 'empty' => [ 'title' => 'Nessun repurpose configurato', 'description' => 'Scegli un punto di partenza qui sotto. TryPost tiene d\'occhio l\'account scelto e ripubblica ogni nuovo video sulle reti che selezioni.', ], 'table' => [ + 'flow' => 'Flusso', 'source' => 'Origine', 'destinations' => 'Destinazioni', 'status' => 'Stato', @@ -46,6 +51,7 @@ 'source_placeholder' => 'Seleziona un account', 'no_accounts' => 'Collega prima un account Instagram o Facebook. Solo questi possono essere origine, perché sono le uniche reti che permettono di scaricare il video.', 'submit' => 'Crea', + 'connect' => 'Collega un account', ], 'show' => [ @@ -63,6 +69,7 @@ 'description' => 'Ogni nuovo video dell\'origine viene pubblicato su tutti gli account selezionati qui.', 'hint' => 'La didascalia viene adattata per rete solo quando supera il limite di quella rete.', 'none_available' => 'Nessun altro account è collegato in questo workspace.', + 'save' => 'Salva destinazioni', ], 'status_card' => [ diff --git a/lang/ja/repurposes.php b/lang/ja/repurposes.php index 517cfa1ee..470905375 100644 --- a/lang/ja/repurposes.php +++ b/lang/ja/repurposes.php @@ -7,12 +7,17 @@ 'description' => 'TryPost の外で投稿した動画を、他のネットワークへ自動で再投稿します。', 'new' => '新しい Repurpose', + 'flow' => [ + 'no_destinations' => '配信先はまだありません', + ], + 'empty' => [ 'title' => 'Repurpose はまだ設定されていません', 'description' => '下から出発点を選んでください。TryPost が選んだアカウントを見張り、新しい動画をチェックしたネットワークへ再投稿します。', ], 'table' => [ + 'flow' => 'フロー', 'source' => 'ソース', 'destinations' => '配信先', 'status' => 'ステータス', @@ -46,6 +51,7 @@ 'source_placeholder' => 'アカウントを選択', 'no_accounts' => '先に Instagram か Facebook のアカウントを接続してください。動画をダウンロードできるのはこの 2 つだけなので、ソースになれるのもこの 2 つだけです。', 'submit' => '作成', + 'connect' => 'アカウントを接続', ], 'show' => [ @@ -63,6 +69,7 @@ 'description' => 'ソースの新しい動画は、ここで選んだすべてのアカウントに投稿されます。', 'hint' => 'キャプションは、そのネットワークの上限を超えたときだけ調整されます。', 'none_available' => 'このワークスペースにはまだ他のアカウントが接続されていません。', + 'save' => '配信先を保存', ], 'status_card' => [ diff --git a/lang/ko/repurposes.php b/lang/ko/repurposes.php index 5d1bce646..fdc1c45ea 100644 --- a/lang/ko/repurposes.php +++ b/lang/ko/repurposes.php @@ -7,12 +7,17 @@ 'description' => 'TryPost 외부에서 올린 영상을 다른 네트워크에 자동으로 다시 게시합니다.', 'new' => '새 Repurpose', + 'flow' => [ + 'no_destinations' => '아직 대상이 없습니다', + ], + 'empty' => [ 'title' => '아직 설정된 Repurpose가 없습니다', 'description' => '아래에서 시작점을 고르세요. TryPost가 선택한 계정을 지켜보다가 새 영상을 선택한 네트워크에 다시 게시합니다.', ], 'table' => [ + 'flow' => '흐름', 'source' => '소스', 'destinations' => '대상', 'status' => '상태', @@ -46,6 +51,7 @@ 'source_placeholder' => '계정 선택', 'no_accounts' => '먼저 Instagram이나 Facebook 계정을 연결하세요. 영상을 내려받을 수 있는 네트워크는 이 둘뿐이라 소스도 이 둘만 가능합니다.', 'submit' => '만들기', + 'connect' => '계정 연결', ], 'show' => [ @@ -63,6 +69,7 @@ 'description' => '소스의 새 영상은 여기서 선택한 모든 계정에 게시됩니다.', 'hint' => '캡션은 해당 네트워크의 한도를 넘을 때만 조정됩니다.', 'none_available' => '이 워크스페이스에 연결된 다른 계정이 아직 없습니다.', + 'save' => '대상 저장', ], 'status_card' => [ diff --git a/lang/nl/repurposes.php b/lang/nl/repurposes.php index 95ce59285..7f43ff6a2 100644 --- a/lang/nl/repurposes.php +++ b/lang/nl/repurposes.php @@ -7,12 +7,17 @@ 'description' => 'Publiceer video\'s die je buiten TryPost post automatisch opnieuw op je andere netwerken.', 'new' => 'Nieuwe repurpose', + 'flow' => [ + 'no_destinations' => 'Nog geen bestemming', + ], + 'empty' => [ 'title' => 'Nog geen repurpose ingesteld', 'description' => 'Kies hieronder een startpunt. TryPost volgt het gekozen account en plaatst elke nieuwe video opnieuw op de netwerken die je aanvinkt.', ], 'table' => [ + 'flow' => 'Stroom', 'source' => 'Bron', 'destinations' => 'Bestemmingen', 'status' => 'Status', @@ -46,6 +51,7 @@ 'source_placeholder' => 'Selecteer een account', 'no_accounts' => 'Koppel eerst een Instagram- of Facebook-account. Alleen die kunnen bron zijn, want alleen zij laten ons de video downloaden.', 'submit' => 'Aanmaken', + 'connect' => 'Account koppelen', ], 'show' => [ @@ -63,6 +69,7 @@ 'description' => 'Elke nieuwe video van de bron wordt geplaatst op elk account dat je hier selecteert.', 'hint' => 'Het bijschrift wordt alleen per netwerk aangepast als het de limiet van dat netwerk overschrijdt.', 'none_available' => 'Er is nog geen ander account gekoppeld in deze workspace.', + 'save' => 'Bestemmingen opslaan', ], 'status_card' => [ diff --git a/lang/pl/repurposes.php b/lang/pl/repurposes.php index cdf4a290f..5758d2ae1 100644 --- a/lang/pl/repurposes.php +++ b/lang/pl/repurposes.php @@ -7,12 +7,17 @@ 'description' => 'Automatycznie publikuj w pozostałych sieciach filmy, które wrzucasz poza TryPost.', 'new' => 'Nowy repurpose', + 'flow' => [ + 'no_destinations' => 'Brak celu', + ], + 'empty' => [ 'title' => 'Nie skonfigurowano jeszcze repurpose', 'description' => 'Wybierz punkt startowy poniżej. TryPost obserwuje wybrane konto i publikuje każdy nowy film w sieciach, które zaznaczysz.', ], 'table' => [ + 'flow' => 'Przepływ', 'source' => 'Źródło', 'destinations' => 'Cele', 'status' => 'Status', @@ -46,6 +51,7 @@ 'source_placeholder' => 'Wybierz konto', 'no_accounts' => 'Najpierw połącz konto Instagrama lub Facebooka. Tylko one mogą być źródłem, bo tylko te sieci pozwalają pobrać film.', 'submit' => 'Utwórz', + 'connect' => 'Połącz konto', ], 'show' => [ @@ -63,6 +69,7 @@ 'description' => 'Każdy nowy film ze źródła trafia na wszystkie zaznaczone tu konta.', 'hint' => 'Opis jest dostosowywany do sieci tylko wtedy, gdy przekracza jej limit.', 'none_available' => 'W tym obszarze roboczym nie ma jeszcze innego połączonego konta.', + 'save' => 'Zapisz cele', ], 'status_card' => [ diff --git a/lang/pt-BR/repurposes.php b/lang/pt-BR/repurposes.php index 34e4b2ed5..c7a8e8f98 100644 --- a/lang/pt-BR/repurposes.php +++ b/lang/pt-BR/repurposes.php @@ -7,12 +7,17 @@ 'description' => 'Replique automaticamente nas suas outras redes os vídeos que você publica fora do TryPost.', 'new' => 'Novo repurpose', + 'flow' => [ + 'no_destinations' => 'Nenhum destino ainda', + ], + 'empty' => [ 'title' => 'Nenhum repurpose configurado', 'description' => 'Escolha um ponto de partida abaixo. O TryPost acompanha a conta que você escolher e republica cada novo vídeo nas redes que você marcar.', ], 'table' => [ + 'flow' => 'Fluxo', 'source' => 'Origem', 'destinations' => 'Destinos', 'status' => 'Status', @@ -46,6 +51,7 @@ 'source_placeholder' => 'Selecione uma conta', 'no_accounts' => 'Conecte antes uma conta do Instagram ou do Facebook. Só elas podem ser origem, porque são as únicas redes que permitem baixar o vídeo.', 'submit' => 'Criar', + 'connect' => 'Conectar uma conta', ], 'show' => [ @@ -63,6 +69,7 @@ 'description' => 'Cada novo vídeo da origem é publicado em todas as contas selecionadas aqui.', 'hint' => 'A legenda só é adaptada por rede quando ultrapassa o limite daquela rede.', 'none_available' => 'Nenhuma outra conta está conectada neste workspace.', + 'save' => 'Salvar destinos', ], 'status_card' => [ diff --git a/lang/ru/repurposes.php b/lang/ru/repurposes.php index e225f7ca9..ccbf55dac 100644 --- a/lang/ru/repurposes.php +++ b/lang/ru/repurposes.php @@ -7,12 +7,17 @@ 'description' => 'Автоматически публикуйте в других сетях видео, которые вы выкладываете вне TryPost.', 'new' => 'Новый repurpose', + 'flow' => [ + 'no_destinations' => 'Пока нет назначения', + ], + 'empty' => [ 'title' => 'Repurpose ещё не настроен', 'description' => 'Выберите отправную точку ниже. TryPost следит за выбранным аккаунтом и заново публикует каждое новое видео в отмеченных сетях.', ], 'table' => [ + 'flow' => 'Поток', 'source' => 'Источник', 'destinations' => 'Назначения', 'status' => 'Статус', @@ -46,6 +51,7 @@ 'source_placeholder' => 'Выберите аккаунт', 'no_accounts' => 'Сначала подключите аккаунт Instagram или Facebook. Только они могут быть источником, потому что только эти сети позволяют скачать видео.', 'submit' => 'Создать', + 'connect' => 'Подключить аккаунт', ], 'show' => [ @@ -63,6 +69,7 @@ 'description' => 'Каждое новое видео из источника публикуется во всех выбранных здесь аккаунтах.', 'hint' => 'Подпись адаптируется под сеть только тогда, когда превышает её лимит.', 'none_available' => 'В этом рабочем пространстве пока нет других подключённых аккаунтов.', + 'save' => 'Сохранить назначения', ], 'status_card' => [ diff --git a/lang/tr/repurposes.php b/lang/tr/repurposes.php index f3ab85a3e..9b9172f9d 100644 --- a/lang/tr/repurposes.php +++ b/lang/tr/repurposes.php @@ -7,12 +7,17 @@ 'description' => 'TryPost dışında paylaştığın videoları diğer ağlarında otomatik olarak yeniden yayınla.', 'new' => 'Yeni repurpose', + 'flow' => [ + 'no_destinations' => 'Henüz hedef yok', + ], + 'empty' => [ 'title' => 'Henüz repurpose kurulmadı', 'description' => 'Aşağıdan bir başlangıç noktası seç. TryPost seçtiğin hesabı izler ve her yeni videoyu işaretlediğin ağlarda yeniden yayınlar.', ], 'table' => [ + 'flow' => 'Akış', 'source' => 'Kaynak', 'destinations' => 'Hedefler', 'status' => 'Durum', @@ -46,6 +51,7 @@ 'source_placeholder' => 'Bir hesap seç', 'no_accounts' => 'Önce bir Instagram veya Facebook hesabı bağla. Yalnızca bunlar kaynak olabilir, çünkü videoyu indirmemize izin veren tek ağlar bunlar.', 'submit' => 'Oluştur', + 'connect' => 'Hesap bağla', ], 'show' => [ @@ -63,6 +69,7 @@ 'description' => 'Kaynaktaki her yeni video, burada seçtiğin tüm hesaplarda yayınlanır.', 'hint' => 'Açıklama yalnızca o ağın sınırını aştığında ağa göre uyarlanır.', 'none_available' => 'Bu çalışma alanında bağlı başka hesap yok.', + 'save' => 'Hedefleri kaydet', ], 'status_card' => [ diff --git a/lang/uk/repurposes.php b/lang/uk/repurposes.php index 7f349e4b7..adb12b977 100644 --- a/lang/uk/repurposes.php +++ b/lang/uk/repurposes.php @@ -7,12 +7,17 @@ 'description' => 'Автоматично публікуйте в інших мережах відео, які ви викладаєте поза TryPost.', 'new' => 'Новий repurpose', + 'flow' => [ + 'no_destinations' => 'Ще немає призначення', + ], + 'empty' => [ 'title' => 'Repurpose ще не налаштовано', 'description' => 'Оберіть відправну точку нижче. TryPost стежить за обраним акаунтом і повторно публікує кожне нове відео в позначених мережах.', ], 'table' => [ + 'flow' => 'Потік', 'source' => 'Джерело', 'destinations' => 'Призначення', 'status' => 'Статус', @@ -46,6 +51,7 @@ 'source_placeholder' => 'Оберіть акаунт', 'no_accounts' => 'Спершу підключіть акаунт Instagram або Facebook. Лише вони можуть бути джерелом, бо тільки ці мережі дозволяють завантажити відео.', 'submit' => 'Створити', + 'connect' => 'Підключити акаунт', ], 'show' => [ @@ -63,6 +69,7 @@ 'description' => 'Кожне нове відео з джерела публікується в усіх обраних тут акаунтах.', 'hint' => 'Підпис адаптується під мережу лише тоді, коли перевищує її ліміт.', 'none_available' => 'У цьому робочому просторі поки немає інших підключених акаунтів.', + 'save' => 'Зберегти призначення', ], 'status_card' => [ diff --git a/lang/zh/repurposes.php b/lang/zh/repurposes.php index 3a3743fac..86ac82c19 100644 --- a/lang/zh/repurposes.php +++ b/lang/zh/repurposes.php @@ -7,12 +7,17 @@ 'description' => '把你在 TryPost 之外发布的视频,自动同步到其他平台。', 'new' => '新建 Repurpose', + 'flow' => [ + 'no_destinations' => '还没有目标', + ], + 'empty' => [ 'title' => '还没有设置 Repurpose', 'description' => '在下面选一个起点。TryPost 会盯着你选的账号,把每条新视频转发到你勾选的平台。', ], 'table' => [ + 'flow' => '流程', 'source' => '来源', 'destinations' => '目标', 'status' => '状态', @@ -46,6 +51,7 @@ 'source_placeholder' => '选择账号', 'no_accounts' => '请先连接 Instagram 或 Facebook 账号。只有它们能作为来源,因为只有这两个平台允许我们下载视频。', 'submit' => '创建', + 'connect' => '连接账号', ], 'show' => [ @@ -63,6 +69,7 @@ 'description' => '来源的每条新视频都会发布到你在这里选中的所有账号。', 'hint' => '只有当文案超出该平台上限时,才会按平台调整。', 'none_available' => '这个工作区还没有连接其他账号。', + 'save' => '保存目标', ], 'status_card' => [ diff --git a/resources/js/components/repurpose/CreateRepurposeDialog.vue b/resources/js/components/repurpose/CreateRepurposeDialog.vue index 5b0029f1e..94703eca4 100644 --- a/resources/js/components/repurpose/CreateRepurposeDialog.vue +++ b/resources/js/components/repurpose/CreateRepurposeDialog.vue @@ -1,8 +1,10 @@ + + diff --git a/resources/js/components/repurpose/RepurposeFlow.vue b/resources/js/components/repurpose/RepurposeFlow.vue new file mode 100644 index 000000000..0efcce61a --- /dev/null +++ b/resources/js/components/repurpose/RepurposeFlow.vue @@ -0,0 +1,40 @@ + + + diff --git a/resources/js/components/repurpose/RepurposeTemplateCard.vue b/resources/js/components/repurpose/RepurposeTemplateCard.vue index 2c36ee6fb..5909cd999 100644 --- a/resources/js/components/repurpose/RepurposeTemplateCard.vue +++ b/resources/js/components/repurpose/RepurposeTemplateCard.vue @@ -1,8 +1,6 @@ diff --git a/resources/js/pages/repurposes/Index.vue b/resources/js/pages/repurposes/Index.vue index 02c93b7fa..7985aaa18 100644 --- a/resources/js/pages/repurposes/Index.vue +++ b/resources/js/pages/repurposes/Index.vue @@ -1,13 +1,13 @@