feat: add PostCommentCreated broadcast event
This commit is contained in:
parent
e3d14256e0
commit
0f6ae9a4e6
95 changed files with 1896 additions and 2620 deletions
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
71
app/DataTransferObjects/MediaItem.php
Normal file
71
app/DataTransferObjects/MediaItem.php
Normal 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'),
|
||||
);
|
||||
}
|
||||
}
|
||||
45
app/Events/PostCommentCreated.php
Normal file
45
app/Events/PostCommentCreated.php
Normal 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,
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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')),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -25,8 +25,9 @@ public function definition(): array
|
|||
return [
|
||||
'workspace_id' => Workspace::factory(),
|
||||
'user_id' => User::factory(),
|
||||
'content' => '',
|
||||
'media' => [],
|
||||
'status' => PostStatus::Draft,
|
||||
'synced' => true,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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' => [],
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -22,6 +22,7 @@
|
|||
|
||||
'save_to_assets' => 'Save to Assets',
|
||||
'saved' => 'Saved to your assets!',
|
||||
'create_post' => 'Create post',
|
||||
|
||||
'delete' => [
|
||||
'title' => 'Delete asset',
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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);
|
||||
}
|
||||
} */
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
8
resources/js/components/posts/editor/CommentsTab.vue
Normal file
8
resources/js/components/posts/editor/CommentsTab.vue
Normal 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>
|
||||
37
resources/js/components/posts/editor/PreviewTab.vue
Normal file
37
resources/js/components/posts/editor/PreviewTab.vue
Normal 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>
|
||||
120
resources/js/components/posts/editor/ScheduleTab.vue
Normal file
120
resources/js/components/posts/editor/ScheduleTab.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
6
resources/js/types/index.d.ts
vendored
6
resources/js/types/index.d.ts
vendored
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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' => [
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
],
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
Loading…
Reference in a new issue