feat: add PostCommentCreated broadcast event

This commit is contained in:
Paulo Castellano 2026-04-15 20:11:36 -03:00
parent e3d14256e0
commit 0f6ae9a4e6
95 changed files with 1896 additions and 2620 deletions

View file

@ -23,8 +23,9 @@ public static function execute(Workspace $workspace, User $user, array $data): P
$post = $workspace->posts()->create([
'user_id' => $user->id,
'content' => data_get($data, 'content', ''),
'media' => data_get($data, 'media', []),
'status' => PostStatus::Draft,
'synced' => true,
'scheduled_at' => $scheduledAt,
]);
@ -34,10 +35,12 @@ public static function execute(Workspace $workspace, User $user, array $data): P
$post->postPlatforms()->create([
'social_account_id' => $account->id,
'platform' => $account->platform->value,
'content' => '',
'platform_name' => $account->display_name,
'platform_username' => $account->username,
'platform_avatar' => $account->getRawOriginal('avatar_url'),
'content_type' => ContentType::defaultFor($account->platform),
'status' => PostPlatformStatus::Pending,
'enabled' => true,
'enabled' => false,
]);
}

View file

@ -32,8 +32,9 @@ public static function execute(Workspace $workspace, Post $post, array $data): a
$status = data_get($data, 'status', $post->status);
$post->update([
'content' => data_get($data, 'content', $post->content),
'media' => data_get($data, 'media', $post->media),
'status' => $status === PostStatus::Publishing->value ? PostStatus::Publishing : $status,
'synced' => data_get($data, 'synced', $post->synced),
'scheduled_at' => $scheduledAt,
]);
@ -45,10 +46,7 @@ public static function execute(Workspace $workspace, Post $post, array $data): a
$post->postPlatforms()->update(['enabled' => false]);
foreach (data_get($data, 'platforms', []) as $platformData) {
$updateData = [
'enabled' => true,
'content' => data_get($platformData, 'content'),
];
$updateData = ['enabled' => true];
if (data_get($platformData, 'content_type') !== null) {
$updateData['content_type'] = data_get($platformData, 'content_type');

View file

@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace App\DataTransferObjects;
class MediaItem
{
public function __construct(
public readonly string $id,
public readonly string $path,
public readonly string $url,
public readonly ?string $mime_type = null,
public readonly ?string $original_filename = null,
) {}
public function isVideo(): bool
{
if ($this->mime_type) {
return str_starts_with($this->mime_type, 'video/');
}
$extension = strtolower(pathinfo($this->path, PATHINFO_EXTENSION));
return in_array($extension, ['mp4', 'mov', 'avi', 'wmv', 'webm', 'mkv', 'm4v']);
}
public function isImage(): bool
{
if ($this->mime_type) {
return str_starts_with($this->mime_type, 'image/');
}
$extension = strtolower(pathinfo($this->path, PATHINFO_EXTENSION));
return in_array($extension, ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg', 'heic', 'heif']);
}
/**
* @param array<string, mixed> $data
*/
/**
* @param array<string, mixed> $data
*/
public static function fromArray(array $data): self
{
$path = data_get($data, 'path', '');
$mimeType = data_get($data, 'mime_type');
if (! $mimeType && $path) {
$extension = strtolower(pathinfo($path, PATHINFO_EXTENSION));
$mimeType = match ($extension) {
'jpg', 'jpeg' => 'image/jpeg',
'png' => 'image/png',
'gif' => 'image/gif',
'webp' => 'image/webp',
'mp4' => 'video/mp4',
'mov' => 'video/quicktime',
default => null,
};
}
return new self(
id: data_get($data, 'id', ''),
path: $path,
url: data_get($data, 'url', ''),
mime_type: $mimeType,
original_filename: data_get($data, 'original_filename'),
);
}
}

View file

@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Events;
use App\Models\PostComment;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
class PostCommentCreated implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets;
public function __construct(public PostComment $comment) {}
public function broadcastOn(): array
{
return [
new PrivateChannel('post.'.$this->comment->post_id),
];
}
public function broadcastWith(): array
{
return [
'comment' => [
'id' => $this->comment->id,
'user_id' => $this->comment->user_id,
'parent_id' => $this->comment->parent_id,
'body' => $this->comment->body,
'reactions' => $this->comment->reactions ?? [],
'created_at' => $this->comment->created_at->toISOString(),
'updated_at' => $this->comment->created_at->toISOString(),
'user' => [
'id' => $this->comment->user->id,
'name' => $this->comment->user->name,
'photo_url' => $this->comment->user->photo_url,
],
],
];
}
}

View file

@ -26,7 +26,7 @@ public function index(Request $request): Response|RedirectResponse
return redirect()->route('app.workspaces.create');
}
$this->authorize('manageAccounts', $workspace);
$this->authorize('createPost', $workspace);
$assets = $workspace->getMedia('assets')
->latest()
@ -41,7 +41,7 @@ public function store(Request $request): JsonResponse
{
$workspace = $request->user()->currentWorkspace;
$this->authorize('manageAccounts', $workspace);
$this->authorize('createPost', $workspace);
$request->validate([
'media' => ['required', 'file', 'max:1048576', 'mimetypes:image/jpeg,image/png,image/gif,image/webp,video/mp4'], // max 1GB in KB
@ -51,8 +51,10 @@ public function store(Request $request): JsonResponse
return response()->json([
'id' => $media->id,
'path' => $media->path,
'url' => $media->url,
'type' => $media->type->value,
'mime_type' => $media->mime_type,
'original_filename' => $media->original_filename,
'size' => $media->size,
'meta' => $media->meta,
@ -64,7 +66,7 @@ public function storeChunked(Request $request): JsonResponse
{
$workspace = $request->user()->currentWorkspace;
$this->authorize('manageAccounts', $workspace);
$this->authorize('createPost', $workspace);
$contentRange = $request->header('Content-Range');
@ -113,8 +115,10 @@ public function storeChunked(Request $request): JsonResponse
return response()->json([
'done' => true,
'id' => $media->id,
'path' => $media->path,
'url' => $media->url,
'type' => $media->type->value,
'mime_type' => $media->mime_type,
'original_filename' => $media->original_filename,
'size' => $media->size,
'meta' => $media->meta,
@ -126,7 +130,7 @@ public function storeFromUrl(Request $request, UnsplashService $unsplash): Redir
{
$workspace = $request->user()->currentWorkspace;
$this->authorize('manageAccounts', $workspace);
$this->authorize('createPost', $workspace);
$validated = $request->validate([
'url' => ['required', 'url', 'regex:/^https:\/\/(images\.unsplash\.com|media[0-9]*\.giphy\.com)\//'],
@ -190,7 +194,7 @@ public function destroy(Request $request, Media $media): RedirectResponse
{
$workspace = $request->user()->currentWorkspace;
$this->authorize('manageAccounts', $workspace);
$this->authorize('createPost', $workspace);
if ($media->mediable_type !== $workspace->getMorphClass() || $media->mediable_id !== $workspace->id) {
abort(SymfonyResponse::HTTP_FORBIDDEN);

View file

@ -14,7 +14,7 @@ public function search(Request $request, GiphyService $giphy): JsonResponse
{
$workspace = $request->user()->currentWorkspace;
$this->authorize('manageAccounts', $workspace);
$this->authorize('createPost', $workspace);
$request->validate([
'query' => ['required', 'string', 'max:255'],
@ -33,7 +33,7 @@ public function trending(Request $request, GiphyService $giphy): JsonResponse
{
$workspace = $request->user()->currentWorkspace;
$this->authorize('manageAccounts', $workspace);
$this->authorize('createPost', $workspace);
$photos = $giphy->trending(
page: $request->integer('page', 1),

View file

@ -44,7 +44,7 @@ public function index(Request $request, ?string $status = null): Response|Redire
}
if ($search = $request->input('search')) {
$query->whereHas('postPlatforms', fn ($q) => $q->where('content', 'ilike', "%{$search}%"));
$query->where('content', 'ilike', "%{$search}%");
}
return Inertia::render('posts/Index', [
@ -134,6 +134,7 @@ public function store(Request $request): RedirectResponse|\Symfony\Component\Htt
$post = CreatePost::execute($workspace, $request->user(), [
'date' => $request->input('date'),
'media' => $request->input('media', []),
]);
return Inertia::location(route('app.posts.edit', $post));
@ -153,7 +154,7 @@ public function edit(Request $request, Post $post): Response|RedirectResponse
abort(404);
}
$post->load(['postPlatforms.socialAccount', 'postPlatforms.media', 'labels']);
$post->load(['postPlatforms.socialAccount', 'labels']);
$socialAccounts = $workspace->socialAccounts()->active()->get();
$labels = $workspace->labels;
$hashtags = $workspace->hashtags;

View file

@ -14,7 +14,7 @@ public function search(Request $request, UnsplashService $unsplash): JsonRespons
{
$workspace = $request->user()->currentWorkspace;
$this->authorize('manageAccounts', $workspace);
$this->authorize('createPost', $workspace);
$request->validate([
'query' => ['required', 'string', 'max:255'],
@ -33,7 +33,7 @@ public function trending(Request $request, UnsplashService $unsplash): JsonRespo
{
$workspace = $request->user()->currentWorkspace;
$this->authorize('manageAccounts', $workspace);
$this->authorize('createPost', $workspace);
$photos = $unsplash->trending(
page: $request->integer('page', 1),

View file

@ -21,11 +21,11 @@ public function rules(): array
{
return [
'status' => ['required', 'string', Rule::in([Status::Draft->value, Status::Scheduled->value, Status::Publishing->value])],
'synced' => ['required', 'boolean'],
'platforms' => ['required', 'array'],
'content' => ['nullable', 'string', 'max:63206'],
'media' => ['sometimes', 'array'],
'platforms' => ['sometimes', 'array'],
'platforms.*.id' => ['required', 'uuid', Rule::exists('post_platforms', 'id')->where('post_id', $this->route('post') instanceof Post ? $this->route('post')->id : $this->route('post'))],
'platforms.*.content' => ['nullable', 'string', 'max:63206'],
'platforms.*.content_type' => ['required', 'string', Rule::in(array_column(ContentType::cases(), 'value'))],
'platforms.*.content_type' => ['sometimes', 'string', Rule::in(array_column(ContentType::cases(), 'value'))],
'platforms.*.meta' => ['nullable', 'array'],
'scheduled_at' => [
'nullable',

View file

@ -20,7 +20,11 @@ public function rules(): array
{
return [
'status' => ['required', 'string', Rule::in([Status::Draft->value, Status::Scheduled->value, Status::Publishing->value])],
'synced' => ['required', 'boolean'],
'content' => ['nullable', 'string', 'max:63206'],
'media' => ['sometimes', 'array'],
'media.*.id' => ['required', 'string'],
'media.*.path' => ['required', 'string', 'max:500'],
'media.*.url' => ['required', 'string', 'max:2048'],
'scheduled_at' => [
'sometimes',
'nullable',
@ -30,10 +34,9 @@ public function rules(): array
['after:now']
),
],
'platforms' => ['required', 'array'],
'platforms' => ['sometimes', 'array'],
'platforms.*.id' => ['required', 'uuid', Rule::exists('post_platforms', 'id')->where('post_id', $this->route('post')->id ?? $this->route('post'))],
'platforms.*.content' => ['nullable', 'string', 'max:63206'],
'platforms.*.content_type' => ['required', 'string', Rule::in(array_column(ContentType::cases(), 'value'))],
'platforms.*.content_type' => ['sometimes', 'string', Rule::in(array_column(ContentType::cases(), 'value'))],
'platforms.*.meta' => ['nullable', 'array'],
'platforms.*.meta.privacy_level' => ['sometimes', 'string', Rule::in(['PUBLIC_TO_EVERYONE', 'MUTUAL_FOLLOW_FRIENDS', 'FOLLOWER_OF_CREATOR', 'SELF_ONLY'])],
'platforms.*.meta.auto_add_music' => ['sometimes', 'boolean'],
@ -53,9 +56,6 @@ public function messages(): array
return [
'status.required' => 'The post status is required.',
'status.in' => 'Invalid post status.',
'synced.required' => 'The synced field is required.',
'platforms.required' => 'At least one platform is required.',
'platforms.*.content_type.required' => 'The content type is required for each platform.',
'platforms.*.content_type.in' => 'Invalid content type.',
'scheduled_at.after' => 'The scheduled date must be in the future.',
];

View file

@ -17,7 +17,6 @@ public function toArray(Request $request): array
return [
'id' => $this->id,
'platform' => $this->platform,
'content' => $this->content,
'content_type' => $this->content_type,
'status' => $this->status,
'enabled' => $this->enabled,

View file

@ -16,8 +16,9 @@ public function toArray(Request $request): array
{
return [
'id' => $this->id,
'content' => $this->content,
'media' => $this->media,
'status' => $this->status,
'synced' => $this->synced,
'scheduled_at' => $this->scheduled_at?->format('Y-m-d H:i:s'),
'published_at' => $this->published_at?->format('Y-m-d H:i:s'),
'platforms' => PostPlatformResource::collection($this->whenLoaded('postPlatforms')),

View file

@ -135,8 +135,8 @@ public function handle(): void
'category' => $e->category->value,
'platform_error_code' => $e->platformErrorCode,
'failed_at' => now()->toIso8601String(),
'content_length' => mb_strlen($this->postPlatform->content ?? ''),
'media_count' => $this->postPlatform->media->count(),
'content_length' => mb_strlen($this->postPlatform->post->content ?? ''),
'media_count' => count($this->postPlatform->post->media ?? []),
]);
break;
} catch (\Throwable $e) {
@ -148,8 +148,8 @@ public function handle(): void
$this->postPlatform->markAsFailed($e->getMessage(), [
'category' => 'unknown',
'failed_at' => now()->toIso8601String(),
'content_length' => mb_strlen($this->postPlatform->content ?? ''),
'media_count' => $this->postPlatform->media->count(),
'content_length' => mb_strlen($this->postPlatform->post->content ?? ''),
'media_count' => count($this->postPlatform->post->media ?? []),
]);
break;
}

View file

@ -20,7 +20,7 @@ class GetPostTool extends Tool
public function handle(Request $request): Response|ResponseFactory
{
$post = Post::where('workspace_id', $request->user()->current_workspace_id)
->with(['postPlatforms.socialAccount', 'postPlatforms.media', 'labels'])
->with(['postPlatforms.socialAccount', 'labels'])
->find(data_get($request->validate(['post_id' => ['required', 'string']]), 'post_id'));
if (! $post) {

View file

@ -7,7 +7,6 @@
use App\Enums\PostPlatform\ContentType;
use App\Enums\PostPlatform\Status;
use App\Enums\SocialAccount\Platform as SocialPlatform;
use App\Models\Traits\HasMedia;
use Database\Factories\PostPlatformFactory;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@ -17,14 +16,16 @@
class PostPlatform extends Model
{
/** @use HasFactory<PostPlatformFactory> */
use HasFactory, HasMedia, HasUuids;
use HasFactory, HasUuids;
protected $fillable = [
'post_id',
'social_account_id',
'enabled',
'platform',
'content',
'platform_name',
'platform_username',
'platform_avatar',
'content_type',
'status',
'platform_post_id',
@ -58,6 +59,30 @@ public function socialAccount(): BelongsTo
return $this->belongsTo(SocialAccount::class);
}
/**
* Get display name, falling back to snapshot if account was deleted.
*/
public function getDisplayNameAttribute(): string
{
return $this->socialAccount?->display_name ?? $this->platform_name ?? $this->platform->label();
}
/**
* Get username, falling back to snapshot if account was deleted.
*/
public function getDisplayUsernameAttribute(): ?string
{
return $this->socialAccount?->username ?? $this->platform_username;
}
/**
* Get avatar URL, falling back to snapshot if account was deleted.
*/
public function getDisplayAvatarAttribute(): ?string
{
return $this->socialAccount?->avatar_url ?? $this->platform_avatar;
}
public function markAsPublishing(): void
{
$this->update(['status' => Status::Publishing]);

View file

@ -5,7 +5,6 @@
namespace App\Models\Traits;
use App\Models\Media;
use App\Models\PostPlatform;
use App\Models\User;
use App\Models\Workspace;
use Illuminate\Database\Eloquent\Relations\MorphMany;
@ -28,9 +27,6 @@ trait HasMedia
User::class => [
'avatar' => 'single',
],
PostPlatform::class => [
'default' => 'multiple',
],
];
public function media(): MorphMany

View file

@ -23,7 +23,7 @@ public function publish(PostPlatform $postPlatform): array
{
$this->validateContentLength($postPlatform);
$content = $postPlatform->content ? app(ContentSanitizer::class)->sanitize($postPlatform->content, $postPlatform->platform) : null;
$content = $postPlatform->post->content ? app(ContentSanitizer::class)->sanitize($postPlatform->post->content, $postPlatform->platform) : null;
$account = $postPlatform->socialAccount;
$service = $account->meta['service'] ?? 'https://bsky.social';
@ -34,7 +34,7 @@ public function publish(PostPlatform $postPlatform): array
$account->refresh();
}
$medias = $postPlatform->media;
$medias = $postPlatform->post->mediaItems;
$embed = null;
// Upload images if present (max 4)

View file

@ -15,7 +15,7 @@ trait HasSocialHttpClient
protected function validateContentLength(PostPlatform $postPlatform): void
{
$maxLength = $postPlatform->platform->maxContentLength();
$contentLength = mb_strlen($postPlatform->content ?? '');
$contentLength = mb_strlen($postPlatform->post->content ?? '');
if ($contentLength > $maxLength) {
throw new \Exception(

View file

@ -21,13 +21,13 @@ public function publish(PostPlatform $postPlatform): array
{
$this->validateContentLength($postPlatform);
$content = $postPlatform->content ? app(ContentSanitizer::class)->sanitize($postPlatform->content, $postPlatform->platform) : null;
$content = $postPlatform->post->content ? app(ContentSanitizer::class)->sanitize($postPlatform->post->content, $postPlatform->platform) : null;
$account = $postPlatform->socialAccount;
$pageId = $account->platform_user_id;
$accessToken = $account->access_token;
$media = $postPlatform->media;
$media = $postPlatform->post->mediaItems;
$contentType = $postPlatform->content_type;
return match ($contentType) {

View file

@ -36,9 +36,9 @@ public function publish(PostPlatform $postPlatform): array
$instagramId = $account->platform_user_id;
$accessToken = $account->access_token;
$content = $postPlatform->content ? app(ContentSanitizer::class)->sanitize($postPlatform->content, $postPlatform->platform) : null;
$content = $postPlatform->post->content ? app(ContentSanitizer::class)->sanitize($postPlatform->post->content, $postPlatform->platform) : null;
$media = $postPlatform->media;
$media = $postPlatform->post->mediaItems;
if ($media->isEmpty()) {
throw new \Exception('Instagram requires at least one image or video.');

View file

@ -35,7 +35,7 @@ public function publish(PostPlatform $postPlatform): array
{
$this->validateContentLength($postPlatform);
$content = $postPlatform->content ? app(ContentSanitizer::class)->sanitize($postPlatform->content, $postPlatform->platform) : null;
$content = $postPlatform->post->content ? app(ContentSanitizer::class)->sanitize($postPlatform->post->content, $postPlatform->platform) : null;
$this->account = $postPlatform->socialAccount;
$this->hasRetried = false;
@ -58,8 +58,8 @@ public function publish(PostPlatform $postPlatform): array
try {
return match ($contentType) {
ContentType::LinkedInPageCarousel => $this->publishCarousel($organizationUrn, $content, $postPlatform->media, $this->account),
ContentType::LinkedInPagePost => $this->publishPost($organizationUrn, $content, $postPlatform->media, $this->account),
ContentType::LinkedInPageCarousel => $this->publishCarousel($organizationUrn, $content, $postPlatform->post->mediaItems, $this->account),
ContentType::LinkedInPagePost => $this->publishPost($organizationUrn, $content, $postPlatform->post->mediaItems, $this->account),
default => throw new \Exception("Unsupported LinkedIn Page content type: {$contentType?->value}"),
};
} catch (TokenExpiredException $e) {
@ -85,8 +85,8 @@ private function retryWithRefresh(PostPlatform $postPlatform, ?string $content,
$contentType = $postPlatform->content_type;
return match ($contentType) {
ContentType::LinkedInPageCarousel => $this->publishCarousel($organizationUrn, $content, $postPlatform->media, $this->account),
ContentType::LinkedInPagePost => $this->publishPost($organizationUrn, $content, $postPlatform->media, $this->account),
ContentType::LinkedInPageCarousel => $this->publishCarousel($organizationUrn, $content, $postPlatform->post->mediaItems, $this->account),
ContentType::LinkedInPagePost => $this->publishPost($organizationUrn, $content, $postPlatform->post->mediaItems, $this->account),
default => throw new \Exception("Unsupported LinkedIn Page content type: {$contentType?->value}"),
};
} catch (\Throwable $e) {

View file

@ -35,7 +35,7 @@ public function publish(PostPlatform $postPlatform): array
{
$this->validateContentLength($postPlatform);
$content = $postPlatform->content ? app(ContentSanitizer::class)->sanitize($postPlatform->content, $postPlatform->platform) : null;
$content = $postPlatform->post->content ? app(ContentSanitizer::class)->sanitize($postPlatform->post->content, $postPlatform->platform) : null;
$this->account = $postPlatform->socialAccount;
$this->hasRetried = false;
@ -52,8 +52,8 @@ public function publish(PostPlatform $postPlatform): array
try {
return match ($contentType) {
ContentType::LinkedInCarousel => $this->publishCarousel($personUrn, $content, $postPlatform->media),
ContentType::LinkedInPost => $this->publishPost($personUrn, $content, $postPlatform->media),
ContentType::LinkedInCarousel => $this->publishCarousel($personUrn, $content, $postPlatform->post->mediaItems),
ContentType::LinkedInPost => $this->publishPost($personUrn, $content, $postPlatform->post->mediaItems),
default => throw new \Exception("Unsupported LinkedIn content type: {$contentType?->value}"),
};
} catch (TokenExpiredException $e) {
@ -78,8 +78,8 @@ private function retryWithRefresh(PostPlatform $postPlatform, ?string $content,
$contentType = $postPlatform->content_type;
return match ($contentType) {
ContentType::LinkedInCarousel => $this->publishCarousel($personUrn, $content, $postPlatform->media),
ContentType::LinkedInPost => $this->publishPost($personUrn, $content, $postPlatform->media),
ContentType::LinkedInCarousel => $this->publishCarousel($personUrn, $content, $postPlatform->post->mediaItems),
ContentType::LinkedInPost => $this->publishPost($personUrn, $content, $postPlatform->post->mediaItems),
default => throw new \Exception("Unsupported LinkedIn content type: {$contentType?->value}"),
};
} catch (\Throwable $e) {

View file

@ -22,12 +22,12 @@ public function publish(PostPlatform $postPlatform): array
{
$this->validateContentLength($postPlatform);
$content = $postPlatform->content ? app(ContentSanitizer::class)->sanitize($postPlatform->content, $postPlatform->platform) : null;
$content = $postPlatform->post->content ? app(ContentSanitizer::class)->sanitize($postPlatform->post->content, $postPlatform->platform) : null;
$account = $postPlatform->socialAccount;
$instance = $account->meta['instance'] ?? 'https://mastodon.social';
$medias = $postPlatform->media;
$medias = $postPlatform->post->mediaItems;
$mediaIds = [];
// Upload media first (max 4)

View file

@ -32,7 +32,7 @@ public function publish(PostPlatform $postPlatform): array
$account->refresh();
}
$content = $postPlatform->content ? app(ContentSanitizer::class)->sanitize($postPlatform->content, $postPlatform->platform) : null;
$content = $postPlatform->post->content ? app(ContentSanitizer::class)->sanitize($postPlatform->post->content, $postPlatform->platform) : null;
return match ($postPlatform->content_type) {
ContentType::PinterestPin => $this->publishImagePin($postPlatform, $content),
@ -45,7 +45,7 @@ public function publish(PostPlatform $postPlatform): array
private function publishImagePin(PostPlatform $postPlatform, ?string $content): array
{
$account = $postPlatform->socialAccount;
$media = $postPlatform->media->first();
$media = $postPlatform->post->mediaItems->first();
if (! $media) {
throw new \Exception('Pinterest requires at least one image');
@ -127,7 +127,7 @@ private function publishImagePin(PostPlatform $postPlatform, ?string $content):
private function publishVideoPin(PostPlatform $postPlatform, ?string $content): array
{
$account = $postPlatform->socialAccount;
$media = $postPlatform->media->first();
$media = $postPlatform->post->mediaItems->first();
if (! $media) {
throw new \Exception('Pinterest requires a video');
@ -261,7 +261,7 @@ private function publishVideoPin(PostPlatform $postPlatform, ?string $content):
private function publishCarousel(PostPlatform $postPlatform, ?string $content): array
{
$account = $postPlatform->socialAccount;
$medias = $postPlatform->media;
$medias = $postPlatform->post->mediaItems;
if ($medias->count() < 2 || $medias->count() > 5) {
throw new \Exception('Pinterest carousel requires 2-5 images');

View file

@ -22,7 +22,7 @@ public function publish(PostPlatform $postPlatform): array
{
$this->validateContentLength($postPlatform);
$content = $postPlatform->content ? app(ContentSanitizer::class)->sanitize($postPlatform->content, $postPlatform->platform) : null;
$content = $postPlatform->post->content ? app(ContentSanitizer::class)->sanitize($postPlatform->post->content, $postPlatform->platform) : null;
$account = $postPlatform->socialAccount;
@ -34,7 +34,7 @@ public function publish(PostPlatform $postPlatform): array
$userId = $account->platform_user_id;
$accessToken = $account->access_token;
$media = $postPlatform->media;
$media = $postPlatform->post->mediaItems;
// Text only post
if ($media->isEmpty()) {

View file

@ -26,7 +26,7 @@ public function publish(PostPlatform $postPlatform): array
{
$this->validateContentLength($postPlatform);
$content = $postPlatform->content ? app(ContentSanitizer::class)->sanitize($postPlatform->content, $postPlatform->platform) : null;
$content = $postPlatform->post->content ? app(ContentSanitizer::class)->sanitize($postPlatform->post->content, $postPlatform->platform) : null;
$account = $postPlatform->socialAccount;
@ -37,7 +37,7 @@ public function publish(PostPlatform $postPlatform): array
$this->accessToken = $account->access_token;
$media = $postPlatform->media;
$media = $postPlatform->post->mediaItems;
if ($media->isEmpty()) {
throw new \Exception('TikTok requires media (video or photos) to publish.');

View file

@ -28,7 +28,7 @@ public function publish(PostPlatform $postPlatform): array
{
$this->validateContentLength($postPlatform);
$content = $postPlatform->content ? app(ContentSanitizer::class)->sanitize($postPlatform->content, $postPlatform->platform) : null;
$content = $postPlatform->post->content ? app(ContentSanitizer::class)->sanitize($postPlatform->post->content, $postPlatform->platform) : null;
$account = $postPlatform->socialAccount;
@ -47,7 +47,7 @@ public function publish(PostPlatform $postPlatform): array
}
$mediaIds = [];
$media = $postPlatform->media;
$media = $postPlatform->post->mediaItems;
if ($media->isNotEmpty()) {
foreach ($media as $mediaItem) {

View file

@ -28,7 +28,7 @@ public function publish(PostPlatform $postPlatform): array
{
$this->validateContentLength($postPlatform);
$content = $postPlatform->content ? app(ContentSanitizer::class)->sanitize($postPlatform->content, $postPlatform->platform) : null;
$content = $postPlatform->post->content ? app(ContentSanitizer::class)->sanitize($postPlatform->post->content, $postPlatform->platform) : null;
$account = $postPlatform->socialAccount;
@ -37,7 +37,7 @@ public function publish(PostPlatform $postPlatform): array
$account->refresh();
}
$media = $postPlatform->media;
$media = $postPlatform->post->mediaItems;
if ($media->isEmpty()) {
throw new \Exception('YouTube Shorts requires a video to publish.');

View file

@ -37,7 +37,7 @@
"google/apiclient": "^2.19",
"inertiajs/inertia-laravel": "^3.0",
"intervention/image": "^4.0",
"laravel/ai": "^0.4.2",
"laravel/ai": "^0.5.1",
"laravel/boost": "^2.0",
"laravel/cashier": "^16.2",
"laravel/framework": "^13.0",

View file

@ -25,8 +25,9 @@ public function definition(): array
return [
'workspace_id' => Workspace::factory(),
'user_id' => User::factory(),
'content' => '',
'media' => [],
'status' => PostStatus::Draft,
'synced' => true,
];
}

View file

@ -29,7 +29,6 @@ public function definition(): array
'social_account_id' => SocialAccount::factory(),
'enabled' => true,
'platform' => Platform::LinkedIn,
'content' => $this->faker->paragraph(),
'content_type' => ContentType::LinkedInPost,
'status' => Status::Pending,
'meta' => [],

View file

@ -16,12 +16,12 @@ public function up(): void
Schema::create('workspaces', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->foreignUuid('account_id')->constrained()->cascadeOnDelete();
$table->uuid('user_id');
$table->uuid('user_id')->nullable();
$table->string('name');
$table->string('timezone');
$table->timestamps();
$table->foreign('user_id')->references('id')->on('users')->cascadeOnDelete();
$table->foreign('user_id')->references('id')->on('users')->nullOnDelete();
});
}

View file

@ -16,15 +16,16 @@ public function up(): void
Schema::create('posts', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->uuid('workspace_id');
$table->uuid('user_id');
$table->uuid('user_id')->nullable();
$table->text('content')->nullable();
$table->json('media')->default('[]');
$table->string('status')->default('draft');
$table->boolean('synced');
$table->timestamp('scheduled_at')->nullable();
$table->timestamp('published_at')->nullable();
$table->timestamps();
$table->foreign('workspace_id')->references('id')->on('workspaces')->cascadeOnDelete();
$table->foreign('user_id')->references('id')->on('users')->cascadeOnDelete();
$table->foreign('user_id')->references('id')->on('users')->nullOnDelete();
$table->index(['workspace_id', 'status', 'scheduled_at']);
});

View file

@ -16,21 +16,24 @@ public function up(): void
Schema::create('post_platforms', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->uuid('post_id');
$table->uuid('social_account_id');
$table->uuid('social_account_id')->nullable();
$table->string('platform');
$table->text('content')->nullable();
$table->string('platform_name')->nullable();
$table->string('platform_username')->nullable();
$table->string('platform_avatar')->nullable();
$table->string('content_type');
$table->string('status')->default('pending');
$table->string('platform_post_id')->nullable();
$table->boolean('enabled');
$table->boolean('enabled')->default(false);
$table->string('platform_url')->nullable();
$table->text('error_message')->nullable();
$table->json('error_context')->nullable();
$table->timestamp('published_at')->nullable();
$table->json('meta')->nullable();
$table->timestamps();
$table->foreign('post_id')->references('id')->on('posts')->cascadeOnDelete();
$table->foreign('social_account_id')->references('id')->on('social_accounts')->cascadeOnDelete();
$table->foreign('social_account_id')->references('id')->on('social_accounts')->nullOnDelete();
});
}

View file

@ -12,7 +12,7 @@ public function up(): void
{
Schema::create('notifications', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->uuid('user_id');
$table->uuid('user_id')->nullable();
$table->uuid('workspace_id');
$table->string('type');
$table->string('channel');
@ -23,7 +23,7 @@ public function up(): void
$table->timestamp('archived_at')->nullable();
$table->timestamps();
$table->foreign('user_id')->references('id')->on('users')->cascadeOnDelete();
$table->foreign('user_id')->references('id')->on('users')->nullOnDelete();
$table->foreign('workspace_id')->references('id')->on('workspaces')->cascadeOnDelete();
$table->index(['user_id', 'workspace_id', 'read_at']);
});

View file

@ -1,22 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('post_platforms', function (Blueprint $table) {
$table->json('error_context')->nullable()->after('error_message');
});
}
public function down(): void
{
Schema::table('post_platforms', function (Blueprint $table) {
$table->dropColumn('error_context');
});
}
};

View file

@ -22,6 +22,7 @@
'save_to_assets' => 'Save to Assets',
'saved' => 'Saved to your assets!',
'create_post' => 'Create post',
'delete' => [
'title' => 'Delete asset',

View file

@ -78,27 +78,39 @@
'edit' => [
'title' => 'Edit Post',
'view_title' => 'View Post',
'manage_platforms' => 'Manage platforms',
'sync' => 'Sync',
'labels' => 'Labels',
'hashtags' => 'Hashtags',
'no_labels' => 'No labels created yet',
'schedule' => 'Schedule',
'publish' => 'Publish',
'pick_time' => 'Pick time',
'post_now' => 'Post now',
'delete' => 'Delete',
'settings' => 'Settings',
'schedule_for' => 'Schedule for',
'schedule_date' => 'Schedule date',
'saving' => 'Saving...',
'saved' => 'Saved',
'scheduled_at' => 'Scheduled:',
'published_at' => 'Published:',
'media' => 'Media',
'caption' => 'Caption',
'no_caption' => 'No caption',
'no_content' => 'No content',
'caption_placeholder' => 'Write your caption...',
'drag_drop' => 'Drag & drop or click to upload',
'publish_to' => 'Publish to',
'organize' => 'Organize',
'hashtags' => 'Hashtags',
'view_on_platform' => 'View on platform',
'platform_status' => 'Platform status',
'empty_state' => [
'title' => 'No platforms selected',
'description' => 'Select at least one platform to create your post',
'tabs' => [
'preview' => 'Preview',
'schedule' => 'Schedule',
'comments' => 'Comments',
'comments_empty' => 'No comments yet.',
'writing_assistant' => 'AI Assistant',
'writing_assistant_empty' => 'AI writing assistant coming soon.',
],
'status' => [
'published' => 'Published',
'publishing' => 'Publishing...',
'failed' => 'Failed',
],
'delete_modal' => [

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -206,182 +206,3 @@ @layer base {
@apply bg-background text-foreground;
}
}
/* :root {
--background: #f7f9f3;
--foreground: #000000;
--card: #ffffff;
--card-foreground: #000000;
--popover: #ffffff;
--popover-foreground: #000000;
--primary: #4f46e5;
--primary-foreground: #ffffff;
--secondary: #14b8a6;
--secondary-foreground: #ffffff;
--muted: #f0f0f0;
--muted-foreground: #333333;
--accent: #f59e0b;
--accent-foreground: #000000;
--destructive: #ef4444;
--destructive-foreground: #ffffff;
--border: #000000;
--input: #737373;
--ring: #a5b4fc;
--chart-1: #4f46e5;
--chart-2: #14b8a6;
--chart-3: #f59e0b;
--chart-4: #ec4899;
--chart-5: #22c55e;
--sidebar: #f7f9f3;
--sidebar-foreground: #000000;
--sidebar-primary: #4f46e5;
--sidebar-primary-foreground: #ffffff;
--sidebar-accent: #f59e0b;
--sidebar-accent-foreground: #000000;
--sidebar-border: #000000;
--sidebar-ring: #a5b4fc;
--font-sans: DM Sans, sans-serif;
--font-serif: DM Sans, sans-serif;
--font-mono: Space Mono, monospace;
--radius: 1rem;
--shadow-x: 0px;
--shadow-y: 0px;
--shadow-blur: 0px;
--shadow-spread: 0px;
--shadow-opacity: 0.05;
--shadow-color: #1a1a1a;
--shadow-2xs: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.03);
--shadow-xs: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.03);
--shadow-sm: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.05), 0px 1px 2px -1px hsl(0 0% 10.1961% / 0.05);
--shadow: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.05), 0px 1px 2px -1px hsl(0 0% 10.1961% / 0.05);
--shadow-md: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.05), 0px 2px 4px -1px hsl(0 0% 10.1961% / 0.05);
--shadow-lg: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.05), 0px 4px 6px -1px hsl(0 0% 10.1961% / 0.05);
--shadow-xl: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.05), 0px 8px 10px -1px hsl(0 0% 10.1961% / 0.05);
--shadow-2xl: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.13);
--tracking-normal: normal;
--spacing: 0.25rem;
}
.dark {
--background: #000000;
--foreground: #ffffff;
--card: #1a212b;
--card-foreground: #ffffff;
--popover: #1a212b;
--popover-foreground: #ffffff;
--primary: #818cf8;
--primary-foreground: #000000;
--secondary: #2dd4bf;
--secondary-foreground: #000000;
--muted: #333333;
--muted-foreground: #cccccc;
--accent: #fcd34d;
--accent-foreground: #000000;
--destructive: #f87171;
--destructive-foreground: #000000;
--border: #545454;
--input: #ffffff;
--ring: #818cf8;
--chart-1: #818cf8;
--chart-2: #2dd4bf;
--chart-3: #fcd34d;
--chart-4: #f472b6;
--chart-5: #4ade80;
--sidebar: #000000;
--sidebar-foreground: #ffffff;
--sidebar-primary: #818cf8;
--sidebar-primary-foreground: #000000;
--sidebar-accent: #fcd34d;
--sidebar-accent-foreground: #000000;
--sidebar-border: #ffffff;
--sidebar-ring: #818cf8;
--font-sans: DM Sans, sans-serif;
--font-serif: DM Sans, sans-serif;
--font-mono: Space Mono, monospace;
--radius: 1rem;
--shadow-x: 0px;
--shadow-y: 0px;
--shadow-blur: 0px;
--shadow-spread: 0px;
--shadow-opacity: 0.05;
--shadow-color: #1a1a1a;
--shadow-2xs: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.03);
--shadow-xs: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.03);
--shadow-sm: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.05), 0px 1px 2px -1px hsl(0 0% 10.1961% / 0.05);
--shadow: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.05), 0px 1px 2px -1px hsl(0 0% 10.1961% / 0.05);
--shadow-md: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.05), 0px 2px 4px -1px hsl(0 0% 10.1961% / 0.05);
--shadow-lg: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.05), 0px 4px 6px -1px hsl(0 0% 10.1961% / 0.05);
--shadow-xl: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.05), 0px 8px 10px -1px hsl(0 0% 10.1961% / 0.05);
--shadow-2xl: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.13);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--font-sans: var(--font-sans);
--font-mono: var(--font-mono);
--font-serif: var(--font-serif);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--shadow-2xs: var(--shadow-2xs);
--shadow-xs: var(--shadow-xs);
--shadow-sm: var(--shadow-sm);
--shadow: var(--shadow);
--shadow-md: var(--shadow-md);
--shadow-lg: var(--shadow-lg);
--shadow-xl: var(--shadow-xl);
--shadow-2xl: var(--shadow-2xl);
--tracking-tighter: calc(var(--tracking-normal) - 0.05em);
--tracking-tight: calc(var(--tracking-normal) - 0.025em);
--tracking-normal: var(--tracking-normal);
--tracking-wide: calc(var(--tracking-normal) + 0.025em);
--tracking-wider: calc(var(--tracking-normal) + 0.05em);
--tracking-widest: calc(var(--tracking-normal) + 0.1em);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
letter-spacing: var(--tracking-normal);
}
} */

View file

@ -1,34 +1,22 @@
<script setup lang="ts">
import Breadcrumbs from '@/components/Breadcrumbs.vue';
import { SidebarTrigger } from '@/components/ui/sidebar';
import type { BreadcrumbItem } from '@/types';
withDefaults(
defineProps<{
breadcrumbs?: BreadcrumbItem[];
showSidebarTrigger?: boolean;
title?: string;
}>(),
{
breadcrumbs: () => [],
showSidebarTrigger: true,
title: '',
},
);
</script>
<template>
<header
class="flex h-16 shrink-0 items-center justify-between gap-2 border-b border-border px-6 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12 md:px-4">
class="flex h-14 shrink-0 items-center justify-between gap-2 border-b border-border px-4">
<div class="flex items-center gap-2">
<SidebarTrigger v-if="showSidebarTrigger" class="-ml-1" />
<slot name="left">
<template v-if="breadcrumbs && breadcrumbs.length > 0">
<Breadcrumbs :breadcrumbs="breadcrumbs" />
</template>
<h2 v-if="title" class="text-lg font-semibold">{{ title }}</h2>
</slot>
</div>
<div v-if="$slots.center" class="flex items-center">
<slot name="center" />
</div>
<div class="flex items-center gap-2">
<slot name="right" />
</div>

View file

@ -1,43 +0,0 @@
<script setup lang="ts">
import { Link } from '@inertiajs/vue3';
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from '@/components/ui/breadcrumb';
interface BreadcrumbItemType {
title: string;
href?: string;
}
defineProps<{
breadcrumbs: BreadcrumbItemType[];
}>();
</script>
<template>
<Breadcrumb>
<BreadcrumbList>
<template v-for="(item, index) in breadcrumbs" :key="index">
<BreadcrumbItem>
<template v-if="index === breadcrumbs.length - 1">
<BreadcrumbPage>{{ item.title }}</BreadcrumbPage>
</template>
<template v-else>
<BreadcrumbLink as-child>
<Link :href="item.href ?? '#'">{{
item.title
}}</Link>
</BreadcrumbLink>
</template>
</BreadcrumbItem>
<BreadcrumbSeparator v-if="index !== breadcrumbs.length - 1" />
</template>
</BreadcrumbList>
</Breadcrumb>
</template>

View file

@ -0,0 +1,8 @@
<script setup lang="ts">
</script>
<template>
<div class="flex flex-col items-center justify-center py-12 text-center">
<p class="text-sm text-muted-foreground">{{ $t('posts.edit.tabs.comments_empty') }}</p>
</div>
</template>

View file

@ -0,0 +1,37 @@
<script setup lang="ts">
import PhoneMockup from '@/components/PhoneMockup.vue';
import { PlatformPreview } from '@/components/posts/previews';
interface MediaItem {
id: string;
path: string;
url: string;
type?: string;
mime_type?: string;
original_filename?: string;
}
interface SocialAccount {
id: string;
platform: string;
display_name: string;
username: string;
avatar_url: string | null;
}
defineProps<{
platform: string;
content: string;
media: MediaItem[];
socialAccount: SocialAccount | null;
contentType: string | null;
}>();
</script>
<template>
<div class="flex justify-center py-8 px-4 bg-muted/30 min-h-full">
<PhoneMockup>
<PlatformPreview :platform="platform" :content="content" :media="media" :social-account="socialAccount" :content-type="contentType" />
</PhoneMockup>
</div>
</template>

View file

@ -0,0 +1,120 @@
<script setup lang="ts">
import {
IconBrandBluesky,
IconBrandFacebook,
IconBrandInstagram,
IconBrandLinkedin,
IconBrandMastodon,
IconBrandPinterest,
IconBrandThreads,
IconBrandTiktok,
IconBrandX,
IconBrandYoutube,
IconCircleCheck,
IconExternalLink,
IconLoader2,
} from '@tabler/icons-vue';
import { type Component } from 'vue';
import { Avatar } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
interface SocialAccount {
id: string;
platform: string;
display_name: string;
username: string;
avatar_url: string | null;
}
interface PostPlatform {
id: string;
social_account_id: string | null;
enabled: boolean;
platform: string;
platform_name: string | null;
platform_username: string | null;
platform_avatar: string | null;
content_type: string | null;
status: string;
platform_url: string | null;
error_message: string | null;
published_at: string | null;
social_account: SocialAccount | null;
meta?: Record<string, any>;
}
const props = defineProps<{
postPlatforms: PostPlatform[];
selectedPlatformIds: string[];
}>();
const emit = defineEmits<{
togglePlatform: [platformId: string];
}>();
const platformIcons: Record<string, Component> = {
linkedin: IconBrandLinkedin,
'linkedin-page': IconBrandLinkedin,
x: IconBrandX,
tiktok: IconBrandTiktok,
youtube: IconBrandYoutube,
facebook: IconBrandFacebook,
instagram: IconBrandInstagram,
'instagram-facebook': IconBrandInstagram,
threads: IconBrandThreads,
pinterest: IconBrandPinterest,
bluesky: IconBrandBluesky,
mastodon: IconBrandMastodon,
};
const getPlatformIcon = (platform: string): Component => platformIcons[platform] || IconBrandX;
const getPlatformDisplayName = (pp: PostPlatform): string =>
pp.social_account?.display_name ?? pp.platform_name ?? pp.platform;
const getPlatformAvatar = (pp: PostPlatform): string | null =>
pp.social_account?.avatar_url ?? pp.platform_avatar ?? null;
</script>
<template>
<div class="space-y-5">
<div>
<p class="mb-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">{{ $t('posts.edit.publish_to') }}</p>
<div class="flex flex-wrap gap-2">
<TooltipProvider v-for="pp in postPlatforms" :key="pp.id">
<Tooltip>
<TooltipTrigger as-child>
<button type="button" class="relative flex items-center gap-2 rounded-lg border px-3 py-2 text-sm transition-all" :class="selectedPlatformIds.includes(pp.id) ? 'border-primary bg-primary/5 ring-1 ring-primary' : 'border-border opacity-50 hover:opacity-80'" @click="emit('togglePlatform', pp.id)">
<Avatar :src="getPlatformAvatar(pp)" :name="getPlatformDisplayName(pp)" class="h-6 w-6 shrink-0 rounded-full" />
<component :is="getPlatformIcon(pp.platform)" class="h-3.5 w-3.5 text-muted-foreground" />
<Badge v-if="pp.status === 'published'" variant="default" class="absolute -top-1.5 -right-1.5 h-4 w-4 p-0"><IconCircleCheck class="h-2.5 w-2.5" /></Badge>
<Badge v-else-if="pp.status === 'failed'" variant="destructive" class="absolute -top-1.5 -right-1.5 h-4 w-4 p-0 text-[9px]">!</Badge>
</button>
</TooltipTrigger>
<TooltipContent>{{ getPlatformDisplayName(pp) }}</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
<div v-if="postPlatforms.some(pp => pp.status !== 'pending')">
<p class="mb-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">{{ $t('posts.edit.platform_status') }}</p>
<div class="space-y-2">
<div v-for="pp in postPlatforms.filter(p => p.enabled)" :key="pp.id" class="flex items-center justify-between rounded-lg border p-3">
<div class="flex items-center gap-2">
<component :is="getPlatformIcon(pp.platform)" class="h-4 w-4 text-muted-foreground" />
<span class="text-sm">{{ getPlatformDisplayName(pp) }}</span>
</div>
<div class="flex items-center gap-2">
<Badge v-if="pp.status === 'published'" variant="default">{{ $t('posts.edit.status.published') }}</Badge>
<Badge v-else-if="pp.status === 'publishing'" variant="secondary"><IconLoader2 class="mr-1 h-3 w-3 animate-spin" />{{ $t('posts.edit.status.publishing') }}</Badge>
<Badge v-else-if="pp.status === 'failed'" variant="destructive">{{ $t('posts.edit.status.failed') }}</Badge>
<a v-if="pp.platform_url" :href="pp.platform_url" target="_blank" rel="noopener noreferrer"><IconExternalLink class="h-4 w-4 text-muted-foreground hover:text-foreground" /></a>
</div>
</div>
</div>
</div>
</div>
</template>

View file

@ -0,0 +1,8 @@
<script setup lang="ts">
</script>
<template>
<div class="flex flex-col items-center justify-center py-12 text-center">
<p class="text-sm text-muted-foreground">{{ $t('posts.edit.tabs.writing_assistant_empty') }}</p>
</div>
</template>

View file

@ -10,7 +10,7 @@ const props = defineProps<{
<template>
<main data-slot="sidebar-inset" :class="cn(
'bg-card text-card-foreground relative flex w-full flex-1 flex-col overflow-y-auto',
'md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-lg md:peer-data-[variant=inset]:border md:peer-data-[variant=inset]:border-border md:peer-data-[variant=inset]:shadow-xs',
'md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-lg md:peer-data-[variant=inset]:border md:peer-data-[variant=inset]:border-border md:peer-data-[variant=inset]:shadow-xs md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2',
props.class,
)">
<slot />

View file

@ -1,22 +1,16 @@
import axios from 'axios';
import { ref, type Ref } from 'vue';
import {
store as storeMedia,
storeChunked as storeMediaChunked,
destroy as destroyMedia,
duplicate as duplicateMedia,
reorder as reorderMedia,
} from '@/actions/App/Http/Controllers/App/MediaController';
import { store as storeAsset, storeChunked as storeAssetChunked } from '@/routes/app/assets';
import { getMediaRulesForContentType } from '@/composables/useMediaRules';
import { uploadChunked, shouldUseChunkedUpload } from '@/utils/chunkedUpload';
export interface MediaItem {
id: string;
group_id: string | null;
path: string;
url: string;
type: string;
original_filename: string;
type?: string;
mime_type?: string;
original_filename?: string;
}
interface PostPlatform {
@ -33,18 +27,16 @@ interface UseMediaManagerOptions {
postPlatforms: Ref<PostPlatform[]>;
}
export function useMediaManager(options: UseMediaManagerOptions) {
export const useMediaManager = (options: UseMediaManagerOptions) => {
const { synced, selectedPlatformIds, platformContentTypes, postPlatforms } = options;
// State
const platformMedia = ref<Record<string, MediaItem[]>>(
Object.fromEntries(postPlatforms.value.map(pp => [pp.id, pp.media || []]))
Object.fromEntries(postPlatforms.value.map((pp) => [pp.id, pp.media || []])),
);
const isUploading = ref<Record<string, boolean>>({});
// Helper: check if platform's content type only allows single media
const isSingleMediaContentType = (platformId: string): boolean => {
const platform = postPlatforms.value.find(pp => pp.id === platformId);
const platform = postPlatforms.value.find((pp) => pp.id === platformId);
if (!platform) return false;
const contentType = platformContentTypes.value[platformId] || platform.content_type;
@ -54,234 +46,136 @@ export function useMediaManager(options: UseMediaManagerOptions) {
return rules.maxFiles === 1;
};
// Helper: clear all media from a platform
const clearPlatformMedia = async (platformId: string) => {
const media = platformMedia.value[platformId] || [];
const csrfToken = document.querySelector<HTMLMetaElement>('meta[name="csrf-token"]')?.content ?? '';
for (const m of media) {
await axios.delete(destroyMedia.url({ modelId: platformId, media: m.id }));
const uploadToAssets = async (file: File): Promise<MediaItem | null> => {
try {
if (shouldUseChunkedUpload(file)) {
const data = await uploadChunked({
file,
url: storeAssetChunked.url(),
model: 'workspace',
modelId: '',
collection: 'assets',
});
return {
id: data.id,
path: data.path ?? '',
url: data.url,
type: data.type,
mime_type: data.mime_type,
original_filename: data.original_filename,
};
}
const formData = new FormData();
formData.append('media', file);
const response = await fetch(storeAsset.url(), {
method: 'POST',
headers: {
Accept: 'application/json',
'X-CSRF-TOKEN': csrfToken,
'X-Requested-With': 'XMLHttpRequest',
},
body: formData,
});
if (!response.ok) return null;
const data = await response.json();
return {
id: data.id,
path: data.path ?? '',
url: data.url,
type: data.type,
mime_type: data.mime_type,
original_filename: data.original_filename,
};
} catch {
return null;
}
platformMedia.value[platformId] = [];
};
// Upload files to a platform (with sync support)
const upload = async (files: File[], postPlatformId: string) => {
if (!files || files.length === 0) return;
// Get content type rules to check max files
const platform = postPlatforms.value.find(pp => pp.id === postPlatformId);
const platform = postPlatforms.value.find((pp) => pp.id === postPlatformId);
const contentType = platformContentTypes.value[postPlatformId] || platform?.content_type || '';
const rules = getMediaRulesForContentType(contentType);
// Get other platforms to duplicate to (if synced)
const otherPlatformIds = synced.value
? selectedPlatformIds.value.filter(id => id !== postPlatformId)
: [];
const targetPlatformIds = synced.value ? selectedPlatformIds.value : [postPlatformId];
// Mark all as uploading
isUploading.value[postPlatformId] = true;
for (const id of otherPlatformIds) {
for (const id of targetPlatformIds) {
isUploading.value[id] = true;
}
// For single-media content types, clear existing media first
if (rules.maxFiles === 1) {
await clearPlatformMedia(postPlatformId);
platformMedia.value[postPlatformId] = [];
}
// Calculate how many files we can still add
const currentMedia = platformMedia.value[postPlatformId] || [];
const remainingSlots = rules.maxFiles - currentMedia.length;
const filesToUpload = Array.from(files).slice(0, remainingSlots);
if (filesToUpload.length === 0) {
// No slots available
isUploading.value[postPlatformId] = false;
for (const id of otherPlatformIds) {
for (const id of targetPlatformIds) {
isUploading.value[id] = false;
}
return;
}
for (const file of filesToUpload) {
try {
let data;
const mediaItem = await uploadToAssets(file);
if (!mediaItem) continue;
// Use chunked upload for large files (> 10MB)
if (shouldUseChunkedUpload(file)) {
data = await uploadChunked({
file,
url: storeMediaChunked.url(),
model: 'postPlatform',
modelId: postPlatformId,
collection: 'default',
onProgress: () => {},
});
for (const targetId of targetPlatformIds) {
if (isSingleMediaContentType(targetId)) {
platformMedia.value[targetId] = [mediaItem];
} else {
// Regular upload for small files
const formData = new FormData();
formData.append('media', file);
formData.append('model', 'postPlatform');
formData.append('model_id', postPlatformId);
const response = await axios.post(storeMedia.url(), formData);
data = response.data;
platformMedia.value[targetId] = [...(platformMedia.value[targetId] || []), mediaItem];
}
// Add to current platform (use spread for reactivity)
const currentMediaList = platformMedia.value[postPlatformId] || [];
platformMedia.value[postPlatformId] = [...currentMediaList, data];
// If synced, duplicate to other platforms
if (otherPlatformIds.length > 0) {
const targets = otherPlatformIds.map(id => ({
model: 'postPlatform',
model_id: id,
}));
const duplicateResponse = await axios.post(
duplicateMedia.url({ media: data.id }),
{ targets }
);
const duplicates = duplicateResponse.data;
for (const dup of duplicates) {
// For single-media platforms, clear first
if (isSingleMediaContentType(dup.mediable_id)) {
await clearPlatformMedia(dup.mediable_id);
}
const existingMedia = platformMedia.value[dup.mediable_id] || [];
platformMedia.value[dup.mediable_id] = [...existingMedia, dup];
}
}
} catch (error) {
console.error('Upload failed:', error);
}
}
// Mark all as done
isUploading.value[postPlatformId] = false;
for (const id of otherPlatformIds) {
for (const id of targetPlatformIds) {
isUploading.value[id] = false;
}
};
// Remove media from a platform (with sync support)
const remove = async (postPlatformId: string, mediaId: string) => {
// Find the media to get its group_id for synced removal
const mediaToRemove = platformMedia.value[postPlatformId]?.find(m => m.id === mediaId);
if (!mediaToRemove) return;
// Get target platforms - all selected if synced, otherwise just the current one
const remove = (postPlatformId: string, mediaId: string) => {
const targetPlatformIds = synced.value ? selectedPlatformIds.value : [postPlatformId];
for (const targetId of targetPlatformIds) {
// Find media with same group_id in this platform
const mediaInPlatform = platformMedia.value[targetId]?.find(
m => m.group_id === mediaToRemove.group_id
);
if (mediaInPlatform) {
// Remove from local state
platformMedia.value[targetId] = platformMedia.value[targetId].filter(
m => m.id !== mediaInPlatform.id
);
// Delete from server
await axios.delete(destroyMedia.url({ modelId: targetId, media: mediaInPlatform.id }));
}
platformMedia.value[targetId] = (platformMedia.value[targetId] || []).filter((m) => m.id !== mediaId);
}
};
// Reorder media in a platform (with sync support)
const reorder = async (postPlatformId: string, mediaIds: string[]) => {
// Get the current platform's media to extract group_ids in new order
const reorder = (postPlatformId: string, mediaIds: string[]) => {
const currentMedia = platformMedia.value[postPlatformId] || [];
const reorderedMedia = mediaIds.map(id => currentMedia.find(m => m.id === id)).filter(Boolean) as MediaItem[];
const reorderedMedia = mediaIds
.map((id) => currentMedia.find((m) => m.id === id))
.filter(Boolean) as MediaItem[];
// Get the group_ids in new order (for syncing to other platforms)
const groupIdsInOrder = reorderedMedia.map(m => m.group_id);
const firstGroupId = groupIdsInOrder[0];
// Get target platforms - all selected if synced, otherwise just the current one
const targetPlatformIds = synced.value ? selectedPlatformIds.value : [postPlatformId];
// Collect all media items to reorder across all platforms
const allMediaToReorder: { id: string; order: number }[] = [];
for (const targetId of targetPlatformIds) {
const targetMedia = platformMedia.value[targetId] || [];
const isSingleMedia = isSingleMediaContentType(targetId);
if (isSingleMedia && synced.value) {
// For single-media platforms with sync enabled:
// Check if current media matches the first group_id
const currentSingleMedia = targetMedia[0];
if (currentSingleMedia && currentSingleMedia.group_id !== firstGroupId) {
// Need to replace: delete current and duplicate the correct one
// Find the source media (first of the new order from the active platform)
const sourceMedia = reorderedMedia[0];
if (sourceMedia) {
// Delete current media from this platform
await axios.delete(destroyMedia.url({ modelId: targetId, media: currentSingleMedia.id }));
// Duplicate the correct media to this platform
const duplicateResponse = await axios.post(
duplicateMedia.url({ media: sourceMedia.id }),
{ targets: [{ model: 'postPlatform', model_id: targetId }] }
);
const duplicate = duplicateResponse.data[0];
if (duplicate) {
// Update local state with the new media
platformMedia.value[targetId] = [{
id: duplicate.id,
group_id: duplicate.group_id,
url: duplicate.url,
type: duplicate.type,
original_filename: duplicate.original_filename,
}];
// Add to reorder payload
allMediaToReorder.push({ id: duplicate.id, order: 0 });
}
}
} else if (currentSingleMedia) {
// Media is already correct, just update order
allMediaToReorder.push({ id: currentSingleMedia.id, order: 0 });
}
if (isSingleMediaContentType(targetId)) {
platformMedia.value[targetId] = reorderedMedia.length > 0 ? [reorderedMedia[0]] : [];
} else {
// For multi-media platforms: reorder based on group_id order
const reorderedTargetMedia = groupIdsInOrder
.map(groupId => targetMedia.find(m => m.group_id === groupId))
const targetMedia = platformMedia.value[targetId] || [];
const reorderedIds = reorderedMedia.map((m) => m.id);
platformMedia.value[targetId] = reorderedIds
.map((id) => targetMedia.find((m) => m.id === id) || reorderedMedia.find((m) => m.id === id))
.filter(Boolean) as MediaItem[];
// Update local state
platformMedia.value[targetId] = reorderedTargetMedia;
// Add to API payload
reorderedTargetMedia.forEach((m, index) => {
allMediaToReorder.push({ id: m.id, order: index });
});
}
}
// Send all reorders to API in one request
if (allMediaToReorder.length > 0) {
await axios.post(reorderMedia.url(), { media: allMediaToReorder });
}
};
// Get media for a specific platform
const getMedia = (platformId: string): MediaItem[] => {
return platformMedia.value[platformId] || [];
};
// Check if a platform is uploading
const isUploadingFor = (platformId: string): boolean => {
return isUploading.value[platformId] || false;
};
@ -296,4 +190,4 @@ export function useMediaManager(options: UseMediaManagerOptions) {
isUploadingFor,
isSingleMediaContentType,
};
}
};

View file

@ -1,28 +1,24 @@
<script setup lang="ts">
import AppLayout from '@/layouts/app/AppSidebarLayout.vue';
import type { BreadcrumbItem } from '@/types';
type Props = {
breadcrumbs?: BreadcrumbItem[];
title?: string;
fullWidth?: boolean;
};
withDefaults(defineProps<Props>(), {
breadcrumbs: () => [],
title: '',
fullWidth: false,
});
</script>
<template>
<AppLayout :breadcrumbs="breadcrumbs" :full-width="fullWidth">
<template v-if="$slots['header-left']" #header-left>
<slot name="header-left" />
<AppLayout :title="title" :full-width="fullWidth">
<template v-if="$slots['header']" #header>
<slot name="header" />
</template>
<template v-if="$slots['header-center']" #header-center>
<slot name="header-center" />
</template>
<template v-if="$slots['header-right']" #header-right>
<slot name="header-right" />
<template v-if="$slots['header-actions']" #header-actions>
<slot name="header-actions" />
</template>
<slot />
</AppLayout>

View file

@ -5,18 +5,17 @@ import AppHeader from '@/components/AppHeader.vue';
import AppSidebar from '@/components/AppSidebar.vue';
import Toast from '@/components/Toast.vue';
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
import type { BreadcrumbItem } from '@/types';
const page = usePage();
const isOpen = page.props.sidebarOpen;
type Props = {
breadcrumbs?: BreadcrumbItem[];
title?: string;
fullWidth?: boolean;
};
withDefaults(defineProps<Props>(), {
breadcrumbs: () => [],
title: '',
fullWidth: false,
});
</script>
@ -24,16 +23,13 @@ withDefaults(defineProps<Props>(), {
<template>
<SidebarProvider :default-open="isOpen">
<AppSidebar />
<SidebarInset class="flex h-screen flex-col overflow-hidden">
<AppHeader :breadcrumbs="$slots['header-left'] ? [] : breadcrumbs" :show-sidebar-trigger="!$slots['header-left']">
<template v-if="$slots['header-left']" #left>
<slot name="header-left" />
<SidebarInset class="overflow-x-hidden">
<AppHeader v-if="$slots['header'] || $slots['header-actions'] || title" :title="$slots['header'] ? '' : title">
<template v-if="$slots['header']" #left>
<slot name="header" />
</template>
<template v-if="$slots['header-center']" #center>
<slot name="header-center" />
</template>
<template v-if="$slots['header-right']" #right>
<slot name="header-right" />
<template v-if="$slots['header-actions']" #right>
<slot name="header-actions" />
</template>
</AppHeader>
<div

View file

@ -1,8 +1,7 @@
<script setup lang="ts">
import { Head, router } from '@inertiajs/vue3';
import { IconAffiliate, IconAlertCircle, IconCheck, IconExternalLink, IconPlus, IconRefresh, IconTrash } from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import { computed, ref } from 'vue';
import { ref } from 'vue';
import AddSocialDialog, { type AvailablePlatform } from '@/components/accounts/AddSocialDialog.vue';
import ConfirmDeleteModal from '@/components/ConfirmDeleteModal.vue';
@ -14,10 +13,7 @@ import { Switch } from '@/components/ui/switch';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import date from '@/date';
import AppLayout from '@/layouts/AppLayout.vue';
import { accounts } from '@/routes/app';
import { disconnect as disconnectAccount, toggle as toggleAccount } from '@/routes/app/accounts';
import { type BreadcrumbItemType } from '@/types';
interface SocialAccount {
id: string;
platform: string;
@ -41,10 +37,6 @@ const props = defineProps<Props>();
const isAddDialogOpen = ref(false);
const deleteModal = ref<InstanceType<typeof ConfirmDeleteModal> | null>(null);
const breadcrumbs = computed<BreadcrumbItemType[]>(() => [
{ title: trans('accounts.title'), href: accounts.url() },
]);
const getPlatformLogo = (platform: string): string => {
const logos: Record<string, string> = {
'linkedin': '/images/accounts/linkedin.png',
@ -120,8 +112,8 @@ const handleDisconnect = (accountId: string) => {
<template>
<Head :title="$t('accounts.page_title')" />
<AppLayout :breadcrumbs="breadcrumbs">
<template #header-right>
<AppLayout :title="$t('accounts.page_title')">
<template #header-actions>
<Button @click="isAddDialogOpen = true">
{{ $t('accounts.add_social') }}
</Button>

View file

@ -15,17 +15,10 @@ import YouTubeAnalytics from '@/components/analytics/YouTubeAnalytics.vue';
import { DateRangePicker } from '@/components/ui/date-range-picker';
import dayjs from '@/dayjs';
import AppLayout from '@/layouts/AppLayout.vue';
import { analytics } from '@/routes/app';
import { type BreadcrumbItemType } from '@/types';
const props = defineProps<{
accounts: AnalyticsAccount[];
}>();
const breadcrumbs = computed<BreadcrumbItemType[]>(() => [
{ title: trans('sidebar.analytics'), href: analytics.url() },
]);
const selectedAccountId = ref<string | null>(props.accounts[0]?.id ?? null);
const dateRange = ref({
@ -44,8 +37,8 @@ const platformSupportsDateRange = computed(() => {
</script>
<template>
<AppLayout :breadcrumbs="breadcrumbs" :full-width="true">
<template #header-right>
<AppLayout :title="$t('sidebar.analytics')" :full-width="true">
<template #header-actions>
<DateRangePicker v-if="platformSupportsDateRange" v-model="dateRange" />
</template>

View file

@ -1,7 +1,6 @@
<script setup lang="ts">
import { Head, InfiniteScroll, router, useHttp } from '@inertiajs/vue3';
import { IconCloudUpload, IconPhoto, IconPlus, IconSearch, IconTrash } from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import { IconCloudUpload, IconPencilPlus, IconPhoto, IconPlus, IconSearch, IconTrash } from '@tabler/icons-vue';
import { computed, onUnmounted, ref } from 'vue';
import ConfirmDeleteModal from '@/components/ConfirmDeleteModal.vue';
@ -13,15 +12,16 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import debounce from '@/debounce';
import AppLayout from '@/layouts/AppLayout.vue';
import { index as assetsIndex, destroy as assetsDestroy, store as assetsStore, storeFromUrl } from '@/routes/app/assets';
import { destroy as assetsDestroy, store as assetsStore, storeFromUrl } from '@/routes/app/assets';
import { search as giphySearch, trending as giphyTrending } from '@/routes/app/assets/giphy';
import { search as unsplashSearch, trending as unsplashTrending } from '@/routes/app/assets/unsplash';
import { type BreadcrumbItem } from '@/types';
import { store as storePost } from '@/routes/app/posts';
interface AssetMedia {
id: string;
path: string;
url: string;
type: string;
mime_type: string;
original_filename: string;
size: number;
meta: { width?: number; height?: number } | null;
@ -68,10 +68,6 @@ const props = defineProps<{
const httpGet = useHttp({});
const httpUpload = useHttp<{ media: File | null }>({ media: null });
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
{ title: trans('assets.title'), href: assetsIndex.url() },
]);
// Upload
const fileInput = ref<HTMLInputElement | null>(null);
const isDragging = ref(false);
@ -383,6 +379,12 @@ const saveFromGiphy = (gif: GiphyGif) => {
});
};
const createPostFromAsset = (asset: AssetMedia) => {
router.post(storePost.url(), {
media: [{ id: asset.id, path: asset.path, url: asset.url, type: asset.type, mime_type: asset.mime_type }],
});
};
const formatFileSize = (bytes: number): string => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
@ -393,7 +395,7 @@ const formatFileSize = (bytes: number): string => {
<template>
<Head :title="$t('assets.title')" />
<AppLayout :breadcrumbs="breadcrumbs">
<AppLayout :title="$t('assets.title')">
<div class="flex flex-col gap-6 p-6">
<Tabs default-value="uploads">
<TabsList>
@ -433,14 +435,7 @@ const formatFileSize = (bytes: number): string => {
</div>
<!-- Assets Grid -->
<EmptyState
v-if="assets.data.length === 0 && !uploading"
:icon="IconPhoto"
:title="$t('assets.empty.title')"
:description="$t('assets.empty.description')"
/>
<div v-else class="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
<div v-if="assets.data.length > 0" class="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
<div
v-for="asset in assets.data"
:key="asset.id"
@ -464,7 +459,22 @@ const formatFileSize = (bytes: number): string => {
<!-- Hover overlay -->
<div class="absolute inset-0 flex flex-col justify-between bg-black/60 p-2 opacity-0 transition-opacity group-hover:opacity-100">
<div class="flex justify-end">
<div class="flex justify-end gap-1">
<TooltipProvider>
<Tooltip>
<TooltipTrigger as-child>
<Button
variant="secondary"
size="icon"
class="size-7"
@click="createPostFromAsset(asset)"
>
<IconPencilPlus class="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ $t('assets.create_post') }}</TooltipContent>
</Tooltip>
</TooltipProvider>
<Button
variant="destructive"
size="icon"

View file

@ -1,15 +1,11 @@
<script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import { IconDownload, IconFileText } from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import { computed } from 'vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import AppLayout from '@/layouts/AppLayout.vue';
import { index as billingIndex, portal } from '@/routes/app/billing';
import { type BreadcrumbItem } from '@/types';
import { portal } from '@/routes/app/billing';
interface Plan {
name: string;
slug: string;
@ -48,10 +44,6 @@ const props = defineProps<{
defaultPaymentMethod: PaymentMethod | null;
}>();
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
{ title: trans('billing.title'), href: billingIndex.url() },
]);
const formatPrice = (cents: number): string => {
if (cents === 0) return 'Free';
return '$' + (cents / 100).toFixed(0);
@ -61,7 +53,7 @@ const formatPrice = (cents: number): string => {
<template>
<Head :title="$t('billing.title')" />
<AppLayout :breadcrumbs="breadcrumbs">
<AppLayout :title="$t('billing.title')">
<div class="mx-auto max-w-3xl space-y-0 p-6">
<!-- Plan -->
<section class="grid grid-cols-1 gap-8 md:grid-cols-[280px_1fr] md:gap-16">

View file

@ -2,7 +2,7 @@
import { Head, InfiniteScroll, router } from '@inertiajs/vue3';
import { IconHash, IconPencil, IconSearch, IconTrash } from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import { computed, ref, watch } from 'vue';
import { ref, watch } from 'vue';
import ConfirmDeleteModal from '@/components/ConfirmDeleteModal.vue';
import EmptyState from '@/components/EmptyState.vue';
@ -15,8 +15,6 @@ import { Skeleton } from '@/components/ui/skeleton';
import debounce from '@/debounce';
import AppLayout from '@/layouts/AppLayout.vue';
import { index as hashtagsIndex, destroy as hashtagsDestroy } from '@/routes/app/hashtags';
import { type BreadcrumbItemType } from '@/types';
interface Workspace {
id: string;
name: string;
@ -67,10 +65,6 @@ const isCreateDialogOpen = ref(false);
const isEditDialogOpen = ref(false);
const editingHashtag = ref<Hashtag | null>(null);
const breadcrumbs = computed<BreadcrumbItemType[]>(() => [
{ title: trans('hashtags.title'), href: hashtagsIndex.url() },
]);
const openEditDialog = (hashtag: Hashtag) => {
editingHashtag.value = hashtag;
isEditDialogOpen.value = true;
@ -91,8 +85,8 @@ const getHashtagCount = (hashtags: string): number => {
<Head :title="$t('hashtags.title')" />
<AppLayout :breadcrumbs="breadcrumbs">
<template #header-right>
<AppLayout :title="$t('hashtags.title')">
<template #header-actions>
<div class="relative">
<IconSearch class="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
<Input

View file

@ -2,7 +2,7 @@
import { Head, InfiniteScroll, router } from '@inertiajs/vue3';
import { IconPencil, IconSearch, IconTag, IconTrash } from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import { computed, ref, watch } from 'vue';
import { ref, watch } from 'vue';
import ConfirmDeleteModal from '@/components/ConfirmDeleteModal.vue';
import EmptyState from '@/components/EmptyState.vue';
@ -15,8 +15,6 @@ import { Skeleton } from '@/components/ui/skeleton';
import debounce from '@/debounce';
import AppLayout from '@/layouts/AppLayout.vue';
import { index as labelsIndex, destroy as labelsDestroy } from '@/routes/app/labels';
import { type BreadcrumbItemType } from '@/types';
interface Label {
id: string;
name: string;
@ -45,10 +43,6 @@ const isCreateDialogOpen = ref(false);
const isEditDialogOpen = ref(false);
const editingLabel = ref<Label | null>(null);
const breadcrumbs = computed<BreadcrumbItemType[]>(() => [
{ title: trans('labels.title'), href: labelsIndex.url() },
]);
const searchQuery = ref(props.filters.search);
const search = debounce(() => {
@ -81,8 +75,8 @@ const handleDelete = (labelId: string) => {
<Head :title="$t('labels.title')" />
<AppLayout :breadcrumbs="breadcrumbs">
<template #header-right>
<AppLayout :title="$t('labels.title')">
<template #header-actions>
<div class="relative">
<IconSearch class="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
<Input

View file

@ -257,7 +257,7 @@ const formatTime = (scheduledAt: string): string => {
<Head :title="$t('calendar.title')" />
<AppLayout :fullWidth="true">
<template #header-left>
<template #header>
<Button variant="outline" size="icon" @click="navigate(-1)">
<IconChevronLeft class="h-4 w-4" />
</Button>
@ -268,15 +268,12 @@ const formatTime = (scheduledAt: string): string => {
<IconChevronRight class="h-4 w-4" />
</Button>
<DatePicker v-if="isMobile" v-model="selectedDate" @update:model-value="(v: any) => goToDate(v)" />
</template>
<template #header-center>
<span class="text-sm font-semibold">
{{ headerTitle }}
</span>
</template>
<template #header-right>
<template #header-actions>
<div class="flex items-center gap-2">
<Tabs v-if="!isMobile" :default-value="view" @update:model-value="switchView">
<TabsList class="h-10">

File diff suppressed because it is too large Load diff

View file

@ -16,8 +16,6 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp
import dayjs from '@/dayjs';
import debounce from '@/debounce';
import AppLayout from '@/layouts/AppLayout.vue';
import { type BreadcrumbItemType } from '@/types';
interface SocialAccount {
id: string;
platform: string;
@ -106,23 +104,6 @@ const pageTitle = computed(() => {
return trans('posts.all_posts');
});
const breadcrumbs = computed<BreadcrumbItemType[]>(() => {
const items: BreadcrumbItemType[] = [
{ title: trans('posts.title'), href: postsIndex.url() },
];
items.push({
title: props.currentStatus
? trans(`posts.status.${props.currentStatus}`)
: trans('posts.all_posts'),
href: props.currentStatus
? postsIndex.url(props.currentStatus)
: postsIndex.url(),
});
return items;
});
const getPlatformLogo = (platform: string): string => {
const logos: Record<string, string> = {
'linkedin': '/images/accounts/linkedin.png',
@ -191,8 +172,8 @@ const handleDelete = (post: Post) => {
<Head :title="pageTitle" />
<AppLayout :breadcrumbs="breadcrumbs">
<template #header-right>
<AppLayout :title="pageTitle">
<template #header-actions>
<div class="relative">
<IconSearch class="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
<Input

View file

@ -1,7 +1,6 @@
<script setup lang="ts">
import { Form, Head } from '@inertiajs/vue3';
import { trans } from 'laravel-vue-i18n';
import { computed } from 'vue';
import HeadingSmall from '@/components/HeadingSmall.vue';
import InputError from '@/components/InputError.vue';
@ -9,9 +8,7 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import AppLayout from '@/layouts/AppLayout.vue';
import { edit as accountEdit, update as accountUpdate } from '@/routes/app/account';
import { type BreadcrumbItem } from '@/types';
import { update as accountUpdate } from '@/routes/app/account';
interface AccountData {
id: string;
name: string;
@ -23,13 +20,10 @@ const props = defineProps<{
selfHosted: boolean;
}>();
const breadcrumbItems = computed<BreadcrumbItem[]>(() => [
{ title: trans('settings.account.title'), href: accountEdit.url() },
]);
</script>
<template>
<AppLayout :breadcrumbs="breadcrumbItems">
<AppLayout :title="$t('settings.account.title')">
<Head :title="$t('settings.account.title')" />
<div class="mx-auto max-w-2xl space-y-6 p-6">

View file

@ -29,9 +29,6 @@ import {
import date from '@/date';
import AppLayout from '@/layouts/AppLayout.vue';
import { copyToClipboard } from '@/lib/utils';
import { index as apiKeysIndex } from '@/routes/app/api-keys';
import { type BreadcrumbItem } from '@/types';
interface ApiToken {
id: string;
name: string;
@ -51,17 +48,12 @@ defineProps<Props>();
const page = usePage();
const newToken = computed(() => (page.props.flash as Record<string, unknown>)?.plainToken as string | undefined);
const breadcrumbItems = computed<BreadcrumbItem[]>(() => [
{ title: trans('settings.title'), href: apiKeysIndex.url() },
{ title: trans('settings.api_keys.title'), href: apiKeysIndex.url() },
]);
const createDialogOpen = ref(false);
const confirmDeleteModal = ref<InstanceType<typeof ConfirmDeleteModal> | null>(null);
</script>
<template>
<AppLayout :breadcrumbs="breadcrumbItems">
<AppLayout :title="$t('settings.api_keys.page_title')">
<Head :title="$t('settings.api_keys.page_title')" />
<h1 class="sr-only">{{ $t('settings.api_keys.heading') }}</h1>

View file

@ -2,7 +2,6 @@
import { Head, useForm, router } from '@inertiajs/vue3';
import { IconUserPlus, IconUsers, IconMail, IconTrash, IconCrown, IconUser, IconShield } from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import { computed } from 'vue';
import { WorkspaceRole } from '@/enums/workspace-role';
import HeadingSmall from '@/components/HeadingSmall.vue';
@ -13,11 +12,8 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import AppLayout from '@/layouts/AppLayout.vue';
import { members as membersRoute } from '@/routes/app';
import { destroy as destroyInvite, store as storeInvite } from '@/routes/app/invites';
import { remove as removeMember } from '@/routes/app/members';
import { type BreadcrumbItem } from '@/types';
interface Workspace {
id: string;
name: string;
@ -51,11 +47,6 @@ interface Props {
defineProps<Props>();
const breadcrumbItems = computed<BreadcrumbItem[]>(() => [
{ title: trans('settings.title'), href: membersRoute.url() },
{ title: trans('settings.nav.members'), href: membersRoute.url() },
]);
const form = useForm({
email: '',
role: WorkspaceRole.Member,
@ -97,7 +88,7 @@ const getRoleIcon = (role: string) => {
</script>
<template>
<AppLayout :breadcrumbs="breadcrumbItems">
<AppLayout :title="$t('settings.members.title')">
<Head :title="$t('settings.members.title')" />
<h1 class="sr-only">{{ $t('settings.members.title') }}</h1>

View file

@ -1,7 +1,6 @@
<script setup lang="ts">
import { Head, router } from '@inertiajs/vue3';
import { trans } from 'laravel-vue-i18n';
import { computed, ref } from 'vue';
import { ref } from 'vue';
import HeadingSmall from '@/components/HeadingSmall.vue';
import { Button } from '@/components/ui/button';
@ -10,8 +9,6 @@ import { Switch } from '@/components/ui/switch';
import AppLayout from '@/layouts/AppLayout.vue';
import SettingsLayout from '@/layouts/settings/Layout.vue';
import { preferences as preferencesRoute } from '@/routes/app/notifications';
import { type BreadcrumbItem } from '@/types';
interface Preferences {
post_published: boolean;
post_failed: boolean;
@ -24,11 +21,6 @@ interface Props {
const props = defineProps<Props>();
const breadcrumbItems = computed<BreadcrumbItem[]>(() => [
{ title: trans('settings.title'), href: preferencesRoute().url },
{ title: trans('settings.nav.notifications'), href: preferencesRoute().url },
]);
const postPublished = ref(props.preferences.post_published);
const postFailed = ref(props.preferences.post_failed);
const accountDisconnected = ref(props.preferences.account_disconnected);
@ -51,7 +43,7 @@ const submit = () => {
</script>
<template>
<AppLayout :breadcrumbs="breadcrumbItems">
<AppLayout :title="$t('settings.notifications.title')">
<Head :title="$t('settings.notifications.title')" />
<h1 class="sr-only">{{ $t('settings.notifications.title') }}</h1>

View file

@ -1,8 +1,6 @@
<script setup lang="ts">
import { Form, Head } from '@inertiajs/vue3';
import { trans } from 'laravel-vue-i18n';
import { computed } from 'vue';
import PasswordController from '@/actions/App/Http/Controllers/App/Settings/PasswordController';
import HeadingSmall from '@/components/HeadingSmall.vue';
import InputError from '@/components/InputError.vue';
@ -11,17 +9,10 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import AppLayout from '@/layouts/AppLayout.vue';
import SettingsLayout from '@/layouts/settings/Layout.vue';
import { edit } from '@/routes/app/user-password';
import { type BreadcrumbItem } from '@/types';
const breadcrumbItems = computed<BreadcrumbItem[]>(() => [
{ title: trans('settings.title'), href: edit().url },
{ title: trans('settings.nav.password'), href: edit().url },
]);
</script>
<template>
<AppLayout :breadcrumbs="breadcrumbItems">
<AppLayout :title="$t('settings.password.title')">
<Head :title="$t('settings.password.title')" />
<h1 class="sr-only">{{ $t('settings.password.title') }}</h1>

View file

@ -14,10 +14,8 @@ import { Label } from '@/components/ui/label';
import { Separator } from '@/components/ui/separator';
import AppLayout from '@/layouts/AppLayout.vue';
import SettingsLayout from '@/layouts/settings/Layout.vue';
import { edit, uploadPhoto, deletePhoto } from '@/routes/app/profile';
import { uploadPhoto, deletePhoto } from '@/routes/app/profile';
import { send } from '@/routes/verification';
import { type BreadcrumbItem } from '@/types';
interface Props {
mustVerifyEmail: boolean;
status?: string;
@ -27,15 +25,10 @@ defineProps<Props>();
const page = usePage();
const user = computed(() => page.props.auth.user);
const breadcrumbItems = computed<BreadcrumbItem[]>(() => [
{ title: trans('settings.title'), href: edit().url },
{ title: trans('settings.nav.profile'), href: edit().url },
]);
</script>
<template>
<AppLayout :breadcrumbs="breadcrumbItems">
<AppLayout :title="$t('settings.profile.title')">
<Head :title="$t('settings.profile.title')" />
<h1 class="sr-only">{{ $t('settings.profile.title') }}</h1>

View file

@ -1,13 +1,8 @@
<script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import { trans } from 'laravel-vue-i18n';
import { computed } from 'vue';
import UsageMetricRow from '@/components/settings/UsageMetricRow.vue';
import AppLayout from '@/layouts/AppLayout.vue';
import { index as usageRoute } from '@/routes/app/usage';
import { type BreadcrumbItem } from '@/types';
interface Plan {
name: string;
slug: string;
@ -32,10 +27,6 @@ const props = defineProps<{
usage: Usage;
}>();
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
{ title: trans('usage.title'), href: usageRoute.url() },
]);
const formatRetention = (days: number): string => {
if (days >= 730) return trans('usage.unlimited');
if (days >= 365) {
@ -49,7 +40,7 @@ const formatRetention = (days: number): string => {
<template>
<Head :title="$t('usage.title')" />
<AppLayout :breadcrumbs="breadcrumbs">
<AppLayout :title="$t('usage.title')">
<div class="mx-auto max-w-3xl space-y-0 p-6">
<!-- Account -->
<section class="grid grid-cols-1 gap-8 md:grid-cols-[280px_1fr] md:gap-16">

View file

@ -2,7 +2,7 @@
import { Form, Head, router } from '@inertiajs/vue3';
import { IconClock, IconDots, IconShield, IconTrash, IconUser } from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import { computed, ref } from 'vue';
import { ref } from 'vue';
import WorkspaceController from '@/actions/App/Http/Controllers/App/WorkspaceController';
import { WorkspaceRole } from '@/enums/workspace-role';
@ -34,9 +34,7 @@ import {
import AppLayout from '@/layouts/AppLayout.vue';
import { destroy as destroyInvite } from '@/routes/app/invites';
import { remove as removeMemberRoute, updateRole } from '@/routes/app/members';
import { settings, uploadLogo, deleteLogo } from '@/routes/app/workspace';
import { type BreadcrumbItem } from '@/types';
import { uploadLogo, deleteLogo } from '@/routes/app/workspace';
interface Workspace {
id: string;
name: string;
@ -65,11 +63,6 @@ const props = defineProps<{
timezones: Record<string, string>;
}>();
const breadcrumbItems = computed<BreadcrumbItem[]>(() => [
{ title: trans('settings.title'), href: settings().url },
{ title: trans('settings.nav.workspace'), href: settings().url },
]);
const timezone = ref(props.workspace.timezone);
const inviteDialogOpen = ref(false);
@ -82,7 +75,7 @@ const changeRole = (member: Member, role: string) => {
</script>
<template>
<AppLayout :breadcrumbs="breadcrumbItems">
<AppLayout :title="$t('settings.workspace.title')">
<Head :title="$t('settings.workspace.title')" />
<h1 class="sr-only">{{ $t('settings.workspace.title') }}</h1>

View file

@ -7,8 +7,6 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import AppLayout from '@/layouts/AppLayout.vue';
import { accounts, calendar } from '@/routes/app';
import { settings } from '@/routes/app/workspace';
import { type BreadcrumbItemType } from '@/types';
interface SocialAccount {
id: string;
platform: string;
@ -36,16 +34,12 @@ interface Props {
}
defineProps<Props>();
const breadcrumbs: BreadcrumbItemType[] = [
{ title: 'Calendar', href: calendar.url() },
];
</script>
<template>
<Head :title="workspace.name" />
<AppLayout :breadcrumbs="breadcrumbs">
<AppLayout :title="workspace.name">
<div class="flex flex-col gap-6 p-6">
<div class="flex items-center justify-between">
<div>

View file

@ -26,11 +26,6 @@ export interface FlashData {
[key: string]: unknown;
}
export interface BreadcrumbItem {
title: string;
href: string;
}
export interface NavItem {
title: string;
href: NonNullable<InertiaLinkProps['href']>;
@ -66,4 +61,3 @@ export interface User {
[key: string]: unknown;
}
export type BreadcrumbItemType = BreadcrumbItem;

View file

@ -1,10 +1,8 @@
import axios from 'axios';
interface ChunkedUploadOptions {
file: File;
url: string;
model: string;
modelId: string;
model?: string;
modelId?: string;
collection?: string;
chunkSize?: number;
onProgress?: (progress: number) => void;
@ -14,14 +12,17 @@ interface ChunkedUploadOptions {
interface ChunkedUploadResult {
id: string;
path?: string;
url: string;
type: string;
mime_type?: string;
original_filename: string;
[key: string]: any;
}
const DEFAULT_CHUNK_SIZE = 5 * 1024 * 1024; // 5MB chunks
export async function uploadChunked(options: ChunkedUploadOptions): Promise<ChunkedUploadResult> {
export const uploadChunked = async (options: ChunkedUploadOptions): Promise<ChunkedUploadResult> => {
const {
file,
url,
@ -34,6 +35,7 @@ export async function uploadChunked(options: ChunkedUploadOptions): Promise<Chun
onError,
} = options;
const csrfToken = document.querySelector<HTMLMetaElement>('meta[name="csrf-token"]')?.content ?? '';
const totalSize = file.size;
const totalChunks = Math.ceil(totalSize / chunkSize);
let uploadedBytes = 0;
@ -44,24 +46,36 @@ export async function uploadChunked(options: ChunkedUploadOptions): Promise<Chun
const end = Math.min(start + chunkSize, totalSize);
const chunk = file.slice(start, end);
const response = await axios.post(url, chunk, {
headers: {
'Content-Type': 'application/octet-stream',
'Content-Range': `bytes ${start}-${end - 1}/${totalSize}`,
'X-Model': model,
'X-Model-Id': modelId,
'X-Collection': collection,
'X-File-Name': file.name,
},
const headers: Record<string, string> = {
'Content-Type': 'application/octet-stream',
'Content-Range': `bytes ${start}-${end - 1}/${totalSize}`,
'X-File-Name': file.name,
'X-CSRF-TOKEN': csrfToken,
'X-Requested-With': 'XMLHttpRequest',
Accept: 'application/json',
};
if (model) headers['X-Model'] = model;
if (modelId) headers['X-Model-Id'] = modelId;
if (collection) headers['X-Collection'] = collection;
const response = await fetch(url, {
method: 'POST',
headers,
body: chunk,
});
if (!response.ok) throw new Error(`Upload chunk failed: ${response.statusText}`);
const data = await response.json();
uploadedBytes = end;
const progress = Math.round((uploadedBytes / totalSize) * 100);
onProgress?.(progress);
if (response.data.done) {
onComplete?.(response.data);
return response.data;
if (data.done) {
onComplete?.(data);
return data;
}
}
@ -70,11 +84,10 @@ export async function uploadChunked(options: ChunkedUploadOptions): Promise<Chun
onError?.(error);
throw error;
}
}
};
// Threshold for when to use chunked upload (10MB)
const CHUNKED_UPLOAD_THRESHOLD = 10 * 1024 * 1024;
const CHUNKED_UPLOAD_THRESHOLD = 10 * 1024 * 1024; // 10MB
export function shouldUseChunkedUpload(file: File): boolean {
export const shouldUseChunkedUpload = (file: File): boolean => {
return file.size > CHUNKED_UPLOAD_THRESHOLD;
}
};

View file

@ -127,11 +127,9 @@
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->putJson(route('api.posts.update', $post), [
'status' => 'draft',
'synced' => true,
'platforms' => [
[
'id' => $postPlatform->id,
'content' => 'Updated content',
'content_type' => ContentType::LinkedInPost->value,
],
],
@ -158,11 +156,9 @@
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->putJson(route('api.posts.update', $post), [
'status' => 'draft',
'synced' => true,
'platforms' => [
[
'id' => $postPlatform->id,
'content' => 'Test',
'content_type' => ContentType::LinkedInPost->value,
],
],
@ -186,11 +182,9 @@
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->putJson(route('api.posts.update', $post), [
'status' => 'draft',
'synced' => true,
'platforms' => [
[
'id' => $postPlatform->id,
'content' => 'Test',
'content_type' => ContentType::LinkedInPost->value,
],
],
@ -284,7 +278,7 @@
->assertOk()
->assertJsonStructure([
'data' => [
'*' => ['id', 'status', 'synced', 'scheduled_at', 'published_at', 'created_at', 'updated_at'],
'*' => ['id', 'status', 'scheduled_at', 'published_at', 'created_at', 'updated_at'],
],
]);
});
@ -298,5 +292,5 @@
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->getJson(route('api.posts.show', $post))
->assertOk()
->assertJsonStructure(['id', 'status', 'synced', 'scheduled_at', 'published_at']);
->assertJsonStructure(['id', 'status', 'scheduled_at', 'published_at']);
});

View file

@ -83,6 +83,7 @@
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
'content' => 'Test post via Facebook Business',
]);
$postPlatform = PostPlatform::factory()->create([
@ -90,17 +91,18 @@
'social_account_id' => $this->instagramFacebookAccount->id,
'platform' => Platform::InstagramFacebook,
'content_type' => ContentType::InstagramFeed,
'content' => 'Test post via Facebook Business',
]);
$postPlatform->media()->create([
'collection' => 'default',
'type' => 'image',
'path' => 'media/test.jpg',
'original_filename' => 'test.jpg',
'mime_type' => 'image/jpeg',
'size' => 512000,
'order' => 0,
$post->update([
'media' => [
[
'id' => 'test-media-id',
'path' => 'media/test.jpg',
'url' => 'https://example.com/media/test.jpg',
'mime_type' => 'image/jpeg',
'original_filename' => 'test.jpg',
],
],
]);
Http::fake([
@ -139,6 +141,7 @@
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
'content' => 'Test post via standalone',
]);
$postPlatform = PostPlatform::factory()->create([
@ -146,17 +149,18 @@
'social_account_id' => $standaloneAccount->id,
'platform' => Platform::Instagram,
'content_type' => ContentType::InstagramFeed,
'content' => 'Test post via standalone',
]);
$postPlatform->media()->create([
'collection' => 'default',
'type' => 'image',
'path' => 'media/test.jpg',
'original_filename' => 'test.jpg',
'mime_type' => 'image/jpeg',
'size' => 512000,
'order' => 0,
$post->update([
'media' => [
[
'id' => 'test-media-id',
'path' => 'media/test.jpg',
'url' => 'https://example.com/media/test.jpg',
'mime_type' => 'image/jpeg',
'original_filename' => 'test.jpg',
],
],
]);
Http::fake([

View file

@ -344,7 +344,7 @@
test('publish to social platform saves error context on generic failure', function () {
Event::fake();
$this->postPlatform->update(['content' => 'Test content here']);
$this->post->update(['content' => 'Test content here']);
$publisher = Mockery::mock(LinkedInPublisher::class);
$publisher->shouldReceive('publish')->andThrow(new Exception('Something broke'));
@ -364,7 +364,7 @@
test('publish to social platform saves error context on social publish exception', function () {
Event::fake();
$this->postPlatform->update(['content' => 'Hello world']);
$this->post->update(['content' => 'Hello world']);
$publisher = Mockery::mock(LinkedInPublisher::class);
$publisher->shouldReceive('publish')->andThrow(

View file

@ -42,8 +42,8 @@
test('store media uploads file', function () {
$response = $this->actingAs($this->user)->post(route('app.medias.store'), [
'model' => 'postPlatform',
'model_id' => $this->postPlatform->id,
'model' => 'workspace',
'model_id' => $this->workspace->id,
'media' => UploadedFile::fake()->image('test.jpg'),
]);
@ -117,20 +117,17 @@
test('duplicate media creates copies', function () {
$media = Media::factory()->create([
'mediable_id' => $this->postPlatform->id,
'mediable_type' => 'postPlatform',
]);
$otherPostPlatform = PostPlatform::factory()->create([
'post_id' => $this->post->id,
'social_account_id' => $this->socialAccount->id,
'mediable_id' => $this->workspace->id,
'mediable_type' => 'workspace',
'collection' => 'assets',
]);
$response = $this->actingAs($this->user)->post(route('app.medias.duplicate', $media), [
'targets' => [
[
'model' => 'postPlatform',
'model_id' => $otherPostPlatform->id,
'model' => 'workspace',
'model_id' => $this->workspace->id,
'collection' => 'assets',
],
],
]);
@ -138,13 +135,23 @@
$response->assertOk();
$response->assertJsonCount(1);
expect(Media::where('mediable_id', $otherPostPlatform->id)->count())->toBe(1);
expect(Media::where('mediable_id', $this->workspace->id)->where('collection', 'assets')->count())->toBe(2);
});
// Reorder tests
test('reorder media updates order', function () {
$media1 = $this->postPlatform->addMedia(UploadedFile::fake()->image('img1.jpg'), 'media');
$media2 = $this->postPlatform->addMedia(UploadedFile::fake()->image('img2.jpg'), 'media');
$media1 = Media::factory()->create([
'mediable_id' => $this->postPlatform->id,
'mediable_type' => 'postPlatform',
'original_filename' => 'img1.jpg',
'order' => 0,
]);
$media2 = Media::factory()->create([
'mediable_id' => $this->postPlatform->id,
'mediable_type' => 'postPlatform',
'original_filename' => 'img2.jpg',
'order' => 1,
]);
$response = $this->actingAs($this->user)->postJson(route('app.medias.reorder'), [
'media' => [
@ -178,7 +185,12 @@
'social_account_id' => $otherAccount->id,
]);
$otherMedia = $otherPlatform->addMedia(UploadedFile::fake()->image('img.jpg'), 'media');
$otherMedia = Media::factory()->create([
'mediable_id' => $otherPlatform->id,
'mediable_type' => 'postPlatform',
'original_filename' => 'img.jpg',
'order' => 0,
]);
$response = $this->actingAs($this->user)->postJson(route('app.medias.reorder'), [
'media' => [

View file

@ -196,21 +196,20 @@
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
'status' => PostStatus::Draft,
'content' => 'Original content',
]);
$postPlatform = PostPlatform::factory()->create([
'post_id' => $post->id,
'social_account_id' => $this->socialAccount->id,
'content' => 'Original content',
]);
$response = $this->actingAs($this->user)->put(route('app.posts.update', $post), [
'status' => 'draft',
'synced' => true,
'content' => 'Updated content',
'platforms' => [
[
'id' => $postPlatform->id,
'content' => 'Updated content',
'content_type' => ContentType::LinkedInPost->value,
],
],
@ -218,8 +217,9 @@
$response->assertRedirect();
$post->refresh();
expect($post->content)->toBe('Updated content');
$postPlatform->refresh();
expect($postPlatform->content)->toBe('Updated content');
expect($postPlatform->content_type)->toBe(ContentType::LinkedInPost);
});
@ -237,11 +237,10 @@
$response = $this->actingAs($this->user)->put(route('app.posts.update', $post), [
'status' => 'draft',
'synced' => true,
'content' => 'Test content',
'platforms' => [
[
'id' => $postPlatform->id,
'content' => 'Test content',
'content_type' => ContentType::LinkedInPost->value,
],
],
@ -258,22 +257,21 @@
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
'status' => PostStatus::Draft,
'content' => 'Test content',
'scheduled_at' => now()->addDays(7),
]);
$postPlatform = PostPlatform::factory()->create([
'post_id' => $post->id,
'social_account_id' => $this->socialAccount->id,
'content' => 'Test content',
]);
$response = $this->actingAs($this->user)->put(route('app.posts.update', $post), [
'status' => 'publishing',
'synced' => true,
'content' => 'Test content',
'platforms' => [
[
'id' => $postPlatform->id,
'content' => 'Test content',
'content_type' => ContentType::LinkedInPost->value,
],
],
@ -395,11 +393,9 @@
$response = $this->actingAs($this->user)->put(route('app.posts.update', $post), [
'status' => 'draft',
'synced' => true,
'platforms' => [
[
'id' => $postPlatform->id,
'content' => 'Test content',
'content_type' => ContentType::LinkedInPost->value,
],
],
@ -433,11 +429,9 @@
$response = $this->actingAs($this->user)->put(route('app.posts.update', $post), [
'status' => 'draft',
'synced' => true,
'platforms' => [
[
'id' => $postPlatform->id,
'content' => 'Test content',
'content_type' => ContentType::LinkedInPost->value,
],
],
@ -472,11 +466,9 @@
// Update with different labels
$response = $this->actingAs($this->user)->put(route('app.posts.update', $post), [
'status' => 'draft',
'synced' => true,
'platforms' => [
[
'id' => $postPlatform->id,
'content' => 'Test content',
'content_type' => ContentType::LinkedInPost->value,
],
],
@ -504,11 +496,9 @@
$response = $this->actingAs($this->user)->put(route('app.posts.update', $post), [
'status' => 'draft',
'synced' => true,
'platforms' => [
[
'id' => $postPlatform->id,
'content' => 'Test content',
'content_type' => ContentType::LinkedInPost->value,
],
],

View file

@ -17,22 +17,20 @@
$this->user->update(['current_workspace_id' => $this->workspace->id]);
});
test('search returns matching posts by platform content', function () {
test('search returns matching posts by content', function () {
$account = SocialAccount::factory()->create(['workspace_id' => $this->workspace->id]);
$matchingPost = Post::factory()->create(['workspace_id' => $this->workspace->id, 'user_id' => $this->user->id]);
$matchingPost = Post::factory()->create(['workspace_id' => $this->workspace->id, 'user_id' => $this->user->id, 'content' => 'Hello marketing world']);
PostPlatform::factory()->create([
'post_id' => $matchingPost->id,
'social_account_id' => $account->id,
'content' => 'Hello marketing world',
'enabled' => true,
]);
$nonMatchingPost = Post::factory()->create(['workspace_id' => $this->workspace->id, 'user_id' => $this->user->id]);
$nonMatchingPost = Post::factory()->create(['workspace_id' => $this->workspace->id, 'user_id' => $this->user->id, 'content' => 'Something else entirely']);
PostPlatform::factory()->create([
'post_id' => $nonMatchingPost->id,
'social_account_id' => $account->id,
'content' => 'Something else entirely',
'enabled' => true,
]);
@ -48,11 +46,10 @@
test('search with no matches returns empty', function () {
$account = SocialAccount::factory()->create(['workspace_id' => $this->workspace->id]);
$post = Post::factory()->create(['workspace_id' => $this->workspace->id, 'user_id' => $this->user->id]);
$post = Post::factory()->create(['workspace_id' => $this->workspace->id, 'user_id' => $this->user->id, 'content' => 'Hello world']);
PostPlatform::factory()->create([
'post_id' => $post->id,
'social_account_id' => $account->id,
'content' => 'Hello world',
'enabled' => true,
]);
@ -88,11 +85,10 @@
test('search is case insensitive', function () {
$account = SocialAccount::factory()->create(['workspace_id' => $this->workspace->id]);
$post = Post::factory()->create(['workspace_id' => $this->workspace->id, 'user_id' => $this->user->id]);
$post = Post::factory()->create(['workspace_id' => $this->workspace->id, 'user_id' => $this->user->id, 'content' => 'MARKETING CAMPAIGN']);
PostPlatform::factory()->create([
'post_id' => $post->id,
'social_account_id' => $account->id,
'content' => 'MARKETING CAMPAIGN',
'enabled' => true,
]);

View file

@ -5,7 +5,6 @@
use App\Enums\PostPlatform\ContentType;
use App\Enums\SocialAccount\Platform;
use App\Exceptions\TokenExpiredException;
use App\Models\Media;
use App\Models\Post;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
@ -21,11 +20,10 @@
'platform_user_id' => '12345678',
'access_token' => 'test-token',
]);
$this->post = Post::factory()->create(['workspace_id' => $this->workspace->id]);
$this->post = Post::factory()->create(['workspace_id' => $this->workspace->id, 'content' => 'Test caption']);
$this->postPlatform = PostPlatform::factory()->instagram()->create([
'post_id' => $this->post->id,
'social_account_id' => $this->socialAccount->id,
'content' => 'Test caption',
'content_type' => ContentType::InstagramFeed,
]);
});
@ -45,11 +43,10 @@
'*/post-123*' => Http::response(['permalink' => 'https://instagram.com/p/abc123'], 200),
]);
Media::factory()->create([
'mediable_type' => 'postPlatform',
'mediable_id' => $this->postPlatform->id,
'mime_type' => 'image/jpeg',
]);
$this->post->update([
'media' => [
['id' => 'test-img', 'path' => 'medias/test.jpg', 'url' => 'https://example.com/medias/test.jpg', 'mime_type' => 'image/jpeg', 'original_filename' => 'test.jpg'],
]]);
$publisher = new InstagramPublisher;
$result = $publisher->publish($this->postPlatform);
@ -68,11 +65,10 @@
'*/reel-123*' => Http::response(['permalink' => 'https://instagram.com/reel/abc123'], 200),
]);
Media::factory()->create([
'mediable_type' => 'postPlatform',
'mediable_id' => $this->postPlatform->id,
'mime_type' => 'video/mp4',
]);
$this->post->update([
'media' => [
['id' => 'test-vid', 'path' => 'medias/test.mp4', 'url' => 'https://example.com/medias/test.mp4', 'mime_type' => 'video/mp4', 'original_filename' => 'test.mp4'],
]]);
$publisher = new InstagramPublisher;
$result = $publisher->publish($this->postPlatform);
@ -90,11 +86,10 @@
'*/story-123*' => Http::response(['permalink' => 'https://instagram.com/stories/abc123'], 200),
]);
Media::factory()->create([
'mediable_type' => 'postPlatform',
'mediable_id' => $this->postPlatform->id,
'mime_type' => 'image/jpeg',
]);
$this->post->update([
'media' => [
['id' => 'test-img', 'path' => 'medias/test.jpg', 'url' => 'https://example.com/medias/test.jpg', 'mime_type' => 'image/jpeg', 'original_filename' => 'test.jpg'],
]]);
$publisher = new InstagramPublisher;
$result = $publisher->publish($this->postPlatform);
@ -113,11 +108,10 @@
], 400),
]);
Media::factory()->create([
'mediable_type' => 'postPlatform',
'mediable_id' => $this->postPlatform->id,
'mime_type' => 'image/jpeg',
]);
$this->post->update([
'media' => [
['id' => 'test-img', 'path' => 'medias/test.jpg', 'url' => 'https://example.com/medias/test.jpg', 'mime_type' => 'image/jpeg', 'original_filename' => 'test.jpg'],
]]);
$publisher = new InstagramPublisher;
@ -135,11 +129,10 @@
], 400),
]);
Media::factory()->create([
'mediable_type' => 'postPlatform',
'mediable_id' => $this->postPlatform->id,
'mime_type' => 'image/jpeg',
]);
$this->post->update([
'media' => [
['id' => 'test-img', 'path' => 'medias/test.jpg', 'url' => 'https://example.com/medias/test.jpg', 'mime_type' => 'image/jpeg', 'original_filename' => 'test.jpg'],
]]);
$publisher = new InstagramPublisher;

View file

@ -21,11 +21,10 @@
'access_token' => 'test-token',
'token_expires_at' => now()->addDays(30),
]);
$this->post = Post::factory()->create(['workspace_id' => $this->workspace->id]);
$this->post = Post::factory()->create(['workspace_id' => $this->workspace->id, 'content' => 'Test LinkedIn post']);
$this->postPlatform = PostPlatform::factory()->create([
'post_id' => $this->post->id,
'social_account_id' => $this->socialAccount->id,
'content' => 'Test LinkedIn post',
'content_type' => ContentType::LinkedInPost,
]);
});

View file

@ -5,7 +5,6 @@
use App\Enums\PostPlatform\ContentType;
use App\Enums\SocialAccount\Platform;
use App\Exceptions\TokenExpiredException;
use App\Models\Media;
use App\Models\Post;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
@ -28,6 +27,7 @@
$this->post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
'content' => 'Hello from Bluesky!',
]);
$this->postPlatform = PostPlatform::factory()->create([
@ -35,7 +35,6 @@
'social_account_id' => $this->socialAccount->id,
'platform' => Platform::Bluesky,
'content_type' => ContentType::BlueskyPost,
'content' => 'Hello from Bluesky!',
]);
$this->publisher = new BlueskyPublisher;
@ -63,7 +62,7 @@
});
test('bluesky publisher parses URLs as facets', function () {
$this->postPlatform->update(['content' => 'Check out https://example.com for more info!']);
$this->post->update(['content' => 'Check out https://example.com for more info!']);
Http::fake([
'https://bsky.social/xrpc/com.atproto.repo.createRecord' => Http::response([
@ -84,7 +83,7 @@
});
test('bluesky publisher parses hashtags as facets', function () {
$this->postPlatform->update(['content' => 'Hello #bluesky #test']);
$this->post->update(['content' => 'Hello #bluesky #test']);
Http::fake([
'https://bsky.social/xrpc/com.atproto.repo.createRecord' => Http::response([
@ -104,15 +103,16 @@
test('bluesky publisher uploads images', function () {
// Create a media item through the PostPlatform's media() relation
$this->postPlatform->media()->create([
'collection' => 'default',
'type' => 'image',
'path' => 'media/2026-01/test-image.jpg',
'original_filename' => 'test.jpg',
'mime_type' => 'image/jpeg',
'size' => 12345,
'order' => 0,
'meta' => ['width' => 1920, 'height' => 1080],
$this->post->update([
'media' => [
[
'id' => 'test-media-id',
'path' => 'media/2026-01/test-image.jpg',
'url' => 'https://example.com/media/2026-01/test-image.jpg',
'mime_type' => 'image/jpeg',
'original_filename' => 'test.jpg',
],
],
]);
Http::fake([
@ -193,15 +193,16 @@
$tempFile = tempnam(sys_get_temp_dir(), 'bsky_test_');
file_put_contents($tempFile, str_repeat('x', 1024));
$this->postPlatform->media()->create([
'collection' => 'default',
'type' => 'image',
'path' => 'media/2026-01/test-image.jpg',
'original_filename' => 'test.jpg',
'mime_type' => 'image/jpeg',
'size' => 12345,
'order' => 0,
'meta' => ['width' => 1920, 'height' => 1080],
$this->post->update([
'media' => [
[
'id' => 'test-media-id',
'path' => 'media/2026-01/test-image.jpg',
'url' => 'https://example.com/media/2026-01/test-image.jpg',
'mime_type' => 'image/jpeg',
'original_filename' => 'test.jpg',
],
],
]);
$this->mock(MediaOptimizer::class)
@ -241,15 +242,16 @@
});
test('bluesky publisher handles media download failure gracefully', function () {
$this->postPlatform->media()->create([
'collection' => 'default',
'type' => 'image',
'path' => 'media/2026-01/test-image.jpg',
'original_filename' => 'test.jpg',
'mime_type' => 'image/jpeg',
'size' => 12345,
'order' => 0,
'meta' => ['width' => 1920, 'height' => 1080],
$this->post->update([
'media' => [
[
'id' => 'test-media-id',
'path' => 'media/2026-01/test-image.jpg',
'url' => 'https://example.com/media/2026-01/test-image.jpg',
'mime_type' => 'image/jpeg',
'original_filename' => 'test.jpg',
],
],
]);
Http::fake(function ($request) {
@ -284,19 +286,17 @@
});
test('bluesky publisher limits images to 4', function () {
// Create 6 media items through the PostPlatform's media() relation
$mediaItems = [];
for ($i = 0; $i < 6; $i++) {
$this->postPlatform->media()->create([
'collection' => 'default',
'type' => 'image',
$mediaItems[] = [
'id' => "test-media-{$i}",
'path' => "media/2026-01/test-image-{$i}.jpg",
'original_filename' => "test-{$i}.jpg",
'url' => "https://example.com/media/2026-01/test-image-{$i}.jpg",
'mime_type' => 'image/jpeg',
'size' => 12345,
'order' => $i,
'meta' => ['width' => 1920, 'height' => 1080],
]);
'original_filename' => "test-{$i}.jpg",
];
}
$this->post->update(['media' => $mediaItems]);
Http::fake([
'https://bsky.social/xrpc/com.atproto.repo.uploadBlob' => Http::response([

View file

@ -83,14 +83,13 @@ public function callValidateContentLength(PostPlatform $postPlatform): void
$user = User::factory()->create();
$workspace = Workspace::factory()->create(['user_id' => $user->id]);
$socialAccount = SocialAccount::factory()->linkedin()->create(['workspace_id' => $workspace->id]);
$post = Post::factory()->create(['workspace_id' => $workspace->id, 'user_id' => $user->id]);
$post = Post::factory()->create(['workspace_id' => $workspace->id, 'user_id' => $user->id, 'content' => str_repeat('a', 100)]);
$postPlatform = PostPlatform::factory()->create([
'post_id' => $post->id,
'social_account_id' => $socialAccount->id,
'platform' => Platform::LinkedIn,
'content_type' => ContentType::LinkedInPost,
'content' => str_repeat('a', 100), // well within LinkedIn's 3000 limit
]);
expect(fn () => $this->client->callValidateContentLength($postPlatform))->not->toThrow(Exception::class);
@ -100,14 +99,13 @@ public function callValidateContentLength(PostPlatform $postPlatform): void
$user = User::factory()->create();
$workspace = Workspace::factory()->create(['user_id' => $user->id]);
$socialAccount = SocialAccount::factory()->linkedin()->create(['workspace_id' => $workspace->id]);
$post = Post::factory()->create(['workspace_id' => $workspace->id, 'user_id' => $user->id]);
$post = Post::factory()->create(['workspace_id' => $workspace->id, 'user_id' => $user->id, 'content' => str_repeat('a', 4000)]);
$postPlatform = PostPlatform::factory()->create([
'post_id' => $post->id,
'social_account_id' => $socialAccount->id,
'platform' => Platform::LinkedIn,
'content_type' => ContentType::LinkedInPost,
'content' => str_repeat('a', 4000), // exceeds LinkedIn's 3000 limit
]);
expect(fn () => $this->client->callValidateContentLength($postPlatform))

View file

@ -31,6 +31,7 @@
$this->post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
'content' => 'Check out this Facebook post!',
]);
$this->postPlatform = PostPlatform::factory()->facebook()->create([
@ -38,7 +39,6 @@
'social_account_id' => $this->socialAccount->id,
'platform' => Platform::Facebook,
'content_type' => ContentType::FacebookPost,
'content' => 'Check out this Facebook post!',
]);
$this->publisher = new FacebookPublisher;
@ -65,14 +65,16 @@
});
test('facebook publisher can publish single image post', function () {
$this->postPlatform->media()->create([
'collection' => 'default',
'type' => 'image',
'path' => 'media/2026-01/image.jpg',
'original_filename' => 'image.jpg',
'mime_type' => 'image/jpeg',
'size' => 512000,
'order' => 0,
$this->post->update([
'media' => [
[
'id' => 'test-media-id',
'path' => 'media/2026-01/image.jpg',
'url' => 'https://example.com/media/2026-01/image.jpg',
'mime_type' => 'image/jpeg',
'original_filename' => 'image.jpg',
],
],
]);
Http::fake([
@ -94,18 +96,18 @@
});
test('facebook publisher can publish multi image post', function () {
// Create 3 images
$mediaItems = [];
for ($i = 1; $i <= 3; $i++) {
$this->postPlatform->media()->create([
'collection' => 'default',
'type' => 'image',
$mediaItems[] = [
'id' => "test-media-{$i}",
'path' => "media/2026-01/image{$i}.jpg",
'original_filename' => "image{$i}.jpg",
'url' => "https://example.com/media/2026-01/image{$i}.jpg",
'mime_type' => 'image/jpeg',
'size' => 512000,
'order' => $i - 1,
]);
'original_filename' => "image{$i}.jpg",
];
}
$this->post->update([
'media' => $mediaItems]);
Http::fake([
'*/page_123/photos' => Http::sequence()
@ -128,14 +130,16 @@
});
test('facebook publisher can publish video post', function () {
$this->postPlatform->media()->create([
'collection' => 'default',
'type' => 'video',
'path' => 'media/2026-01/video.mp4',
'original_filename' => 'video.mp4',
'mime_type' => 'video/mp4',
'size' => 10240000,
'order' => 0,
$this->post->update([
'media' => [
[
'id' => 'test-media-video',
'path' => 'media/2026-01/video.mp4',
'url' => 'https://example.com/media/2026-01/video.mp4',
'mime_type' => 'video/mp4',
'original_filename' => 'video.mp4',
],
],
]);
Http::fake([
@ -159,14 +163,18 @@
test('facebook publisher can publish reel', function () {
$this->postPlatform->update(['content_type' => ContentType::FacebookReel]);
$this->postPlatform->media()->create([
'collection' => 'default',
'type' => 'video',
'path' => 'media/2026-01/reel.mp4',
'original_filename' => 'reel.mp4',
'mime_type' => 'video/mp4',
'size' => 5120000,
'order' => 0,
$this->post->update([
'media' => [
[
'id' => 'test-media-reel',
'path' => 'media/2026-01/reel.mp4',
'url' => 'https://example.com/media/2026-01/reel.mp4',
'mime_type' => 'video/mp4',
'original_filename' => 'reel.mp4',
],
],
]);
Http::fake([
@ -187,14 +195,18 @@
test('facebook publisher can publish image story', function () {
$this->postPlatform->update(['content_type' => ContentType::FacebookStory]);
$this->postPlatform->media()->create([
'collection' => 'default',
'type' => 'image',
'path' => 'media/2026-01/story.jpg',
'original_filename' => 'story.jpg',
'mime_type' => 'image/jpeg',
'size' => 512000,
'order' => 0,
$this->post->update([
'media' => [
[
'id' => 'test-media-story',
'path' => 'media/2026-01/story.jpg',
'url' => 'https://example.com/media/2026-01/story.jpg',
'mime_type' => 'image/jpeg',
'original_filename' => 'story.jpg',
],
],
]);
Http::fake([
@ -216,14 +228,18 @@
test('facebook publisher can publish video story', function () {
$this->postPlatform->update(['content_type' => ContentType::FacebookStory]);
$this->postPlatform->media()->create([
'collection' => 'default',
'type' => 'video',
'path' => 'media/2026-01/story.mp4',
'original_filename' => 'story.mp4',
'mime_type' => 'video/mp4',
'size' => 5120000,
'order' => 0,
$this->post->update([
'media' => [
[
'id' => 'test-media-video-story',
'path' => 'media/2026-01/story.mp4',
'url' => 'https://example.com/media/2026-01/story.mp4',
'mime_type' => 'video/mp4',
'original_filename' => 'story.mp4',
],
],
]);
Http::fake([
@ -294,18 +310,18 @@
});
test('facebook publisher throws exception when multi image upload fails', function () {
// Create 3 images
$mediaItems = [];
for ($i = 1; $i <= 3; $i++) {
$this->postPlatform->media()->create([
'collection' => 'default',
'type' => 'image',
$mediaItems[] = [
'id' => "test-media-{$i}",
'path' => "media/2026-01/image{$i}.jpg",
'original_filename' => "image{$i}.jpg",
'url' => "https://example.com/media/2026-01/image{$i}.jpg",
'mime_type' => 'image/jpeg',
'size' => 512000,
'order' => $i - 1,
]);
'original_filename' => "image{$i}.jpg",
];
}
$this->post->update([
'media' => $mediaItems]);
Http::fake([
'*/page_123/photos' => Http::response([
@ -318,14 +334,16 @@
});
test('facebook publisher throws exception for unsupported media type', function () {
$this->postPlatform->media()->create([
'collection' => 'default',
'type' => 'document',
'path' => 'media/2026-01/doc.pdf',
'original_filename' => 'doc.pdf',
'mime_type' => 'application/pdf',
'size' => 512000,
'order' => 0,
$this->post->update([
'media' => [
[
'id' => 'test-media-doc',
'path' => 'media/2026-01/doc.pdf',
'url' => 'https://example.com/media/2026-01/doc.pdf',
'mime_type' => 'application/pdf',
'original_filename' => 'doc.pdf',
],
],
]);
expect(fn () => $this->publisher->publish($this->postPlatform))
@ -333,7 +351,7 @@
});
test('facebook publisher throws exception for text post with null content', function () {
$this->postPlatform->update(['content' => null]);
$this->post->update(['content' => null]);
expect(fn () => $this->publisher->publish($this->postPlatform))
->toThrow(Exception::class, 'Facebook text posts require content');
@ -342,14 +360,18 @@
test('facebook publisher cleans up temp files after reel upload', function () {
$this->postPlatform->update(['content_type' => ContentType::FacebookReel]);
$this->postPlatform->media()->create([
'collection' => 'default',
'type' => 'video',
'path' => 'media/2026-01/reel.mp4',
'original_filename' => 'reel.mp4',
'mime_type' => 'video/mp4',
'size' => 5120000,
'order' => 0,
$this->post->update([
'media' => [
[
'id' => 'test-media-reel',
'path' => 'media/2026-01/reel.mp4',
'url' => 'https://example.com/media/2026-01/reel.mp4',
'mime_type' => 'video/mp4',
'original_filename' => 'reel.mp4',
],
],
]);
Http::fake([
@ -370,16 +392,17 @@
});
test('facebook publisher can publish single image with null content', function () {
$this->postPlatform->update(['content' => null]);
$this->postPlatform->media()->create([
'collection' => 'default',
'type' => 'image',
'path' => 'media/2026-01/test-image.jpg',
'original_filename' => 'test.jpg',
'mime_type' => 'image/jpeg',
'size' => 12345,
'order' => 0,
$this->post->update([
'content' => null,
'media' => [
[
'id' => 'test-media-id',
'path' => 'media/2026-01/test-image.jpg',
'url' => 'https://example.com/media/2026-01/test-image.jpg',
'mime_type' => 'image/jpeg',
'original_filename' => 'test.jpg',
],
],
]);
Http::fake([

View file

@ -27,6 +27,7 @@
$this->post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
'content' => 'Hello from Instagram!',
]);
$this->postPlatform = PostPlatform::factory()->create([
@ -34,7 +35,6 @@
'social_account_id' => $this->socialAccount->id,
'platform' => Platform::Instagram,
'content_type' => ContentType::InstagramFeed,
'content' => 'Hello from Instagram!',
]);
$this->publisher = new InstagramPublisher;
@ -46,15 +46,16 @@
});
test('instagram publisher can publish single image', function () {
$this->postPlatform->media()->create([
'collection' => 'default',
'type' => 'image',
'path' => 'media/2026-01/test-image.jpg',
'original_filename' => 'test.jpg',
'mime_type' => 'image/jpeg',
'size' => 12345,
'order' => 0,
'meta' => ['width' => 1920, 'height' => 1080],
$this->post->update([
'media' => [
[
'id' => 'test-media-id',
'path' => 'media/2026-01/test-image.jpg',
'url' => 'https://example.com/media/2026-01/test-image.jpg',
'mime_type' => 'image/jpeg',
'original_filename' => 'test.jpg',
],
],
]);
Http::fake([
@ -87,15 +88,18 @@
test('instagram publisher can publish reel', function () {
$this->postPlatform->update(['content_type' => ContentType::InstagramReel]);
$this->postPlatform->media()->create([
'collection' => 'default',
'type' => 'video',
'path' => 'media/2026-01/test-video.mp4',
'original_filename' => 'test.mp4',
'mime_type' => 'video/mp4',
'size' => 1234567,
'order' => 0,
'meta' => ['width' => 1080, 'height' => 1920, 'duration' => 30],
$this->post->update([
'media' => [
[
'id' => 'test-media-video',
'path' => 'media/2026-01/test-video.mp4',
'url' => 'https://example.com/media/2026-01/test-video.mp4',
'mime_type' => 'video/mp4',
'original_filename' => 'test.mp4',
],
],
]);
Http::fake([
@ -127,14 +131,18 @@
test('instagram publisher can publish image story', function () {
$this->postPlatform->update(['content_type' => ContentType::InstagramStory]);
$this->postPlatform->media()->create([
'collection' => 'default',
'type' => 'image',
'path' => 'media/2026-01/story.jpg',
'original_filename' => 'story.jpg',
'mime_type' => 'image/jpeg',
'size' => 512000,
'order' => 0,
$this->post->update([
'media' => [
[
'id' => 'test-media-story',
'path' => 'media/2026-01/story.jpg',
'url' => 'https://example.com/media/2026-01/story.jpg',
'mime_type' => 'image/jpeg',
'original_filename' => 'story.jpg',
],
],
]);
Http::fake([
@ -164,14 +172,18 @@
test('instagram publisher can publish video story', function () {
$this->postPlatform->update(['content_type' => ContentType::InstagramStory]);
$this->postPlatform->media()->create([
'collection' => 'default',
'type' => 'video',
'path' => 'media/2026-01/story.mp4',
'original_filename' => 'story.mp4',
'mime_type' => 'video/mp4',
'size' => 5120000,
'order' => 0,
$this->post->update([
'media' => [
[
'id' => 'test-media-video-story',
'path' => 'media/2026-01/story.mp4',
'url' => 'https://example.com/media/2026-01/story.mp4',
'mime_type' => 'video/mp4',
'original_filename' => 'story.mp4',
],
],
]);
Http::fake([
@ -195,19 +207,18 @@
});
test('instagram publisher can publish carousel', function () {
// Create multiple media items
$mediaItems = [];
for ($i = 0; $i < 3; $i++) {
$this->postPlatform->media()->create([
'collection' => 'default',
'type' => 'image',
$mediaItems[] = [
'id' => "test-media-{$i}",
'path' => "media/2026-01/test-image-{$i}.jpg",
'original_filename' => "test-{$i}.jpg",
'url' => "https://example.com/media/2026-01/test-image-{$i}.jpg",
'mime_type' => 'image/jpeg',
'size' => 12345,
'order' => $i,
'meta' => ['width' => 1920, 'height' => 1080],
]);
'original_filename' => "test-{$i}.jpg",
];
}
$this->post->update([
'media' => $mediaItems]);
Http::fake([
'https://graph.instagram.com/v24.0/ig_123456789/media' => Http::sequence()
@ -233,24 +244,23 @@
});
test('instagram publisher can publish carousel with videos', function () {
// Create image and video mix
$this->postPlatform->media()->create([
'collection' => 'default',
'type' => 'image',
'path' => 'media/2026-01/test-image.jpg',
'original_filename' => 'test.jpg',
'mime_type' => 'image/jpeg',
'size' => 12345,
'order' => 0,
]);
$this->postPlatform->media()->create([
'collection' => 'default',
'type' => 'video',
'path' => 'media/2026-01/test-video.mp4',
'original_filename' => 'test.mp4',
'mime_type' => 'video/mp4',
'size' => 1234567,
'order' => 1,
$this->post->update([
'media' => [
[
'id' => 'test-media-image',
'path' => 'media/2026-01/test-image.jpg',
'url' => 'https://example.com/media/2026-01/test-image.jpg',
'mime_type' => 'image/jpeg',
'original_filename' => 'test.jpg',
],
[
'id' => 'test-media-video',
'path' => 'media/2026-01/test-video.mp4',
'url' => 'https://example.com/media/2026-01/test-video.mp4',
'mime_type' => 'video/mp4',
'original_filename' => 'test.mp4',
],
],
]);
Http::fake([
@ -278,14 +288,16 @@
});
test('instagram publisher throws exception on api error', function () {
$this->postPlatform->media()->create([
'collection' => 'default',
'type' => 'image',
'path' => 'media/2026-01/test-image.jpg',
'original_filename' => 'test.jpg',
'mime_type' => 'image/jpeg',
'size' => 12345,
'order' => 0,
$this->post->update([
'media' => [
[
'id' => 'test-media-id',
'path' => 'media/2026-01/test-image.jpg',
'url' => 'https://example.com/media/2026-01/test-image.jpg',
'mime_type' => 'image/jpeg',
'original_filename' => 'test.jpg',
],
],
]);
Http::fake([
@ -303,14 +315,16 @@
});
test('instagram publisher throws token expired exception on oauth error', function () {
$this->postPlatform->media()->create([
'collection' => 'default',
'type' => 'image',
'path' => 'media/2026-01/test-image.jpg',
'original_filename' => 'test.jpg',
'mime_type' => 'image/jpeg',
'size' => 12345,
'order' => 0,
$this->post->update([
'media' => [
[
'id' => 'test-media-id',
'path' => 'media/2026-01/test-image.jpg',
'url' => 'https://example.com/media/2026-01/test-image.jpg',
'mime_type' => 'image/jpeg',
'original_filename' => 'test.jpg',
],
],
]);
Http::fake([
@ -328,14 +342,16 @@
});
test('instagram publisher throws token expired exception on session expired subcode', function () {
$this->postPlatform->media()->create([
'collection' => 'default',
'type' => 'image',
'path' => 'media/2026-01/test-image.jpg',
'original_filename' => 'test.jpg',
'mime_type' => 'image/jpeg',
'size' => 12345,
'order' => 0,
$this->post->update([
'media' => [
[
'id' => 'test-media-id',
'path' => 'media/2026-01/test-image.jpg',
'url' => 'https://example.com/media/2026-01/test-image.jpg',
'mime_type' => 'image/jpeg',
'original_filename' => 'test.jpg',
],
],
]);
Http::fake([
@ -356,14 +372,16 @@
test('instagram publisher throws exception for unsupported content type', function () {
$this->postPlatform->update(['content_type' => ContentType::LinkedInPost]);
$this->postPlatform->media()->create([
'collection' => 'default',
'type' => 'image',
'path' => 'media/2026-01/test-image.jpg',
'original_filename' => 'test.jpg',
'mime_type' => 'image/jpeg',
'size' => 12345,
'order' => 0,
$this->post->update([
'media' => [
[
'id' => 'test-media-id',
'path' => 'media/2026-01/test-image.jpg',
'url' => 'https://example.com/media/2026-01/test-image.jpg',
'mime_type' => 'image/jpeg',
'original_filename' => 'test.jpg',
],
],
]);
expect(fn () => $this->publisher->publish($this->postPlatform))
@ -371,14 +389,16 @@
});
test('instagram publisher throws exception when no container id returned', function () {
$this->postPlatform->media()->create([
'collection' => 'default',
'type' => 'image',
'path' => 'media/2026-01/test-image.jpg',
'original_filename' => 'test.jpg',
'mime_type' => 'image/jpeg',
'size' => 12345,
'order' => 0,
$this->post->update([
'media' => [
[
'id' => 'test-media-id',
'path' => 'media/2026-01/test-image.jpg',
'url' => 'https://example.com/media/2026-01/test-image.jpg',
'mime_type' => 'image/jpeg',
'original_filename' => 'test.jpg',
],
],
]);
Http::fake([
@ -393,14 +413,16 @@
});
test('instagram publisher handles media processing error', function () {
$this->postPlatform->media()->create([
'collection' => 'default',
'type' => 'image',
'path' => 'media/2026-01/test-image.jpg',
'original_filename' => 'test.jpg',
'mime_type' => 'image/jpeg',
'size' => 12345,
'order' => 0,
$this->post->update([
'media' => [
[
'id' => 'test-media-id',
'path' => 'media/2026-01/test-image.jpg',
'url' => 'https://example.com/media/2026-01/test-image.jpg',
'mime_type' => 'image/jpeg',
'original_filename' => 'test.jpg',
],
],
]);
Http::fake([
@ -417,14 +439,16 @@
});
test('instagram publisher waits for media processing', function () {
$this->postPlatform->media()->create([
'collection' => 'default',
'type' => 'image',
'path' => 'media/2026-01/test-image.jpg',
'original_filename' => 'test.jpg',
'mime_type' => 'image/jpeg',
'size' => 12345,
'order' => 0,
$this->post->update([
'media' => [
[
'id' => 'test-media-id',
'path' => 'media/2026-01/test-image.jpg',
'url' => 'https://example.com/media/2026-01/test-image.jpg',
'mime_type' => 'image/jpeg',
'original_filename' => 'test.jpg',
],
],
]);
Http::fake([
@ -449,18 +473,18 @@
});
test('instagram publisher throws exception when all carousel items fail', function () {
// Create multiple media items
$mediaItems = [];
for ($i = 0; $i < 3; $i++) {
$this->postPlatform->media()->create([
'collection' => 'default',
'type' => 'image',
$mediaItems[] = [
'id' => "test-media-{$i}",
'path' => "media/2026-01/test-image-{$i}.jpg",
'original_filename' => "test-{$i}.jpg",
'url' => "https://example.com/media/2026-01/test-image-{$i}.jpg",
'mime_type' => 'image/jpeg',
'size' => 12345,
'order' => $i,
]);
'original_filename' => "test-{$i}.jpg",
];
}
$this->post->update([
'media' => $mediaItems]);
Http::fake([
'https://graph.instagram.com/v24.0/ig_123456789/media' => Http::response([
@ -473,16 +497,17 @@
});
test('instagram publisher can publish single image with null content', function () {
$this->postPlatform->update(['content' => null]);
$this->postPlatform->media()->create([
'collection' => 'default',
'type' => 'image',
'path' => 'media/2026-01/test-image.jpg',
'original_filename' => 'test.jpg',
'mime_type' => 'image/jpeg',
'size' => 12345,
'order' => 0,
$this->post->update([
'content' => null,
'media' => [
[
'id' => 'test-media-id',
'path' => 'media/2026-01/test-image.jpg',
'url' => 'https://example.com/media/2026-01/test-image.jpg',
'mime_type' => 'image/jpeg',
'original_filename' => 'test.jpg',
],
],
]);
Http::fake([
@ -507,19 +532,18 @@
});
test('instagram publisher can publish reel with null content', function () {
$this->postPlatform->update([
'content_type' => ContentType::InstagramReel,
$this->postPlatform->update(['content_type' => ContentType::InstagramReel]);
$this->post->update([
'content' => null,
]);
$this->postPlatform->media()->create([
'collection' => 'default',
'type' => 'video',
'path' => 'media/2026-01/test-video.mp4',
'original_filename' => 'test.mp4',
'mime_type' => 'video/mp4',
'size' => 1234567,
'order' => 0,
'media' => [
[
'id' => 'test-media-video',
'path' => 'media/2026-01/test-video.mp4',
'url' => 'https://example.com/media/2026-01/test-video.mp4',
'mime_type' => 'video/mp4',
'original_filename' => 'test.mp4',
],
],
]);
Http::fake([
@ -543,19 +567,25 @@
});
test('instagram publisher can publish carousel with null content', function () {
$this->postPlatform->update(['content' => null]);
for ($i = 0; $i < 2; $i++) {
$this->postPlatform->media()->create([
'collection' => 'default',
'type' => 'image',
'path' => "media/2026-01/test-image-{$i}.jpg",
'original_filename' => "test-{$i}.jpg",
'mime_type' => 'image/jpeg',
'size' => 12345,
'order' => $i,
]);
}
$this->post->update([
'content' => null,
'media' => [
[
'id' => 'test-media-0',
'path' => 'media/2026-01/test-image-0.jpg',
'url' => 'https://example.com/media/2026-01/test-image-0.jpg',
'mime_type' => 'image/jpeg',
'original_filename' => 'test-0.jpg',
],
[
'id' => 'test-media-1',
'path' => 'media/2026-01/test-image-1.jpg',
'url' => 'https://example.com/media/2026-01/test-image-1.jpg',
'mime_type' => 'image/jpeg',
'original_filename' => 'test-1.jpg',
],
],
]);
Http::fake([
'https://graph.instagram.com/v24.0/ig_123456789/media' => Http::sequence()
@ -579,16 +609,17 @@
});
test('instagram publisher can publish single image with empty string content', function () {
$this->postPlatform->update(['content' => '']);
$this->postPlatform->media()->create([
'collection' => 'default',
'type' => 'image',
'path' => 'media/2026-01/test-image.jpg',
'original_filename' => 'test.jpg',
'mime_type' => 'image/jpeg',
'size' => 12345,
'order' => 0,
$this->post->update([
'content' => '',
'media' => [
[
'id' => 'test-media-id',
'path' => 'media/2026-01/test-image.jpg',
'url' => 'https://example.com/media/2026-01/test-image.jpg',
'mime_type' => 'image/jpeg',
'original_filename' => 'test.jpg',
],
],
]);
Http::fake([
@ -613,15 +644,16 @@
test('instagram publisher routes feed video to reel', function () {
// InstagramFeed content type with a single video should route to publishReel (REELS media_type)
$this->postPlatform->media()->create([
'collection' => 'default',
'type' => 'video',
'path' => 'media/2026-01/feed-video.mp4',
'original_filename' => 'feed-video.mp4',
'mime_type' => 'video/mp4',
'size' => 2048000,
'order' => 0,
'meta' => ['width' => 1080, 'height' => 1920, 'duration' => 15],
$this->post->update([
'media' => [
[
'id' => 'test-media-feed-video',
'path' => 'media/2026-01/feed-video.mp4',
'url' => 'https://example.com/media/2026-01/feed-video.mp4',
'mime_type' => 'video/mp4',
'original_filename' => 'feed-video.mp4',
],
],
]);
Http::fake([
@ -651,14 +683,16 @@
});
test('instagram publisher handles publish failure', function () {
$this->postPlatform->media()->create([
'collection' => 'default',
'type' => 'image',
'path' => 'media/2026-01/test-image.jpg',
'original_filename' => 'test.jpg',
'mime_type' => 'image/jpeg',
'size' => 12345,
'order' => 0,
$this->post->update([
'media' => [
[
'id' => 'test-media-id',
'path' => 'media/2026-01/test-image.jpg',
'url' => 'https://example.com/media/2026-01/test-image.jpg',
'mime_type' => 'image/jpeg',
'original_filename' => 'test.jpg',
],
],
]);
Http::fake([

View file

@ -32,6 +32,7 @@
$this->post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
'content' => 'Hello from our LinkedIn Page!',
]);
$this->postPlatform = PostPlatform::factory()->create([
@ -39,7 +40,6 @@
'social_account_id' => $this->socialAccount->id,
'platform' => Platform::LinkedInPage,
'content_type' => ContentType::LinkedInPagePost,
'content' => 'Hello from our LinkedIn Page!',
]);
$this->publisher = new LinkedInPagePublisher;
@ -168,7 +168,7 @@
});
test('linkedin page publisher handles empty content', function () {
$this->postPlatform->update(['content' => '']);
$this->post->update(['content' => '']);
Http::fake([
'https://api.linkedin.com/rest/posts' => Http::response(null, 201, [
@ -219,15 +219,16 @@
});
test('linkedin page publisher can publish post with image using organization urn', function () {
$this->postPlatform->media()->create([
'collection' => 'default',
'type' => 'image',
'path' => 'media/2026-01/test-image.jpg',
'original_filename' => 'test.jpg',
'mime_type' => 'image/jpeg',
'size' => 512000,
'order' => 0,
'meta' => ['width' => 1920, 'height' => 1080],
$this->post->update([
'media' => [
[
'id' => 'test-media-id',
'path' => 'media/2026-01/test-image.jpg',
'url' => 'https://example.com/media/2026-01/test-image.jpg',
'mime_type' => 'image/jpeg',
'original_filename' => 'test.jpg',
],
],
]);
$uploadUrl = 'https://www.linkedin.com/dms/upload/v2/pic/0/OrgFake';

View file

@ -27,6 +27,7 @@
$this->post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
'content' => 'Hello from LinkedIn!',
]);
$this->postPlatform = PostPlatform::factory()->create([
@ -34,7 +35,6 @@
'social_account_id' => $this->socialAccount->id,
'platform' => Platform::LinkedIn,
'content_type' => ContentType::LinkedInPost,
'content' => 'Hello from LinkedIn!',
]);
$this->publisher = new LinkedInPublisher;
@ -142,7 +142,7 @@
});
test('linkedin publisher handles empty content', function () {
$this->postPlatform->update(['content' => '']);
$this->post->update(['content' => '']);
Http::fake([
'https://api.linkedin.com/rest/posts' => Http::response(null, 201, [
@ -167,15 +167,16 @@
});
test('linkedin publisher can publish post with image', function () {
$this->postPlatform->media()->create([
'collection' => 'default',
'type' => 'image',
'path' => 'media/2026-01/test-image.jpg',
'original_filename' => 'test.jpg',
'mime_type' => 'image/jpeg',
'size' => 512000,
'order' => 0,
'meta' => ['width' => 1920, 'height' => 1080],
$this->post->update([
'media' => [
[
'id' => 'test-media-id',
'path' => 'media/2026-01/test-image.jpg',
'url' => 'https://example.com/media/2026-01/test-image.jpg',
'mime_type' => 'image/jpeg',
'original_filename' => 'test.jpg',
],
],
]);
$uploadUrl = 'https://www.linkedin.com/dms/upload/v2/pic/0/C5622AQFake';
@ -217,19 +218,31 @@
test('linkedin publisher can publish carousel with multiple images', function () {
$this->postPlatform->update(['content_type' => ContentType::LinkedInCarousel]);
for ($i = 1; $i <= 3; $i++) {
$this->postPlatform->media()->create([
'collection' => 'default',
'type' => 'image',
'path' => "media/2026-01/carousel-{$i}.jpg",
'original_filename' => "carousel-{$i}.jpg",
'mime_type' => 'image/jpeg',
'size' => 256000,
'order' => $i - 1,
'meta' => ['width' => 1200, 'height' => 628],
]);
}
$this->post->update([
'media' => [
[
'id' => 'test-media-1',
'path' => 'media/2026-01/carousel-1.jpg',
'url' => 'https://example.com/media/2026-01/carousel-1.jpg',
'mime_type' => 'image/jpeg',
'original_filename' => 'carousel-1.jpg',
],
[
'id' => 'test-media-2',
'path' => 'media/2026-01/carousel-2.jpg',
'url' => 'https://example.com/media/2026-01/carousel-2.jpg',
'mime_type' => 'image/jpeg',
'original_filename' => 'carousel-2.jpg',
],
[
'id' => 'test-media-3',
'path' => 'media/2026-01/carousel-3.jpg',
'url' => 'https://example.com/media/2026-01/carousel-3.jpg',
'mime_type' => 'image/jpeg',
'original_filename' => 'carousel-3.jpg',
],
],
]);
$uploadUrls = [
'https://www.linkedin.com/dms/upload/v2/pic/carousel/1',
@ -292,15 +305,16 @@
});
test('linkedin publisher can publish post with video', function () {
$this->postPlatform->media()->create([
'collection' => 'default',
'type' => 'video',
'path' => 'media/2026-01/test-video.mp4',
'original_filename' => 'test-video.mp4',
'mime_type' => 'video/mp4',
'size' => 2 * 1024 * 1024,
'order' => 0,
'meta' => ['duration' => 30],
$this->post->update([
'media' => [
[
'id' => 'test-media-video',
'path' => 'media/2026-01/test-video.mp4',
'url' => 'https://example.com/media/2026-01/test-video.mp4',
'mime_type' => 'video/mp4',
'original_filename' => 'test-video.mp4',
],
],
]);
$chunkUploadUrl = 'https://www.linkedin.com/dms/upload/v2/chunk/video/1';

View file

@ -28,6 +28,7 @@
$this->post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
'content' => 'Hello from Mastodon!',
]);
$this->postPlatform = PostPlatform::factory()->create([
@ -35,7 +36,6 @@
'social_account_id' => $this->socialAccount->id,
'platform' => Platform::Mastodon,
'content_type' => ContentType::MastodonPost,
'content' => 'Hello from Mastodon!',
]);
$this->publisher = new MastodonPublisher;
@ -91,16 +91,16 @@
});
test('mastodon publisher uploads media', function () {
// Create a media item through the PostPlatform's media() relation
$this->postPlatform->media()->create([
'collection' => 'default',
'type' => 'image',
'path' => 'media/2026-01/test-image.jpg',
'original_filename' => 'test.jpg',
'mime_type' => 'image/jpeg',
'size' => 12345,
'order' => 0,
'meta' => ['width' => 1920, 'height' => 1080],
$this->post->update([
'media' => [
[
'id' => 'test-media-id',
'path' => 'media/2026-01/test-image.jpg',
'url' => 'https://example.com/media/2026-01/test-image.jpg',
'mime_type' => 'image/jpeg',
'original_filename' => 'test.jpg',
],
],
]);
Http::fake([
@ -172,19 +172,18 @@
});
test('mastodon publisher limits media to 4', function () {
// Create 6 media items through the PostPlatform's media() relation
$mediaItems = [];
for ($i = 0; $i < 6; $i++) {
$this->postPlatform->media()->create([
'collection' => 'default',
'type' => 'image',
$mediaItems[] = [
'id' => "test-media-{$i}",
'path' => "media/2026-01/test-image-{$i}.jpg",
'original_filename' => "test-{$i}.jpg",
'url' => "https://example.com/media/2026-01/test-image-{$i}.jpg",
'mime_type' => 'image/jpeg',
'size' => 12345,
'order' => $i,
'meta' => ['width' => 1920, 'height' => 1080],
]);
'original_filename' => "test-{$i}.jpg",
];
}
$this->post->update([
'media' => $mediaItems]);
Http::fake([
'https://mastodon.social/api/v1/media' => Http::response([
@ -206,7 +205,7 @@
});
test('mastodon publisher handles empty content', function () {
$this->postPlatform->update(['content' => '']);
$this->post->update(['content' => '']);
Http::fake([
'https://mastodon.social/api/v1/statuses' => Http::response([
@ -253,15 +252,16 @@
."\x05\x01\x01\x01\x01\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x01\x02\x03\x04\x05"
."\xFF\xDA\x00\x08\x01\x01\x00\x00\x3F\x00\xFB\xD3\xFF\xD9";
$this->postPlatform->media()->create([
'collection' => 'default',
'type' => 'image',
'path' => 'media/2026-01/test-image.jpg',
'original_filename' => 'test.jpg',
'mime_type' => 'image/jpeg',
'size' => 12345,
'order' => 0,
'meta' => ['width' => 1920, 'height' => 1080],
$this->post->update([
'media' => [
[
'id' => 'test-media-id',
'path' => 'media/2026-01/test-image.jpg',
'url' => 'https://example.com/media/2026-01/test-image.jpg',
'mime_type' => 'image/jpeg',
'original_filename' => 'test.jpg',
],
],
]);
$this->mock(MediaOptimizer::class)
@ -309,15 +309,16 @@
$optimizedFile = tempnam(sys_get_temp_dir(), 'masto_fail_opt_');
file_put_contents($optimizedFile, str_repeat('x', 512));
$this->postPlatform->media()->create([
'collection' => 'default',
'type' => 'image',
'path' => 'media/2026-01/failing-upload.jpg',
'original_filename' => 'failing-upload.jpg',
'mime_type' => 'image/jpeg',
'size' => 12345,
'order' => 0,
'meta' => ['width' => 800, 'height' => 600],
$this->post->update([
'media' => [
[
'id' => 'test-media-fail',
'path' => 'media/2026-01/failing-upload.jpg',
'url' => 'https://example.com/media/2026-01/failing-upload.jpg',
'mime_type' => 'image/jpeg',
'original_filename' => 'failing-upload.jpg',
],
],
]);
$this->mock(MediaOptimizer::class)

View file

@ -31,6 +31,7 @@
$this->post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
'content' => 'Check out this pin!',
]);
$this->postPlatform = PostPlatform::factory()->pinterest()->create([
@ -38,7 +39,6 @@
'social_account_id' => $this->socialAccount->id,
'platform' => Platform::Pinterest,
'content_type' => ContentType::PinterestPin,
'content' => 'Check out this pin!',
'meta' => ['board_id' => 'board_123'],
]);
@ -57,14 +57,16 @@
});
test('pinterest publisher can publish image pin', function () {
$this->postPlatform->media()->create([
'collection' => 'default',
'type' => 'image',
'path' => 'media/2026-01/image.jpg',
'original_filename' => 'image.jpg',
'mime_type' => 'image/jpeg',
'size' => 512000,
'order' => 0,
$this->post->update([
'media' => [
[
'id' => 'test-media-id',
'path' => 'media/2026-01/image.jpg',
'url' => 'https://example.com/media/2026-01/image.jpg',
'mime_type' => 'image/jpeg',
'original_filename' => 'image.jpg',
],
],
]);
Http::fake([
@ -95,14 +97,16 @@
$this->postPlatform->update(['meta' => []]);
$this->socialAccount->update(['meta' => []]);
$this->postPlatform->media()->create([
'collection' => 'default',
'type' => 'image',
'path' => 'media/2026-01/image.jpg',
'original_filename' => 'image.jpg',
'mime_type' => 'image/jpeg',
'size' => 512000,
'order' => 0,
$this->post->update([
'media' => [
[
'id' => 'test-media-id',
'path' => 'media/2026-01/image.jpg',
'url' => 'https://example.com/media/2026-01/image.jpg',
'mime_type' => 'image/jpeg',
'original_filename' => 'image.jpg',
],
],
]);
expect(fn () => $this->publisher->publish($this->postPlatform))
@ -112,14 +116,16 @@
test('pinterest publisher uses default board id from account', function () {
$this->postPlatform->update(['meta' => []]); // No board_id in post meta
$this->postPlatform->media()->create([
'collection' => 'default',
'type' => 'image',
'path' => 'media/2026-01/image.jpg',
'original_filename' => 'image.jpg',
'mime_type' => 'image/jpeg',
'size' => 512000,
'order' => 0,
$this->post->update([
'media' => [
[
'id' => 'test-media-id',
'path' => 'media/2026-01/image.jpg',
'url' => 'https://example.com/media/2026-01/image.jpg',
'mime_type' => 'image/jpeg',
'original_filename' => 'image.jpg',
],
],
]);
Http::fake([
@ -138,20 +144,23 @@
});
test('pinterest publisher can publish carousel', function () {
$mediaItems = [];
for ($i = 1; $i <= 3; $i++) {
$mediaItems[] = [
'id' => "test-media-{$i}",
'path' => "media/2026-01/image{$i}.jpg",
'url' => "https://example.com/media/2026-01/image{$i}.jpg",
'mime_type' => 'image/jpeg',
'original_filename' => "image{$i}.jpg",
];
}
$this->postPlatform->update(['content_type' => ContentType::PinterestCarousel]);
// Create 3 images for carousel
for ($i = 1; $i <= 3; $i++) {
$this->postPlatform->media()->create([
'collection' => 'default',
'type' => 'image',
'path' => "media/2026-01/image{$i}.jpg",
'original_filename' => "image{$i}.jpg",
'mime_type' => 'image/jpeg',
'size' => 512000,
'order' => $i - 1,
]);
}
$this->post->update([
'media' => $mediaItems,
]);
Http::fake([
'*/v5/pins' => Http::response([
@ -172,14 +181,16 @@
test('pinterest publisher throws exception for carousel with less than 2 images', function () {
$this->postPlatform->update(['content_type' => ContentType::PinterestCarousel]);
$this->postPlatform->media()->create([
'collection' => 'default',
'type' => 'image',
'path' => 'media/2026-01/image.jpg',
'original_filename' => 'image.jpg',
'mime_type' => 'image/jpeg',
'size' => 512000,
'order' => 0,
$this->post->update([
'media' => [
[
'id' => 'test-media-id',
'path' => 'media/2026-01/image.jpg',
'url' => 'https://example.com/media/2026-01/image.jpg',
'mime_type' => 'image/jpeg',
'original_filename' => 'image.jpg',
],
],
]);
expect(fn () => $this->publisher->publish($this->postPlatform))
@ -187,34 +198,39 @@
});
test('pinterest publisher throws exception for carousel with more than 5 images', function () {
$mediaItems = [];
for ($i = 1; $i <= 6; $i++) {
$mediaItems[] = [
'id' => "test-media-{$i}",
'path' => "media/2026-01/image{$i}.jpg",
'url' => "https://example.com/media/2026-01/image{$i}.jpg",
'mime_type' => 'image/jpeg',
'original_filename' => "image{$i}.jpg",
];
}
$this->postPlatform->update(['content_type' => ContentType::PinterestCarousel]);
// Create 6 images
for ($i = 1; $i <= 6; $i++) {
$this->postPlatform->media()->create([
'collection' => 'default',
'type' => 'image',
'path' => "media/2026-01/image{$i}.jpg",
'original_filename' => "image{$i}.jpg",
'mime_type' => 'image/jpeg',
'size' => 512000,
'order' => $i - 1,
]);
}
$this->post->update([
'media' => $mediaItems,
]);
expect(fn () => $this->publisher->publish($this->postPlatform))
->toThrow(Exception::class, 'Pinterest carousel requires 2-5 images');
});
test('pinterest publisher throws exception on api error', function () {
$this->postPlatform->media()->create([
'collection' => 'default',
'type' => 'image',
'path' => 'media/2026-01/image.jpg',
'original_filename' => 'image.jpg',
'mime_type' => 'image/jpeg',
'size' => 512000,
'order' => 0,
$this->post->update([
'media' => [
[
'id' => 'test-media-id',
'path' => 'media/2026-01/image.jpg',
'url' => 'https://example.com/media/2026-01/image.jpg',
'mime_type' => 'image/jpeg',
'original_filename' => 'image.jpg',
],
],
]);
Http::fake([
@ -230,14 +246,16 @@
});
test('pinterest publisher throws token expired exception on auth error', function () {
$this->postPlatform->media()->create([
'collection' => 'default',
'type' => 'image',
'path' => 'media/2026-01/image.jpg',
'original_filename' => 'image.jpg',
'mime_type' => 'image/jpeg',
'size' => 512000,
'order' => 0,
$this->post->update([
'media' => [
[
'id' => 'test-media-id',
'path' => 'media/2026-01/image.jpg',
'url' => 'https://example.com/media/2026-01/image.jpg',
'mime_type' => 'image/jpeg',
'original_filename' => 'image.jpg',
],
],
]);
Http::fake([
@ -255,14 +273,16 @@
test('pinterest publisher refreshes token when expired', function () {
$this->socialAccount->update(['token_expires_at' => now()->subHour()]);
$this->postPlatform->media()->create([
'collection' => 'default',
'type' => 'image',
'path' => 'media/2026-01/image.jpg',
'original_filename' => 'image.jpg',
'mime_type' => 'image/jpeg',
'size' => 512000,
'order' => 0,
$this->post->update([
'media' => [
[
'id' => 'test-media-id',
'path' => 'media/2026-01/image.jpg',
'url' => 'https://example.com/media/2026-01/image.jpg',
'mime_type' => 'image/jpeg',
'original_filename' => 'image.jpg',
],
],
]);
Http::fake([
@ -296,14 +316,16 @@
],
]);
$this->postPlatform->media()->create([
'collection' => 'default',
'type' => 'image',
'path' => 'media/2026-01/image.jpg',
'original_filename' => 'image.jpg',
'mime_type' => 'image/jpeg',
'size' => 512000,
'order' => 0,
$this->post->update([
'media' => [
[
'id' => 'test-media-id',
'path' => 'media/2026-01/image.jpg',
'url' => 'https://example.com/media/2026-01/image.jpg',
'mime_type' => 'image/jpeg',
'original_filename' => 'image.jpg',
],
],
]);
Http::fake([
@ -341,14 +363,18 @@
test('pinterest publisher can publish video pin', function () {
$this->postPlatform->update(['content_type' => ContentType::PinterestVideoPin]);
$this->postPlatform->media()->create([
'collection' => 'default',
'type' => 'video',
'path' => 'media/2026-01/video.mp4',
'original_filename' => 'video.mp4',
'mime_type' => 'video/mp4',
'size' => 5120000,
'order' => 0,
$this->post->update([
'media' => [
[
'id' => 'test-media-video',
'path' => 'media/2026-01/video.mp4',
'url' => 'https://example.com/media/2026-01/video.mp4',
'mime_type' => 'video/mp4',
'original_filename' => 'video.mp4',
],
],
]);
$s3UploadUrl = 'https://pinterest-media-upload.s3.amazonaws.com/upload';
@ -403,14 +429,16 @@
test('pinterest publisher throws exception for unsupported content type', function () {
$this->postPlatform->update(['content_type' => ContentType::InstagramFeed]);
$this->postPlatform->media()->create([
'collection' => 'default',
'type' => 'image',
'path' => 'media/2026-01/image.jpg',
'original_filename' => 'image.jpg',
'mime_type' => 'image/jpeg',
'size' => 512000,
'order' => 0,
$this->post->update([
'media' => [
[
'id' => 'test-media-id',
'path' => 'media/2026-01/image.jpg',
'url' => 'https://example.com/media/2026-01/image.jpg',
'mime_type' => 'image/jpeg',
'original_filename' => 'image.jpg',
],
],
]);
expect(fn () => $this->publisher->publish($this->postPlatform))

View file

@ -27,6 +27,7 @@
$this->post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
'content' => 'Hello from Threads!',
]);
$this->postPlatform = PostPlatform::factory()->create([
@ -34,7 +35,6 @@
'social_account_id' => $this->socialAccount->id,
'platform' => Platform::Threads,
'content_type' => ContentType::ThreadsPost,
'content' => 'Hello from Threads!',
]);
$this->publisher = new ThreadsPublisher;
@ -67,15 +67,16 @@
});
test('threads publisher can publish image post', function () {
$this->postPlatform->media()->create([
'collection' => 'default',
'type' => 'image',
'path' => 'media/2026-01/test-image.jpg',
'original_filename' => 'test.jpg',
'mime_type' => 'image/jpeg',
'size' => 12345,
'order' => 0,
'meta' => ['width' => 1920, 'height' => 1080],
$this->post->update([
'media' => [
[
'id' => 'test-media-id',
'path' => 'media/2026-01/test-image.jpg',
'url' => 'https://example.com/media/2026-01/test-image.jpg',
'mime_type' => 'image/jpeg',
'original_filename' => 'test.jpg',
],
],
]);
Http::fake([
@ -103,15 +104,16 @@
});
test('threads publisher can publish video post', function () {
$this->postPlatform->media()->create([
'collection' => 'default',
'type' => 'video',
'path' => 'media/2026-01/test-video.mp4',
'original_filename' => 'test.mp4',
'mime_type' => 'video/mp4',
'size' => 1234567,
'order' => 0,
'meta' => ['width' => 1080, 'height' => 1920, 'duration' => 30],
$this->post->update([
'media' => [
[
'id' => 'test-media-video',
'path' => 'media/2026-01/test-video.mp4',
'url' => 'https://example.com/media/2026-01/test-video.mp4',
'mime_type' => 'video/mp4',
'original_filename' => 'test.mp4',
],
],
]);
Http::fake([
@ -139,19 +141,18 @@
});
test('threads publisher can publish carousel', function () {
// Create multiple media items
$mediaItems = [];
for ($i = 0; $i < 3; $i++) {
$this->postPlatform->media()->create([
'collection' => 'default',
'type' => 'image',
$mediaItems[] = [
'id' => "test-media-{$i}",
'path' => "media/2026-01/test-image-{$i}.jpg",
'original_filename' => "test-{$i}.jpg",
'url' => "https://example.com/media/2026-01/test-image-{$i}.jpg",
'mime_type' => 'image/jpeg',
'size' => 12345,
'order' => $i,
'meta' => ['width' => 1920, 'height' => 1080],
]);
'original_filename' => "test-{$i}.jpg",
];
}
$this->post->update([
'media' => $mediaItems]);
Http::fake([
'https://graph.threads.net/v1.0/123456789/threads' => Http::response([
@ -237,15 +238,16 @@
});
test('threads publisher waits for media processing', function () {
$this->postPlatform->media()->create([
'collection' => 'default',
'type' => 'image',
'path' => 'media/2026-01/test-image.jpg',
'original_filename' => 'test.jpg',
'mime_type' => 'image/jpeg',
'size' => 12345,
'order' => 0,
'meta' => ['width' => 1920, 'height' => 1080],
$this->post->update([
'media' => [
[
'id' => 'test-media-id',
'path' => 'media/2026-01/test-image.jpg',
'url' => 'https://example.com/media/2026-01/test-image.jpg',
'mime_type' => 'image/jpeg',
'original_filename' => 'test.jpg',
],
],
]);
Http::fake([
@ -270,15 +272,16 @@
});
test('threads publisher handles media processing error', function () {
$this->postPlatform->media()->create([
'collection' => 'default',
'type' => 'image',
'path' => 'media/2026-01/test-image.jpg',
'original_filename' => 'test.jpg',
'mime_type' => 'image/jpeg',
'size' => 12345,
'order' => 0,
'meta' => ['width' => 1920, 'height' => 1080],
$this->post->update([
'media' => [
[
'id' => 'test-media-id',
'path' => 'media/2026-01/test-image.jpg',
'url' => 'https://example.com/media/2026-01/test-image.jpg',
'mime_type' => 'image/jpeg',
'original_filename' => 'test.jpg',
],
],
]);
Http::fake([
@ -296,23 +299,24 @@
});
test('threads publisher throws exception for text post with null content', function () {
$this->postPlatform->update(['content' => null]);
$this->post->update(['content' => null]);
expect(fn () => $this->publisher->publish($this->postPlatform))
->toThrow(Exception::class, 'Threads text posts require content');
});
test('threads publisher can publish image with null content', function () {
$this->postPlatform->update(['content' => null]);
$this->postPlatform->media()->create([
'collection' => 'default',
'type' => 'image',
'path' => 'media/2026-01/test-image.jpg',
'original_filename' => 'test.jpg',
'mime_type' => 'image/jpeg',
'size' => 12345,
'order' => 0,
$this->post->update([
'content' => null,
'media' => [
[
'id' => 'test-media-id',
'path' => 'media/2026-01/test-image.jpg',
'url' => 'https://example.com/media/2026-01/test-image.jpg',
'mime_type' => 'image/jpeg',
'original_filename' => 'test.jpg',
],
],
]);
Http::fake([
@ -336,18 +340,18 @@
});
test('threads publisher throws exception when all carousel items fail', function () {
$mediaItems = [];
for ($i = 0; $i < 3; $i++) {
$this->postPlatform->media()->create([
'collection' => 'default',
'type' => 'image',
$mediaItems[] = [
'id' => "test-media-{$i}",
'path' => "media/2026-01/fail-image-{$i}.jpg",
'original_filename' => "fail-{$i}.jpg",
'url' => "https://example.com/media/2026-01/fail-image-{$i}.jpg",
'mime_type' => 'image/jpeg',
'size' => 12345,
'order' => $i,
'meta' => ['width' => 1920, 'height' => 1080],
]);
'original_filename' => "fail-{$i}.jpg",
];
}
$this->post->update([
'media' => $mediaItems]);
Http::fake([
'https://graph.threads.net/v1.0/123456789/threads' => Http::response([
@ -364,16 +368,17 @@
});
test('threads publisher can publish video with null content', function () {
$this->postPlatform->update(['content' => null]);
$this->postPlatform->media()->create([
'collection' => 'default',
'type' => 'video',
'path' => 'media/2026-01/test-video.mp4',
'original_filename' => 'test.mp4',
'mime_type' => 'video/mp4',
'size' => 1234567,
'order' => 0,
$this->post->update([
'content' => null,
'media' => [
[
'id' => 'test-media-video',
'path' => 'media/2026-01/test-video.mp4',
'url' => 'https://example.com/media/2026-01/test-video.mp4',
'mime_type' => 'video/mp4',
'original_filename' => 'test.mp4',
],
],
]);
Http::fake([

View file

@ -28,6 +28,7 @@
$this->post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
'content' => 'Check out this TikTok video!',
]);
$this->postPlatform = PostPlatform::factory()->tiktok()->create([
@ -35,7 +36,6 @@
'social_account_id' => $this->socialAccount->id,
'platform' => Platform::TikTok,
'content_type' => ContentType::TikTokVideo,
'content' => 'Check out this TikTok video!',
]);
$this->publisher = new TikTokPublisher;
@ -47,14 +47,16 @@
});
test('tiktok publisher can publish video', function () {
$this->postPlatform->media()->create([
'collection' => 'default',
'type' => 'video',
'path' => 'media/2026-01/test-video.mp4',
'original_filename' => 'test-video.mp4',
'mime_type' => 'video/mp4',
'size' => 1024000,
'order' => 0,
$this->post->update([
'media' => [
[
'id' => 'test-media-video',
'path' => 'media/2026-01/test-video.mp4',
'url' => 'https://example.com/media/2026-01/test-video.mp4',
'mime_type' => 'video/mp4',
'original_filename' => 'test-video.mp4',
],
],
]);
Http::fake([
@ -82,14 +84,16 @@
});
test('tiktok publisher can publish photos', function () {
$this->postPlatform->media()->create([
'collection' => 'default',
'type' => 'image',
'path' => 'media/2026-01/image1.jpg',
'original_filename' => 'image1.jpg',
'mime_type' => 'image/jpeg',
'size' => 512000,
'order' => 0,
$this->post->update([
'media' => [
[
'id' => 'test-media-image',
'path' => 'media/2026-01/image1.jpg',
'url' => 'https://example.com/media/2026-01/image1.jpg',
'mime_type' => 'image/jpeg',
'original_filename' => 'image1.jpg',
],
],
]);
Http::fake([
@ -115,14 +119,16 @@
});
test('tiktok publisher throws exception on api error', function () {
$this->postPlatform->media()->create([
'collection' => 'default',
'type' => 'video',
'path' => 'media/2026-01/test-video.mp4',
'original_filename' => 'test-video.mp4',
'mime_type' => 'video/mp4',
'size' => 1024000,
'order' => 0,
$this->post->update([
'media' => [
[
'id' => 'test-media-video',
'path' => 'media/2026-01/test-video.mp4',
'url' => 'https://example.com/media/2026-01/test-video.mp4',
'mime_type' => 'video/mp4',
'original_filename' => 'test-video.mp4',
],
],
]);
Http::fake([
@ -139,14 +145,16 @@
});
test('tiktok publisher throws token expired exception on auth error', function () {
$this->postPlatform->media()->create([
'collection' => 'default',
'type' => 'video',
'path' => 'media/2026-01/test-video.mp4',
'original_filename' => 'test-video.mp4',
'mime_type' => 'video/mp4',
'size' => 1024000,
'order' => 0,
$this->post->update([
'media' => [
[
'id' => 'test-media-video',
'path' => 'media/2026-01/test-video.mp4',
'url' => 'https://example.com/media/2026-01/test-video.mp4',
'mime_type' => 'video/mp4',
'original_filename' => 'test-video.mp4',
],
],
]);
Http::fake([
@ -165,14 +173,16 @@
test('tiktok publisher refreshes token when expired', function () {
$this->socialAccount->update(['token_expires_at' => now()->subHour()]);
$this->postPlatform->media()->create([
'collection' => 'default',
'type' => 'video',
'path' => 'media/2026-01/test-video.mp4',
'original_filename' => 'test-video.mp4',
'mime_type' => 'video/mp4',
'size' => 1024000,
'order' => 0,
$this->post->update([
'media' => [
[
'id' => 'test-media-video',
'path' => 'media/2026-01/test-video.mp4',
'url' => 'https://example.com/media/2026-01/test-video.mp4',
'mime_type' => 'video/mp4',
'original_filename' => 'test-video.mp4',
],
],
]);
Http::fake([
@ -205,14 +215,16 @@
'refresh_token' => null,
]);
$this->postPlatform->media()->create([
'collection' => 'default',
'type' => 'video',
'path' => 'media/2026-01/test-video.mp4',
'original_filename' => 'test-video.mp4',
'mime_type' => 'video/mp4',
'size' => 1024000,
'order' => 0,
$this->post->update([
'media' => [
[
'id' => 'test-media-video',
'path' => 'media/2026-01/test-video.mp4',
'url' => 'https://example.com/media/2026-01/test-video.mp4',
'mime_type' => 'video/mp4',
'original_filename' => 'test-video.mp4',
],
],
]);
expect(fn () => $this->publisher->publish($this->postPlatform))
@ -220,14 +232,16 @@
});
test('tiktok publisher throws exception for unsupported media type', function () {
$this->postPlatform->media()->create([
'collection' => 'default',
'type' => 'document',
'path' => 'media/2026-01/doc.pdf',
'original_filename' => 'doc.pdf',
'mime_type' => 'application/pdf',
'size' => 512000,
'order' => 0,
$this->post->update([
'media' => [
[
'id' => 'test-media-doc',
'path' => 'media/2026-01/doc.pdf',
'url' => 'https://example.com/media/2026-01/doc.pdf',
'mime_type' => 'application/pdf',
'original_filename' => 'doc.pdf',
],
],
]);
expect(fn () => $this->publisher->publish($this->postPlatform))
@ -235,14 +249,16 @@
});
test('tiktok publisher builds correct profile url when username present', function () {
$this->postPlatform->media()->create([
'collection' => 'default',
'type' => 'video',
'path' => 'media/2026-01/test-video.mp4',
'original_filename' => 'test-video.mp4',
'mime_type' => 'video/mp4',
'size' => 1024000,
'order' => 0,
$this->post->update([
'media' => [
[
'id' => 'test-media-video',
'path' => 'media/2026-01/test-video.mp4',
'url' => 'https://example.com/media/2026-01/test-video.mp4',
'mime_type' => 'video/mp4',
'original_filename' => 'test-video.mp4',
],
],
]);
Http::fake([
@ -262,14 +278,16 @@
test('tiktok publisher returns null url when username missing', function () {
$this->socialAccount->update(['username' => null]);
$this->postPlatform->media()->create([
'collection' => 'default',
'type' => 'video',
'path' => 'media/2026-01/test-video.mp4',
'original_filename' => 'test-video.mp4',
'mime_type' => 'video/mp4',
'size' => 1024000,
'order' => 0,
$this->post->update([
'media' => [
[
'id' => 'test-media-video',
'path' => 'media/2026-01/test-video.mp4',
'url' => 'https://example.com/media/2026-01/test-video.mp4',
'mime_type' => 'video/mp4',
'original_filename' => 'test-video.mp4',
],
],
]);
Http::fake([
@ -287,14 +305,16 @@
});
test('tiktok publisher falls back to self only when creator info fails', function () {
$this->postPlatform->media()->create([
'collection' => 'default',
'type' => 'video',
'path' => 'media/2026-01/test-video.mp4',
'original_filename' => 'test-video.mp4',
'mime_type' => 'video/mp4',
'size' => 1024000,
'order' => 0,
$this->post->update([
'media' => [
[
'id' => 'test-media-video',
'path' => 'media/2026-01/test-video.mp4',
'url' => 'https://example.com/media/2026-01/test-video.mp4',
'mime_type' => 'video/mp4',
'original_filename' => 'test-video.mp4',
],
],
]);
Http::fake([
@ -330,14 +350,16 @@
});
test('tiktok publisher throws exception when publish fails', function () {
$this->postPlatform->media()->create([
'collection' => 'default',
'type' => 'video',
'path' => 'media/2026-01/test-video.mp4',
'original_filename' => 'test-video.mp4',
'mime_type' => 'video/mp4',
'size' => 1024000,
'order' => 0,
$this->post->update([
'media' => [
[
'id' => 'test-media-video',
'path' => 'media/2026-01/test-video.mp4',
'url' => 'https://example.com/media/2026-01/test-video.mp4',
'mime_type' => 'video/mp4',
'original_filename' => 'test-video.mp4',
],
],
]);
Http::fake([
@ -369,14 +391,16 @@
],
]);
$this->postPlatform->media()->create([
'collection' => 'default',
'type' => 'video',
'path' => 'media/2026-01/test-video.mp4',
'original_filename' => 'test-video.mp4',
'mime_type' => 'video/mp4',
'size' => 1024000,
'order' => 0,
$this->post->update([
'media' => [
[
'id' => 'test-media-video',
'path' => 'media/2026-01/test-video.mp4',
'url' => 'https://example.com/media/2026-01/test-video.mp4',
'mime_type' => 'video/mp4',
'original_filename' => 'test-video.mp4',
],
],
]);
Http::fake([
@ -421,14 +445,16 @@
],
]);
$this->postPlatform->media()->create([
'collection' => 'default',
'type' => 'image',
'path' => 'media/2026-01/photo.jpg',
'original_filename' => 'photo.jpg',
'mime_type' => 'image/jpeg',
'size' => 512000,
'order' => 0,
$this->post->update([
'media' => [
[
'id' => 'test-media-photo',
'path' => 'media/2026-01/photo.jpg',
'url' => 'https://example.com/media/2026-01/photo.jpg',
'mime_type' => 'image/jpeg',
'original_filename' => 'photo.jpg',
],
],
]);
Http::fake([
@ -467,14 +493,16 @@
],
]);
$this->postPlatform->media()->create([
'collection' => 'default',
'type' => 'video',
'path' => 'media/2026-01/video.mp4',
'original_filename' => 'video.mp4',
'mime_type' => 'video/mp4',
'size' => 1024000,
'order' => 0,
$this->post->update([
'media' => [
[
'id' => 'test-media-video',
'path' => 'media/2026-01/video.mp4',
'url' => 'https://example.com/media/2026-01/video.mp4',
'mime_type' => 'video/mp4',
'original_filename' => 'video.mp4',
],
],
]);
Http::fake([
@ -505,14 +533,16 @@
test('tiktok publisher uses default settings when meta is empty', function () {
$this->postPlatform->update(['meta' => null]);
$this->postPlatform->media()->create([
'collection' => 'default',
'type' => 'video',
'path' => 'media/2026-01/test-video.mp4',
'original_filename' => 'test-video.mp4',
'mime_type' => 'video/mp4',
'size' => 1024000,
'order' => 0,
$this->post->update([
'media' => [
[
'id' => 'test-media-video',
'path' => 'media/2026-01/test-video.mp4',
'url' => 'https://example.com/media/2026-01/test-video.mp4',
'mime_type' => 'video/mp4',
'original_filename' => 'test-video.mp4',
],
],
]);
Http::fake([

View file

@ -27,6 +27,7 @@
$this->post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
'content' => 'Hello from X!',
]);
$this->postPlatform = PostPlatform::factory()->create([
@ -34,7 +35,6 @@
'social_account_id' => $this->socialAccount->id,
'platform' => Platform::X,
'content_type' => ContentType::XPost,
'content' => 'Hello from X!',
]);
$this->publisher = new XPublisher;
@ -159,14 +159,14 @@
});
test('x publisher throws exception with empty content and no media', function () {
$this->postPlatform->update(['content' => '']);
$this->post->update(['content' => '']);
expect(fn () => $this->publisher->publish($this->postPlatform))
->toThrow(Exception::class, 'X posts require either text or media');
});
test('x publisher throws exception with null content and no media', function () {
$this->postPlatform->update(['content' => null]);
$this->post->update(['content' => null]);
expect(fn () => $this->publisher->publish($this->postPlatform))
->toThrow(Exception::class, 'X posts require either text or media');
@ -183,14 +183,16 @@
});
test('x publisher handles gif upload with processing', function () {
$this->postPlatform->media()->create([
'collection' => 'default',
'type' => 'image',
'path' => 'media/2026-01/animated.gif',
'original_filename' => 'animated.gif',
'mime_type' => 'image/gif',
'size' => 1024 * 1024, // 1MB — triggers chunked upload (isGif === true)
'order' => 0,
$this->post->update([
'media' => [
[
'id' => 'test-media-gif',
'path' => 'media/2026-01/animated.gif',
'url' => 'https://example.com/media/2026-01/animated.gif',
'mime_type' => 'image/gif',
'original_filename' => 'animated.gif',
],
],
]);
Http::fake(function ($request) {
@ -237,15 +239,16 @@
});
test('x publisher uploads video via chunked upload', function () {
$this->postPlatform->media()->create([
'collection' => 'default',
'type' => 'video',
'path' => 'media/2026-01/test-video.mp4',
'original_filename' => 'test-video.mp4',
'mime_type' => 'video/mp4',
'size' => 2 * 1024 * 1024,
'order' => 0,
'meta' => ['duration' => 30],
$this->post->update([
'media' => [
[
'id' => 'test-media-video',
'path' => 'media/2026-01/test-video.mp4',
'url' => 'https://example.com/media/2026-01/test-video.mp4',
'mime_type' => 'video/mp4',
'original_filename' => 'test-video.mp4',
],
],
]);
Http::fake(function ($request) {

View file

@ -31,6 +31,7 @@
$this->post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
'content' => 'Check out this YouTube Short!',
]);
$this->postPlatform = PostPlatform::factory()->youtube()->create([
@ -38,7 +39,6 @@
'social_account_id' => $this->socialAccount->id,
'platform' => Platform::YouTube,
'content_type' => ContentType::YouTubeShort,
'content' => 'Check out this YouTube Short!',
]);
$this->publisher = new YouTubePublisher;
@ -50,14 +50,16 @@
});
test('youtube publisher throws exception for non-video content', function () {
$this->postPlatform->media()->create([
'collection' => 'default',
'type' => 'image',
'path' => 'media/2026-01/image.jpg',
'original_filename' => 'image.jpg',
'mime_type' => 'image/jpeg',
'size' => 512000,
'order' => 0,
$this->post->update([
'media' => [
[
'id' => 'test-media-image',
'path' => 'media/2026-01/image.jpg',
'url' => 'https://example.com/media/2026-01/image.jpg',
'mime_type' => 'image/jpeg',
'original_filename' => 'image.jpg',
],
],
]);
expect(fn () => $this->publisher->publish($this->postPlatform))
@ -67,14 +69,16 @@
test('youtube publisher refreshes token when expired', function () {
$this->socialAccount->update(['token_expires_at' => now()->subHour()]);
$this->postPlatform->media()->create([
'collection' => 'default',
'type' => 'video',
'path' => 'media/2026-01/test-video.mp4',
'original_filename' => 'test-video.mp4',
'mime_type' => 'video/mp4',
'size' => 1024000,
'order' => 0,
$this->post->update([
'media' => [
[
'id' => 'test-media-video',
'path' => 'media/2026-01/test-video.mp4',
'url' => 'https://example.com/media/2026-01/test-video.mp4',
'mime_type' => 'video/mp4',
'original_filename' => 'test-video.mp4',
],
],
]);
Http::fake([
@ -106,14 +110,16 @@
'refresh_token' => null,
]);
$this->postPlatform->media()->create([
'collection' => 'default',
'type' => 'video',
'path' => 'media/2026-01/test-video.mp4',
'original_filename' => 'test-video.mp4',
'mime_type' => 'video/mp4',
'size' => 1024000,
'order' => 0,
$this->post->update([
'media' => [
[
'id' => 'test-media-video',
'path' => 'media/2026-01/test-video.mp4',
'url' => 'https://example.com/media/2026-01/test-video.mp4',
'mime_type' => 'video/mp4',
'original_filename' => 'test-video.mp4',
],
],
]);
expect(fn () => $this->publisher->publish($this->postPlatform))
@ -121,14 +127,16 @@
});
test('youtube publisher throws exception on api init error', function () {
$this->postPlatform->media()->create([
'collection' => 'default',
'type' => 'video',
'path' => 'media/2026-01/test-video.mp4',
'original_filename' => 'test-video.mp4',
'mime_type' => 'video/mp4',
'size' => 1024000,
'order' => 0,
$this->post->update([
'media' => [
[
'id' => 'test-media-video',
'path' => 'media/2026-01/test-video.mp4',
'url' => 'https://example.com/media/2026-01/test-video.mp4',
'mime_type' => 'video/mp4',
'original_filename' => 'test-video.mp4',
],
],
]);
Http::fake([
@ -148,16 +156,17 @@
// expiration handling. Full integration tests should cover the 401 error scenario.
test('youtube publisher throws exception with null content', function () {
$this->postPlatform->update(['content' => null]);
$this->postPlatform->media()->create([
'collection' => 'default',
'type' => 'video',
'path' => 'media/2026-01/test-video.mp4',
'original_filename' => 'test.mp4',
'mime_type' => 'video/mp4',
'size' => 1234567,
'order' => 0,
$this->post->update([
'content' => null,
'media' => [
[
'id' => 'test-media-video',
'path' => 'media/2026-01/test-video.mp4',
'url' => 'https://example.com/media/2026-01/test-video.mp4',
'mime_type' => 'video/mp4',
'original_filename' => 'test.mp4',
],
],
]);
expect(fn () => $this->publisher->publish($this->postPlatform))

View file

@ -23,11 +23,10 @@
'access_token' => 'test-token',
'token_expires_at' => now()->addDays(30),
]);
$this->post = Post::factory()->create(['workspace_id' => $this->workspace->id]);
$this->post = Post::factory()->create(['workspace_id' => $this->workspace->id, 'content' => 'Test tweet']);
$this->postPlatform = PostPlatform::factory()->create([
'post_id' => $this->post->id,
'social_account_id' => $this->socialAccount->id,
'content' => 'Test tweet',
'content_type' => ContentType::XPost,
]);
});

View file

@ -5,7 +5,6 @@
use App\Enums\PostPlatform\ContentType;
use App\Enums\SocialAccount\Platform;
use App\Exceptions\TokenExpiredException;
use App\Models\Media;
use App\Models\Post;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
@ -22,11 +21,10 @@
'access_token' => 'test-token',
'token_expires_at' => now()->addDays(30),
]);
$this->post = Post::factory()->create(['workspace_id' => $this->workspace->id]);
$this->post = Post::factory()->create(['workspace_id' => $this->workspace->id, 'content' => 'Test YouTube Short description']);
$this->postPlatform = PostPlatform::factory()->create([
'post_id' => $this->post->id,
'social_account_id' => $this->socialAccount->id,
'content' => 'Test YouTube Short description',
'content_type' => ContentType::YouTubeShort,
]);
});
@ -39,11 +37,10 @@
});
test('youtube publisher throws exception for non video media', function () {
Media::factory()->create([
'mediable_type' => 'postPlatform',
'mediable_id' => $this->postPlatform->id,
'mime_type' => 'image/jpeg',
]);
$this->post->update([
'media' => [
['id' => 'test-img', 'path' => 'medias/test.jpg', 'url' => 'https://example.com/medias/test.jpg', 'mime_type' => 'image/jpeg', 'original_filename' => 'test.jpg'],
]]);
$publisher = new YouTubePublisher;
@ -57,11 +54,10 @@
'refresh_token' => null,
]);
Media::factory()->create([
'mediable_type' => 'postPlatform',
'mediable_id' => $this->postPlatform->id,
'mime_type' => 'video/mp4',
]);
$this->post->update([
'media' => [
['id' => 'test-vid', 'path' => 'medias/test.mp4', 'url' => 'https://example.com/medias/test.mp4', 'mime_type' => 'video/mp4', 'original_filename' => 'test.mp4'],
]]);
$publisher = new YouTubePublisher;
@ -70,11 +66,10 @@
});
test('youtube publisher throws token expired exception on 401', function () {
Media::factory()->create([
'mediable_type' => 'postPlatform',
'mediable_id' => $this->postPlatform->id,
'mime_type' => 'video/mp4',
]);
$this->post->update([
'media' => [
['id' => 'test-vid', 'path' => 'medias/test.mp4', 'url' => 'https://example.com/medias/test.mp4', 'mime_type' => 'video/mp4', 'original_filename' => 'test.mp4'],
]]);
Http::fake([
'*' => Http::response([
@ -102,11 +97,10 @@
'refresh_token' => 'invalid-refresh-token',
]);
Media::factory()->create([
'mediable_type' => 'postPlatform',
'mediable_id' => $this->postPlatform->id,
'mime_type' => 'video/mp4',
]);
$this->post->update([
'media' => [
['id' => 'test-vid', 'path' => 'medias/test.mp4', 'url' => 'https://example.com/medias/test.mp4', 'mime_type' => 'video/mp4', 'original_filename' => 'test.mp4'],
]]);
Http::fake([
'https://oauth2.googleapis.com/token' => Http::response([
@ -127,11 +121,10 @@
'refresh_token' => 'refresh-token',
]);
Media::factory()->create([
'mediable_type' => 'postPlatform',
'mediable_id' => $this->postPlatform->id,
'mime_type' => 'video/mp4',
]);
$this->post->update([
'media' => [
['id' => 'test-vid', 'path' => 'medias/test.mp4', 'url' => 'https://example.com/medias/test.mp4', 'mime_type' => 'video/mp4', 'original_filename' => 'test.mp4'],
]]);
Http::fake([
'https://oauth2.googleapis.com/token' => Http::response([

View file

@ -3,7 +3,6 @@
declare(strict_types=1);
use App\Models\Media;
use App\Models\PostPlatform;
use App\Models\User;
use App\Models\Workspace;
use Illuminate\Database\Eloquent\Relations\MorphMany;
@ -94,14 +93,14 @@
});
test('adding media to multiple collection does not clear existing', function () {
$post = PostPlatform::factory()->create();
$workspace = Workspace::factory()->create();
$file1 = UploadedFile::fake()->image('image1.jpg', 100, 100);
$file2 = UploadedFile::fake()->image('image2.jpg', 100, 100);
$post->addMedia($file1, 'default');
$post->addMedia($file2, 'default');
$workspace->addMedia($file1, 'assets');
$workspace->addMedia($file2, 'assets');
expect($post->getMedia('default')->count())->toBe(2);
expect($workspace->getMedia('assets')->count())->toBe(2);
});
test('model can add media from file path', function () {
@ -129,15 +128,15 @@
$workspace->addMedia($file1, 'logo');
// Need to use a 'multiple' collection model
$post = PostPlatform::factory()->create();
$post->addMedia($file2, 'default');
$post->addMedia($file3, 'default');
$ws = Workspace::factory()->create();
$ws->addMedia($file2, 'assets');
$ws->addMedia($file3, 'assets');
expect($post->getMedia('default')->count())->toBe(2);
expect($ws->getMedia('assets')->count())->toBe(2);
$post->clearMediaCollection('default');
$ws->clearMediaCollection('assets');
expect($post->getMedia('default')->count())->toBe(0);
expect($ws->getMedia('assets')->count())->toBe(0);
});
test('is single media collection returns true for single collections', function () {
@ -147,9 +146,9 @@
});
test('is single media collection returns false for multiple collections', function () {
$post = PostPlatform::factory()->create();
$workspace = Workspace::factory()->create();
expect($post->isSingleMediaCollection('default'))->toBeFalse();
expect($workspace->isSingleMediaCollection('assets'))->toBeFalse();
});
test('is single media collection returns false for undefined collections', function () {