refactor: unify social analytics, reorganize workspace settings, and implement content validation rules

This commit is contained in:
Paulo Castellano 2026-05-02 12:22:42 -03:00
parent 3c3b170b21
commit f3605717c7
91 changed files with 3522 additions and 1839 deletions

View file

@ -46,7 +46,7 @@ ## Skills Activation
- `cashier-stripe-development` — Handles Laravel Cashier Stripe integration including subscriptions, webhooks, Stripe Checkout, invoices, charges, refunds, trials, coupons, metered billing, and payment failure handling. Triggered when a user mentions Cashier, Billable, IncompletePayment, stripe_id, newSubscription, Stripe subscriptions, or billing. Also applies when setting up webhooks, handling SCA/3DS payment failures, testing with Stripe test cards, or troubleshooting incomplete subscriptions, CSRF webhook errors, or migration publish issues.
- `laravel-best-practices` — Apply this skill whenever writing, reviewing, or refactoring Laravel PHP code. This includes creating or modifying controllers, models, migrations, form requests, policies, jobs, scheduled commands, service classes, and Eloquent queries. Triggers for N+1 and query performance issues, caching strategies, authorization and security patterns, validation, error handling, queue and job configuration, route definitions, and architectural decisions. Also use for Laravel code reviews and refactoring existing Laravel code to follow best practices. Covers any task involving Laravel backend PHP code patterns.
- `configuring-horizon` — Use this skill whenever the user mentions Horizon by name in a Laravel context. Covers the full Horizon lifecycle: installing Horizon (horizon:install, Sail setup), configuring config/horizon.php (supervisor blocks, queue assignments, balancing strategies, minProcesses/maxProcesses), fixing the dashboard (authorization via Gate::define viewHorizon, blank metrics, horizon:snapshot scheduling), and troubleshooting production issues (worker crashes, timeout chain ordering, LongWaitDetected notifications, waits config). Also covers job tagging and silencing. Do not use for generic Laravel queues without Horizon, SQS or database drivers, standalone Redis setup, Linux supervisord, Telescope, or job batching.
- `mcp-development` — Use this skill for Laravel MCP development only. Trigger when creating or editing MCP tools, resources, prompts, or servers in Laravel projects. Covers: artisan make:mcp-* generators, mcp:inspector, routes/ai.php, Tool/Resource/Prompt classes, schema validation, shouldRegister(), OAuth setup, URI templates, read-only attributes, and MCP debugging. Do not use for non-Laravel MCP projects or generic AI features without MCP.
- `mcp-development` — Use this skill for Laravel MCP development only. Trigger when creating or editing MCP tools, resources, prompts, or servers in Laravel projects. Covers: artisan make:mcp-\* generators, mcp:inspector, routes/ai.php, Tool/Resource/Prompt classes, schema validation, shouldRegister(), OAuth setup, URI templates, read-only attributes, and MCP debugging. Do not use for non-Laravel MCP projects or generic AI features without MCP.
- `configure-nightwatch` — Configures Laravel Nightwatch data collection, sampling rates, filtering rules, and redaction policies. Use when setting up Nightwatch, managing data volume, protecting sensitive data (PII), or optimizing event collection for production workloads.
- `pennant-development` — Use when working with Laravel Pennant the official Laravel feature flag package. Trigger whenever the query mentions Pennant by name or involves feature flags or feature toggles in a Laravel project. Tasks include defining feature flags checking whether features are active creating class based features in `app/Features` using Blade `@feature` directives scoping flags to users or teams building custom Pennant storage drivers protecting routes with feature flags testing feature flags with Pest or PHPUnit and implementing A B testing or gradual rollouts with feature flags. Do not trigger for generic Laravel configuration authorization policies authentication or non Pennant feature management systems.
- `socialite-development` — Manages OAuth social authentication with Laravel Socialite. Activate when adding social login providers; configuring OAuth redirect/callback flows; retrieving authenticated user details; customizing scopes or parameters; setting up community providers; testing with Socialite fakes; or when the user mentions social login, OAuth, Socialite, or third-party authentication.
@ -119,7 +119,7 @@ ## Tinker
- Execute PHP in app context for debugging and testing code. Do not create models without user approval, prefer tests with factories instead. Prefer existing Artisan commands over custom tinker code.
- Always use single quotes to prevent shell expansion: `php artisan tinker --execute 'Your::code();'`
- Double quotes for PHP strings inside: `php artisan tinker --execute 'User::where("active", true)->count();'`
- Double quotes for PHP strings inside: `php artisan tinker --execute 'User::where("active", true)->count();'`
=== php rules ===
@ -199,10 +199,6 @@ ## Vite Error
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
## Deployment
- Laravel can be deployed using [Laravel Cloud](https://cloud.laravel.com/), which is the fastest way to deploy and scale production Laravel applications.
=== wayfinder/core rules ===
# Laravel Wayfinder
@ -222,6 +218,7 @@ ## Pest
- This project uses Pest for testing. Create tests: `php artisan make:test --pest {name}`.
- Run tests: `php artisan test --compact` or filter: `php artisan test --compact --filter=testName`.
- When running locally, always pass `--parallel` to speed up the suite (e.g. `php artisan test --parallel --compact`). Combine with `--filter` or specific paths when iterating on a small set of tests.
- Do NOT delete tests without approval.
=== inertia-vue/core rules ===
@ -229,8 +226,21 @@ ## Pest
# Inertia + Vue
Vue components must have a single root element.
- IMPORTANT: Activate `inertia-vue-development` when working with Inertia Vue client-side patterns.
=== laravel/ai rules ===
## Laravel AI SDK
- This application uses the Laravel AI SDK (`laravel/ai`) for all AI functionality.
- Activate the `developing-with-ai-sdk` skill when building, editing, updating, debugging, or testing AI agents, text generation, chat, streaming, structured output, tools, image generation, audio, transcription, embeddings, reranking, vector stores, files, conversation memory, or any AI provider integration (OpenAI, Anthropic, Gemini, Cohere, Groq, xAI, ElevenLabs, Jina, OpenRouter).
=== spatie/laravel-medialibrary rules ===
- `spatie/laravel-medialibrary` associates files with Eloquent models, with support for collections, conversions, and responsive images.
- Always activate the `medialibrary-development` skill when working with media uploads, conversions, collections, responsive images, or any code that uses the `HasMedia` interface or `InteractsWithMedia` trait.
</laravel-boost-guidelines>
# Project-Specific Rules
@ -270,8 +280,8 @@ ## Form Validation
## Dusk (Browser Tests)
- In Dusk tests, ALWAYS use named routes via `route()` helper. NEVER hardcode URLs like `'https://app.sendkit.test/login'`.
- Example: `$browser->visit(route('login'))` instead of `$browser->visit('https://app.sendkit.test/login')`.
- In Dusk tests, ALWAYS use named routes via `route()` helper. NEVER hardcode URLs like `'https://trypost.test/login'`.
- Example: `$browser->visit(route('login'))` instead of `$browser->visit('https://trypost.test/login')`.
- ALWAYS use `dusk` selectors (`@selector-name`) for interacting with and asserting elements. NEVER use CSS classes (`.text-red-600`), tag names, or text strings.
- Add `dusk="my-element"` attributes to Vue components and use `$browser->click('@my-element')`, `$browser->waitFor('@my-element')`, etc.
- Example: `$browser->waitFor('@input-error')` instead of `$browser->waitFor('.text-red-600')`.
@ -293,9 +303,9 @@ ## API Response Status Codes
- When returning JSON responses with explicit status codes, always use `Symfony\Component\HttpFoundation\Response` constants instead of magic numbers.
- Example: `Response::HTTP_CREATED` instead of `201`, `Response::HTTP_NO_CONTENT` instead of `204`.
## Sendkit Documentation
## TryPost.it Documentation
- All our documentation to final user it's under https://docs.sendkit.dev
- All our documentation to final user it's under https://docs.trypost.it
## Git

View file

@ -30,7 +30,9 @@ public static function execute(User $user, array $data): Workspace
'user_id' => $user->id,
]);
$workspace->members()->attach($user->id, ['role' => Role::Member->value]);
// Creator becomes Admin of the workspace they made. The Account Owner
// is resolved separately (via account.owner_id) and outranks this role.
$workspace->members()->attach($user->id, ['role' => Role::Admin->value]);
$user->switchWorkspace($workspace);
return $workspace;

View file

@ -178,6 +178,7 @@ public function requiresMedia(): bool
{
return match ($this) {
self::LinkedInPost, self::LinkedInPagePost => false,
self::FacebookPost => false,
self::XPost => false,
self::ThreadsPost => false,
self::BlueskyPost => false,

View file

@ -1,207 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\App;
use App\Http\Requests\App\Media\DuplicateMediaRequest;
use App\Http\Requests\App\Media\StoreChunkedMediaRequest;
use App\Http\Requests\App\Media\StoreMediaRequest;
use App\Models\Media;
use App\Models\Post;
use App\Models\PostPlatform;
use App\Models\User;
use App\Models\Workspace;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class MediaController extends Controller
{
public function store(StoreMediaRequest $request): JsonResponse
{
$model = $this->resolveModel($request->input('model'), $request->input('model_id'));
$this->authorizeModelOwnership($model, $request);
$collection = $request->input('collection', 'default');
$media = $model->addMedia(
$request->file('media'),
$collection
);
return response()->json([
'id' => $media->id,
'group_id' => $media->group_id,
'url' => $media->url,
'type' => $media->type->value,
'original_filename' => $media->original_filename,
]);
}
public function storeChunked(StoreChunkedMediaRequest $request): JsonResponse
{
$tempFile = $this->chunkTempPath($request->chunkIdentifier());
$this->appendChunk($tempFile, $request->getContent(), $request->isFirstChunk());
if (! $request->isLastChunk()) {
return response()->json([
'done' => false,
'progress' => $request->progress(),
]);
}
$model = $this->resolveModel($request->input('model'), $request->input('model_id'));
$this->authorizeModelOwnership($model, $request);
$media = $model->addMediaFromPath(
$tempFile,
$request->input('file_name'),
$request->input('collection'),
);
@unlink($tempFile);
return response()->json([
'done' => true,
'id' => $media->id,
'group_id' => $media->group_id,
'url' => $media->url,
'type' => $media->type->value,
'original_filename' => $media->original_filename,
]);
}
public function destroy(string $modelId, Media $media, Request $request): JsonResponse
{
if ($media->mediable_id !== $modelId) {
abort(403);
}
$model = $media->mediable;
$this->authorizeModelOwnership($model, $request);
$media->delete();
return response()->json(['success' => true]);
}
public function reorder(Request $request): JsonResponse
{
$request->validate([
'media' => 'required|array',
'media.*.id' => 'required|exists:medias,id',
'media.*.order' => 'required|integer|min:0',
]);
$workspace = $request->user()->currentWorkspace;
$mediaIds = collect($request->input('media'))->pluck('id');
// Verify all media belongs to the current workspace
$ownedCount = Media::whereIn('id', $mediaIds)
->whereHasMorph('mediable', [PostPlatform::class], fn ($query) => $query->whereHas('post', fn ($q) => $q->where('workspace_id', $workspace->id))
)->count();
if ($ownedCount !== $mediaIds->count()) {
abort(403);
}
foreach ($request->input('media') as $item) {
Media::where('id', data_get($item, 'id'))->update(['order' => data_get($item, 'order')]);
}
return response()->json(['success' => true]);
}
public function duplicate(Media $media, DuplicateMediaRequest $request): JsonResponse
{
$sourceModel = $media->mediable;
$this->authorizeModelOwnership($sourceModel, $request);
$targets = $request->validated('targets', []);
$duplicates = [];
foreach ($targets as $target) {
$model = $this->resolveModel(data_get($target, 'model'), data_get($target, 'model_id'));
$this->authorizeModelOwnership($model, $request);
$collection = data_get($target, 'collection', $media->collection);
$duplicate = $model->media()->create([
'group_id' => $media->group_id,
'collection' => $collection,
'type' => $media->type,
'path' => $media->path,
'original_filename' => $media->original_filename,
'mime_type' => $media->mime_type,
'size' => $media->size,
'order' => $media->order,
'meta' => $media->meta,
]);
$duplicates[] = [
'id' => $duplicate->id,
'group_id' => $duplicate->group_id,
'mediable_id' => $duplicate->mediable_id,
'mediable_type' => $duplicate->mediable_type,
'url' => $duplicate->url,
'type' => $duplicate->type->value,
'original_filename' => $duplicate->original_filename,
];
}
return response()->json($duplicates);
}
private function authorizeModelOwnership(Model $model, Request $request): void
{
$workspace = $request->user()->currentWorkspace;
if ($model instanceof PostPlatform) {
if ($model->post->workspace_id !== $workspace->id) {
abort(403);
}
} elseif ($model instanceof Post) {
if ($model->workspace_id !== $workspace->id) {
abort(403);
}
} elseif ($model instanceof Workspace) {
if ($model->id !== $workspace->id) {
abort(403);
}
} elseif ($model instanceof User) {
if ($model->id !== $request->user()->id) {
abort(403);
}
} else {
abort(403);
}
}
private function resolveModel(string $alias, string $id): Model
{
$modelClass = Relation::getMorphedModel($alias);
if (! $modelClass) {
abort(404);
}
return $modelClass::findOrFail($id);
}
private function chunkTempPath(string $identifier): string
{
return storage_path("app/private/chunks/{$identifier}");
}
private function appendChunk(string $path, string $content, bool $isFirst): void
{
$directory = dirname($path);
if (! is_dir($directory)) {
mkdir($directory, 0755, true);
}
file_put_contents($path, $content, $isFirst ? 0 : FILE_APPEND);
}
}

View file

@ -14,11 +14,23 @@
use App\Http\Requests\App\Post\UpdatePostRequest;
use App\Http\Resources\App\PlatformConfigResource;
use App\Models\Post;
use App\Models\PostPlatform;
use App\Services\Social\BlueskyAnalytics;
use App\Services\Social\FacebookAnalytics;
use App\Services\Social\InstagramAnalytics;
use App\Services\Social\LinkedInPageAnalytics;
use App\Services\Social\MastodonAnalytics;
use App\Services\Social\PinterestAnalytics;
use App\Services\Social\PinterestPublisher;
use App\Services\Social\ThreadsAnalytics;
use App\Services\Social\TikTokCreatorInfo;
use App\Services\Social\XAnalytics;
use App\Services\Social\YouTubeAnalytics;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Inertia\Inertia;
use Inertia\Response;
@ -143,7 +155,43 @@ public function store(Request $request): RedirectResponse|\Symfony\Component\Htt
return Inertia::location(route('app.posts.edit', $post));
}
public function edit(Request $request, Post $post): Response|RedirectResponse
public function platformMetrics(Request $request, Post $post, PostPlatform $postPlatform): JsonResponse
{
$workspace = $request->user()->currentWorkspace;
if (! $workspace || $post->workspace_id !== $workspace->id) {
abort(404);
}
$this->authorize('view', $workspace);
if ($postPlatform->post_id !== $post->id) {
abort(404);
}
if ($postPlatform->status->value !== 'published' || ! $postPlatform->platform_post_id) {
return response()->json(['unsupported' => true, 'reason' => 'not_published']);
}
$cacheKey = "post_metrics:{$postPlatform->id}";
$metrics = Cache::remember($cacheKey, 300, fn () => match ($postPlatform->platform) {
Platform::X => app(XAnalytics::class)->fetchPostMetrics($postPlatform),
Platform::Bluesky => app(BlueskyAnalytics::class)->fetchPostMetrics($postPlatform),
Platform::Mastodon => app(MastodonAnalytics::class)->fetchPostMetrics($postPlatform),
Platform::Instagram, Platform::InstagramFacebook => app(InstagramAnalytics::class)->fetchPostMetrics($postPlatform),
Platform::Facebook => app(FacebookAnalytics::class)->fetchPostMetrics($postPlatform),
Platform::Threads => app(ThreadsAnalytics::class)->fetchPostMetrics($postPlatform),
Platform::LinkedInPage => app(LinkedInPageAnalytics::class)->fetchPostMetrics($postPlatform),
Platform::YouTube => app(YouTubeAnalytics::class)->fetchPostMetrics($postPlatform),
Platform::Pinterest => app(PinterestAnalytics::class)->fetchPostMetrics($postPlatform),
default => ['unsupported' => true, 'reason' => 'platform_not_supported'],
});
return response()->json($metrics);
}
public function show(Request $request, Post $post): Response|RedirectResponse
{
$workspace = $request->user()->currentWorkspace;
@ -158,9 +206,37 @@ public function edit(Request $request, Post $post): Response|RedirectResponse
}
if (in_array($post->status, [PostStatus::Draft, PostStatus::Scheduled, PostStatus::Failed], true)) {
SyncPostPlatforms::execute($post);
return redirect()->route('app.posts.edit', $post);
}
$post->load(['postPlatforms.socialAccount', 'labels']);
return Inertia::render('posts/Show', [
'workspace' => $workspace,
'post' => $post,
]);
}
public function edit(Request $request, Post $post): Response|RedirectResponse
{
$workspace = $request->user()->currentWorkspace;
if (! $workspace) {
return redirect()->route('app.workspaces.create');
}
$this->authorize('view', $workspace);
if ($post->workspace_id !== $workspace->id) {
abort(404);
}
if (in_array($post->status, [PostStatus::Publishing, PostStatus::Published, PostStatus::PartiallyPublished], true)) {
return redirect()->route('app.posts.show', $post);
}
SyncPostPlatforms::execute($post);
$post->load(['postPlatforms.socialAccount', 'labels']);
$socialAccounts = $workspace->socialAccounts()->active()->get();
$labels = $workspace->labels;
@ -230,14 +306,14 @@ public function update(UpdatePostRequest $request, Post $post): RedirectResponse
session()->flash('flash.banner', __('posts.flash.publishing'));
session()->flash('flash.bannerStyle', 'success');
return redirect()->route('app.posts.edit', $post);
return redirect()->route('app.posts.show', $post);
}
if ($action === PostAction::Scheduled) {
session()->flash('flash.banner', __('posts.flash.scheduled'));
session()->flash('flash.bannerStyle', 'success');
return redirect()->route('app.posts.edit', $post);
return redirect()->route('app.posts.show', $post);
}
return back();
@ -257,6 +333,13 @@ public function destroy(Request $request, Post $post): RedirectResponse
abort(404);
}
if (in_array($post->status, [PostStatus::Publishing, PostStatus::Published, PostStatus::PartiallyPublished], true)) {
session()->flash('flash.banner', __('posts.flash.cannot_delete_published'));
session()->flash('flash.bannerStyle', 'danger');
return back();
}
DeletePost::execute($post);
session()->flash('flash.banner', __('posts.flash.deleted'));

View file

@ -140,25 +140,24 @@ public function settings(Request $request): Response|RedirectResponse
$this->authorize('update', $workspace);
$members = $workspace->members()
->get()
->map(fn ($member) => [
'id' => $member->id,
'name' => $member->name,
'email' => $member->email,
'role' => $member->pivot->role,
'is_owner' => $member->id === $workspace->account?->owner_id,
]);
$invitations = $workspace->invites()
->select('id', 'email')
->latest()
->get();
return Inertia::render('settings/Workspace', [
'workspace' => $workspace,
'members' => $members,
'invitations' => $invitations,
]);
}
public function brandSettings(Request $request): Response|RedirectResponse
{
$user = $request->user();
$workspace = $user->currentWorkspace;
if (! $workspace) {
return redirect()->route('app.workspaces.create');
}
$this->authorize('update', $workspace);
return Inertia::render('settings/Brand', [
'workspace' => $workspace,
]);
}

View file

@ -5,6 +5,7 @@
namespace App\Http\Controllers\Auth;
use App\Actions\SocialAccount\ToggleSocialAccount;
use App\Enums\PostPlatform\Status as PostPlatformStatus;
use App\Enums\SocialAccount\Platform as SocialPlatform;
use App\Enums\SocialAccount\Status;
use App\Features\SocialAccountLimit;
@ -86,6 +87,13 @@ public function disconnect(Request $request, SocialAccount $account): RedirectRe
abort(403);
}
// Drop pending platform rows from drafts/scheduled posts so the account
// disappears cleanly from their UI. Published/failed rows survive via the
// FK's nullOnDelete cascade and keep their snapshot fields for history.
$account->postPlatforms()
->where('status', PostPlatformStatus::Pending->value)
->delete();
$account->delete();
session()->flash('flash.banner', __('accounts.flash.disconnected'));

View file

@ -1,25 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\App\Media;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class DuplicateMediaRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'targets' => ['required', 'array', 'max:50'],
'targets.*.model' => ['required', 'string', Rule::in(['postPlatform', 'workspace', 'user'])],
'targets.*.model_id' => ['required', 'uuid'],
];
}
}

View file

@ -1,118 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\App\Media;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreChunkedMediaRequest extends FormRequest
{
private const CONTENT_RANGE_PATTERN = '/bytes (\d+)-(\d+)\/(\d+)/';
protected array $allowedModels = [
'postPlatform',
'workspace',
'user',
];
public function authorize(): bool
{
return true;
}
public function prepareForValidation(): void
{
$this->merge([
'model' => $this->header('X-Model'),
'model_id' => $this->header('X-Model-Id'),
'collection' => $this->header('X-Collection', 'default'),
'file_name' => $this->header('X-File-Name', 'upload'),
'content_range' => $this->header('Content-Range'),
]);
}
public function rules(): array
{
return [
'content_range' => ['required', 'string', 'regex:'.self::CONTENT_RANGE_PATTERN],
'model' => ['required', 'string', Rule::in($this->allowedModels)],
'model_id' => ['required', 'uuid'],
'collection' => ['sometimes', 'string', 'max:255'],
'file_name' => [
'required',
'string',
'max:255',
'regex:/\.(jpe?g|png|gif|webp|mp4)$/i',
],
];
}
public function messages(): array
{
return [
'file_name.regex' => 'File type not supported. Allowed: JPEG, PNG, GIF, WebP, MP4.',
];
}
public function after(): array
{
return [
function ($validator) {
if ($validator->errors()->has('content_range')) {
return;
}
if ($this->totalSize() > 1073741824) { // 1GB
$validator->errors()->add('content_range', 'File size exceeds the maximum allowed (1GB).');
}
},
];
}
public function rangeStart(): int
{
return (int) $this->parsedRange()[1];
}
public function rangeEnd(): int
{
return (int) $this->parsedRange()[2];
}
public function totalSize(): int
{
return (int) $this->parsedRange()[3];
}
public function isLastChunk(): bool
{
return ($this->rangeEnd() + 1) >= $this->totalSize();
}
public function isFirstChunk(): bool
{
return $this->rangeStart() === 0;
}
public function progress(): int
{
return (int) round(($this->rangeEnd() + 1) / $this->totalSize() * 100);
}
public function chunkIdentifier(): string
{
return md5($this->user()->id.$this->input('file_name').$this->totalSize());
}
/**
* @return array<int, string>
*/
private function parsedRange(): array
{
preg_match(self::CONTENT_RANGE_PATTERN, $this->input('content_range'), $matches);
return $matches;
}
}

View file

@ -1,67 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\App\Media;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreMediaRequest extends FormRequest
{
protected array $allowedModels = [
'postPlatform',
'workspace',
'user',
];
public function authorize(): bool
{
return true;
}
public function rules(): array
{
$maxSize = $this->isVideoUpload() ? 1048576 : 10240; // Videos: 1GB, Images: 10MB
return [
'media' => [
'required',
'file',
"max:{$maxSize}",
'mimetypes:image/jpeg,image/png,image/gif,image/webp,video/mp4',
],
'model' => [
'required',
'string',
Rule::in($this->allowedModels),
],
'model_id' => [
'required',
'uuid',
],
'collection' => [
'sometimes',
'string',
'max:255',
],
];
}
public function messages(): array
{
return [
'media.required' => 'The file is required.',
'media.max' => $this->isVideoUpload() ? 'Videos must be at most 1GB.' : 'Images must be at most 10MB.',
'media.mimetypes' => 'File type not supported.',
'model.required' => 'The model is required.',
'model.in' => 'Invalid model type.',
'model_id.required' => 'The model ID is required.',
];
}
private function isVideoUpload(): bool
{
return str_starts_with($this->file('media')?->getMimeType() ?? '', 'video/');
}
}

View file

@ -6,6 +6,7 @@
use App\Enums\Post\Status;
use App\Enums\PostPlatform\ContentType;
use App\Rules\ContentTypeCompatibleWithMedia;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
@ -18,6 +19,12 @@ public function authorize(): bool
public function rules(): array
{
$enforcesMediaCompatibility = in_array(
$this->input('status'),
[Status::Scheduled->value, Status::Publishing->value],
true,
);
return [
'status' => ['required', 'string', Rule::in([Status::Draft->value, Status::Scheduled->value, Status::Publishing->value])],
'content' => ['nullable', 'string', 'max:63206'],
@ -40,9 +47,15 @@ public function rules(): 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_type' => ['sometimes', 'string', Rule::in(array_column(ContentType::cases(), 'value'))],
'platforms.*.id' => ['required', 'uuid', Rule::exists('post_platforms', 'id')->where('post_id', $this->route('post')->id)],
'platforms.*.content_type' => [
$enforcesMediaCompatibility ? 'required' : 'sometimes',
'string',
Rule::in(array_column(ContentType::cases(), 'value')),
Rule::when($enforcesMediaCompatibility, [new ContentTypeCompatibleWithMedia]),
],
'platforms.*.meta' => ['nullable', 'array'],
'platforms.*.meta.aspect_ratio' => ['sometimes', 'nullable', 'string', Rule::in(['1:1', '4:5', '16:9', 'original'])],
'platforms.*.meta.privacy_level' => ['sometimes', 'nullable', 'string', Rule::in(['PUBLIC_TO_EVERYONE', 'MUTUAL_FOLLOW_FRIENDS', 'FOLLOWER_OF_CREATOR', 'SELF_ONLY'])],
'platforms.*.meta.auto_add_music' => ['sometimes', 'boolean'],
'platforms.*.meta.allow_comments' => ['sometimes', 'boolean'],

View file

@ -12,6 +12,7 @@
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Facades\Storage;
class PostPlatform extends Model
{
@ -80,7 +81,11 @@ public function getDisplayUsernameAttribute(): ?string
*/
public function getDisplayAvatarAttribute(): ?string
{
return $this->socialAccount?->avatar_url ?? $this->platform_avatar;
if ($this->socialAccount?->avatar_url) {
return $this->socialAccount->avatar_url;
}
return $this->platform_avatar ? Storage::url($this->platform_avatar) : null;
}
public function markAsPublishing(): void

View file

@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace App\Rules;
use App\Enums\Media\Type as MediaType;
use App\Enums\PostPlatform\ContentType;
use Closure;
use Illuminate\Contracts\Validation\DataAwareRule;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Translation\PotentiallyTranslatedString;
class ContentTypeCompatibleWithMedia implements DataAwareRule, ValidationRule
{
/**
* @var array<string, mixed>
*/
private array $data = [];
/**
* @param array<string, mixed> $data
*/
public function setData(array $data): static
{
$this->data = $data;
return $this;
}
/**
* @param Closure(string, ?string=): PotentiallyTranslatedString $fail
*/
public function validate(string $attribute, mixed $value, Closure $fail): void
{
$contentType = ContentType::tryFrom((string) $value);
if (! $contentType) {
return;
}
$media = (array) data_get($this->data, 'media', []);
$count = count($media);
if ($contentType->requiresMedia() && $count === 0) {
$fail("{$contentType->label()} requires at least one media file.");
return;
}
if ($count === 0) {
return;
}
$hasImage = collect($media)->contains(fn ($item) => $this->isImage((array) $item));
$hasVideo = collect($media)->contains(fn ($item) => $this->isVideo((array) $item));
if ($hasImage && ! $contentType->supportsImage()) {
$fail("{$contentType->label()} does not support images.");
}
if ($hasVideo && ! $contentType->supportsVideo()) {
$fail("{$contentType->label()} does not support videos.");
}
}
/**
* @param array<string, mixed> $item
*/
private function isImage(array $item): bool
{
if (data_get($item, 'type') === MediaType::Image->value) {
return true;
}
return str_starts_with((string) data_get($item, 'mime_type', ''), 'image/');
}
/**
* @param array<string, mixed> $item
*/
private function isVideo(array $item): bool
{
if (data_get($item, 'type') === MediaType::Video->value) {
return true;
}
return str_starts_with((string) data_get($item, 'mime_type', ''), 'video/');
}
}

View file

@ -95,6 +95,47 @@ public function optimizeImage(string $filePath, Platform $platform): string
return $tempFile;
}
/**
* Center-crop an image to the given aspect ratio (width / height).
* Returns path to a temp file (caller must clean up).
*/
public function cropToAspectRatio(string $filePath, float $ratio): string
{
$image = $this->manager->decodePath($filePath);
$width = $image->width();
$height = $image->height();
$current = $width / $height;
if (abs($current - $ratio) < 0.001) {
$tempFile = tempnam(sys_get_temp_dir(), 'media_crop_');
copy($filePath, $tempFile);
return $tempFile;
}
if ($current > $ratio) {
// Wider than target: keep height, shrink width.
$newWidth = (int) round($height * $ratio);
$newHeight = $height;
} else {
// Taller than target: keep width, shrink height.
$newWidth = $width;
$newHeight = (int) round($width / $ratio);
}
$offsetX = (int) round(($width - $newWidth) / 2);
$offsetY = (int) round(($height - $newHeight) / 2);
$image->crop($newWidth, $newHeight, $offsetX, $offsetY);
$tempFile = tempnam(sys_get_temp_dir(), 'media_crop_');
$encoded = $image->encodeUsingMediaType('image/jpeg', quality: 100);
file_put_contents($tempFile, (string) $encoded);
return $tempFile;
}
/**
* @return array{max_width: int, max_size: int, format: string, quality: int}
*/

View file

@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Services\Social;
use App\Models\PostPlatform;
use App\Services\Social\Concerns\HasSocialHttpClient;
use Illuminate\Support\Facades\Log;
class BlueskyAnalytics
{
use HasSocialHttpClient;
public function fetchPostMetrics(PostPlatform $postPlatform): array
{
$account = $postPlatform->socialAccount;
if (! $account || ! $postPlatform->platform_post_id) {
return ['unsupported' => true, 'reason' => 'missing_post_id'];
}
$service = data_get($account->meta, 'service', 'https://bsky.social');
$did = $account->platform_user_id;
$atUri = "at://{$did}/app.bsky.feed.post/{$postPlatform->platform_post_id}";
// Public AppView API: no auth required for reading post counts.
$response = $this->socialHttp()
->get("{$service}/xrpc/app.bsky.feed.getPosts", [
'uris' => [$atUri],
]);
if ($response->failed()) {
Log::warning('Bluesky post metrics fetch failed', [
'body' => $this->redactResponseBody($response->body()),
]);
return ['unsupported' => true, 'reason' => 'api_error'];
}
$post = data_get($response->json(), 'posts.0', []);
return [
['label' => 'Likes', 'value' => data_get($post, 'likeCount', 0)],
['label' => 'Reposts', 'value' => data_get($post, 'repostCount', 0)],
['label' => 'Quotes', 'value' => data_get($post, 'quoteCount', 0)],
['label' => 'Replies', 'value' => data_get($post, 'replyCount', 0)],
];
}
}

View file

@ -4,6 +4,7 @@
namespace App\Services\Social;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Services\Social\Concerns\HasSocialHttpClient;
use Carbon\CarbonInterface;
@ -32,6 +33,39 @@ public function getMetrics(SocialAccount $account, ?CarbonInterface $since = nul
});
}
public function fetchPostMetrics(PostPlatform $postPlatform): array
{
$account = $postPlatform->socialAccount;
if (! $account || ! $postPlatform->platform_post_id) {
return ['unsupported' => true, 'reason' => 'missing_post_id'];
}
$response = $this->socialHttp()
->get("{$this->baseUrl}/{$postPlatform->platform_post_id}/insights", [
'metric' => 'post_impressions,post_impressions_unique,post_reactions_like_total,post_clicks',
'access_token' => $account->access_token,
]);
if ($response->failed()) {
Log::warning('Facebook post metrics fetch failed', [
'body' => $this->redactResponseBody($response->body()),
]);
return ['unsupported' => true, 'reason' => 'api_error'];
}
$insights = data_get($response->json(), 'data', []);
return collect($insights)
->map(fn (array $item) => [
'label' => ucfirst(str_replace('_', ' ', data_get($item, 'name', ''))),
'value' => (int) data_get($item, 'values.0.value', 0),
])
->values()
->all();
}
private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $since, CarbonInterface $until): array
{
$this->accessToken = $account->access_token;

View file

@ -4,8 +4,10 @@
namespace App\Services\Social;
use App\Enums\PostPlatform\ContentType;
use App\Enums\SocialAccount\Platform;
use App\Exceptions\TokenExpiredException;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Services\Social\Concerns\HasSocialHttpClient;
use Carbon\CarbonInterface;
@ -35,6 +37,56 @@ public function getMetrics(SocialAccount $account, ?CarbonInterface $since = nul
});
}
public function fetchPostMetrics(PostPlatform $postPlatform): array
{
$account = $postPlatform->socialAccount;
if (! $account || ! $postPlatform->platform_post_id) {
return ['unsupported' => true, 'reason' => 'missing_post_id'];
}
$this->baseUrl = $account->platform->instagramGraphBaseUrl();
if ($account->is_token_expired || $account->is_token_expiring_soon) {
$this->refreshTokenWithLock($account, fn () => $this->refreshToken($account));
$account->refresh();
}
$this->accessToken = $account->access_token;
// Per-post metric set differs by content type. Reels/Stories expose
// different fields than feed posts. Pick the right set per type.
$metrics = match ($postPlatform->content_type) {
ContentType::InstagramReel => 'reach,likes,comments,shares,saved,plays',
ContentType::InstagramStory => 'reach,impressions,replies',
default => 'reach,likes,comments,shares,saved,total_interactions',
};
$response = $this->socialHttp()
->get("{$this->baseUrl}/{$postPlatform->platform_post_id}/insights", [
'metric' => $metrics,
'access_token' => $this->accessToken,
]);
if ($response->failed()) {
Log::warning('Instagram post metrics fetch failed', [
'body' => $this->redactResponseBody($response->body()),
]);
return ['unsupported' => true, 'reason' => 'api_error'];
}
$data = data_get($response->json(), 'data', []);
return collect($data)
->map(fn (array $item) => [
'label' => ucfirst(str_replace('_', ' ', data_get($item, 'name', ''))),
'value' => (int) data_get($item, 'values.0.value', 0),
])
->values()
->all();
}
private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $since, CarbonInterface $until): array
{
$this->baseUrl = preg_replace('#/v[\d.]+$#', '', $account->platform->instagramGraphBaseUrl());

View file

@ -6,14 +6,18 @@
use App\Enums\PostPlatform\ContentType;
use App\Enums\SocialAccount\Platform;
use App\Exceptions\Social\ErrorCategory;
use App\Exceptions\Social\InstagramPublishException;
use App\Exceptions\TokenExpiredException;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Services\Media\MediaOptimizer;
use App\Services\Social\Concerns\HasSocialHttpClient;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class InstagramPublisher
{
@ -41,24 +45,32 @@ public function publish(PostPlatform $postPlatform): array
$media = $postPlatform->post->mediaItems;
if ($media->isEmpty()) {
throw new \Exception('Instagram requires at least one image or video.');
throw new InstagramPublishException(
userMessage: 'Instagram requires at least one image or video.',
category: ErrorCategory::MediaFormat,
);
}
$firstMedia = $media->first();
$contentType = $postPlatform->content_type;
$aspectRatio = data_get($postPlatform->meta, 'aspect_ratio');
return match ($contentType) {
ContentType::InstagramReel => $this->publishReel($instagramId, $accessToken, $content, $firstMedia),
ContentType::InstagramStory => $this->publishStory($instagramId, $accessToken, $firstMedia),
ContentType::InstagramFeed => $this->publishFeed($instagramId, $accessToken, $content, $media),
default => throw new \Exception("Unsupported Instagram content type: {$contentType?->value}"),
ContentType::InstagramFeed => $this->publishFeed($instagramId, $accessToken, $content, $media, $aspectRatio),
default => throw new InstagramPublishException(
userMessage: "Unsupported Instagram content type: {$contentType?->value}",
category: ErrorCategory::ContentPolicy,
),
};
}
private function publishFeed(string $instagramId, string $accessToken, ?string $content, $media): array
private function publishFeed(string $instagramId, string $accessToken, ?string $content, $media, ?string $aspectRatio): array
{
if ($media->count() > 1) {
return $this->publishCarousel($instagramId, $accessToken, $content, $media);
return $this->publishCarousel($instagramId, $accessToken, $content, $media, $aspectRatio);
}
$firstMedia = $media->first();
@ -67,14 +79,16 @@ private function publishFeed(string $instagramId, string $accessToken, ?string $
return $this->publishReel($instagramId, $accessToken, $content, $firstMedia);
}
return $this->publishSingleImage($instagramId, $accessToken, $content, $firstMedia);
return $this->publishSingleImage($instagramId, $accessToken, $content, $firstMedia, $aspectRatio);
}
private function publishSingleImage(string $instagramId, string $accessToken, ?string $content, $media): array
private function publishSingleImage(string $instagramId, string $accessToken, ?string $content, $media, ?string $aspectRatio): array
{
$imageUrl = $this->cropForFeed($media->url, $aspectRatio);
// Step 1: Create container
$containerResponse = $this->socialHttp()->post("{$this->baseUrl}/{$instagramId}/media", [
'image_url' => $media->url,
'image_url' => $imageUrl,
'caption' => $content,
'access_token' => $accessToken,
]);
@ -90,7 +104,10 @@ private function publishSingleImage(string $instagramId, string $accessToken, ?s
$containerId = $containerResponse->json()['id'] ?? null;
if (! $containerId) {
throw new \Exception('Instagram container creation failed: No container ID returned');
throw new InstagramPublishException(
userMessage: 'Instagram container creation failed: No container ID returned',
category: ErrorCategory::ServerError,
);
}
// Step 2: Wait for container to be ready
@ -121,7 +138,10 @@ private function publishReel(string $instagramId, string $accessToken, ?string $
$containerId = $containerResponse->json()['id'] ?? null;
if (! $containerId) {
throw new \Exception('Instagram reel container creation failed: No container ID returned');
throw new InstagramPublishException(
userMessage: 'Instagram reel container creation failed: No container ID returned',
category: ErrorCategory::ServerError,
);
}
// Wait for video processing
@ -160,7 +180,10 @@ private function publishStory(string $instagramId, string $accessToken, $media):
$containerId = $containerResponse->json()['id'] ?? null;
if (! $containerId) {
throw new \Exception('Instagram story container creation failed: No container ID returned');
throw new InstagramPublishException(
userMessage: 'Instagram story container creation failed: No container ID returned',
category: ErrorCategory::ServerError,
);
}
// Step 2: Wait for media processing
@ -170,7 +193,7 @@ private function publishStory(string $instagramId, string $accessToken, $media):
return $this->publishContainer($instagramId, $accessToken, $containerId);
}
private function publishCarousel(string $instagramId, string $accessToken, ?string $content, $mediaCollection): array
private function publishCarousel(string $instagramId, string $accessToken, ?string $content, $mediaCollection, ?string $aspectRatio): array
{
// Step 1: Create containers for each media item
$childContainers = [];
@ -187,7 +210,7 @@ private function publishCarousel(string $instagramId, string $accessToken, ?stri
$params['video_url'] = $media->url;
$params['media_type'] = 'VIDEO';
} else {
$params['image_url'] = $media->url;
$params['image_url'] = $this->cropForFeed($media->url, $aspectRatio);
}
$containerResponse = $this->socialHttp()->post("{$this->baseUrl}/{$instagramId}/media", $params);
@ -217,7 +240,10 @@ private function publishCarousel(string $instagramId, string $accessToken, ?stri
}
if (empty($childContainers)) {
throw new \Exception('Failed to create any carousel items');
throw new InstagramPublishException(
userMessage: 'Failed to create any carousel items',
category: ErrorCategory::ServerError,
);
}
// Step 2: Create carousel container
@ -238,7 +264,10 @@ private function publishCarousel(string $instagramId, string $accessToken, ?stri
$carouselId = $carouselResponse->json()['id'] ?? null;
if (! $carouselId) {
throw new \Exception('Instagram carousel container creation failed: No container ID returned');
throw new InstagramPublishException(
userMessage: 'Instagram carousel container creation failed: No container ID returned',
category: ErrorCategory::ServerError,
);
}
// Step 3: Wait for carousel to be ready
@ -266,7 +295,10 @@ private function publishContainer(string $instagramId, string $accessToken, stri
$mediaId = $publishResponse->json()['id'] ?? null;
if (! $mediaId) {
throw new \Exception('Instagram publish failed: no media ID returned');
throw new InstagramPublishException(
userMessage: 'Instagram publish failed: no media ID returned',
category: ErrorCategory::ServerError,
);
}
// Get permalink
@ -283,6 +315,54 @@ private function publishContainer(string $instagramId, string $accessToken, stri
];
}
/**
* Crop the image to the user-selected aspect ratio and return a public URL
* Instagram can fetch. Returns the original URL untouched when no ratio is
* set or 'original' is selected (caller has already validated the original
* fits IG's 4:5 to 1.91:1 range).
*/
private function cropForFeed(string $imageUrl, ?string $aspectRatio): string
{
if (! $aspectRatio || $aspectRatio === 'original') {
return $imageUrl;
}
$ratio = $this->aspectRatioToFloat($aspectRatio);
$tempInput = tempnam(sys_get_temp_dir(), 'ig_crop_in_');
try {
$download = Http::sink($tempInput)->timeout(120)->get($imageUrl);
if ($download->failed()) {
throw new InstagramPublishException(
userMessage: 'Failed to download image for cropping',
category: ErrorCategory::ServerError,
);
}
$cropped = app(MediaOptimizer::class)->cropToAspectRatio($tempInput, $ratio);
$path = 'instagram-crops/'.Str::uuid()->toString().'.jpg';
Storage::put($path, file_get_contents($cropped));
@unlink($cropped);
return Storage::url($path);
} finally {
@unlink($tempInput);
}
}
private function aspectRatioToFloat(string $ratio): float
{
return match ($ratio) {
'4:5' => 4 / 5,
'16:9' => 16 / 9,
default => 1.0,
};
}
private function waitForMediaProcessing(string $containerId, string $accessToken, int $maxAttempts = 30): void
{
for ($i = 0; $i < $maxAttempts; $i++) {
@ -304,7 +384,10 @@ private function waitForMediaProcessing(string $containerId, string $accessToken
}
if ($status === 'ERROR') {
throw new \Exception('Instagram media processing failed');
throw new InstagramPublishException(
userMessage: 'Instagram media processing failed',
category: ErrorCategory::ServerError,
);
}
sleep(5);

View file

@ -5,6 +5,7 @@
namespace App\Services\Social;
use App\Exceptions\TokenExpiredException;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Services\Social\Concerns\HasSocialHttpClient;
use Carbon\CarbonInterface;
@ -34,6 +35,42 @@ public function getMetrics(SocialAccount $account, ?CarbonInterface $since = nul
});
}
public function fetchPostMetrics(PostPlatform $postPlatform): array
{
$account = $postPlatform->socialAccount;
if (! $account || ! $postPlatform->platform_post_id) {
return ['unsupported' => true, 'reason' => 'missing_post_id'];
}
if ($account->is_token_expired || $account->is_token_expiring_soon) {
$this->refreshTokenWithLock($account, fn () => $this->refreshToken($account));
$account->refresh();
}
// platform_post_id is the share URN (e.g., "urn:li:share:12345").
$shareUrn = urlencode($postPlatform->platform_post_id);
$response = $this->socialHttp()
->withToken($account->access_token)
->get("{$this->baseUrl}/socialActions/{$shareUrn}");
if ($response->failed()) {
Log::warning('LinkedIn Page post metrics fetch failed', [
'body' => $this->redactResponseBody($response->body()),
]);
return ['unsupported' => true, 'reason' => 'api_error'];
}
$data = $response->json();
return [
['label' => 'Likes', 'value' => (int) data_get($data, 'likesSummary.totalLikes', 0)],
['label' => 'Comments', 'value' => (int) data_get($data, 'commentsSummary.aggregatedTotalComments', 0)],
];
}
private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $since, CarbonInterface $until): array
{
if ($account->is_token_expired || $account->is_token_expiring_soon) {

View file

@ -7,12 +7,14 @@
use App\Enums\SocialAccount\Platform;
use App\Models\SocialAccount;
use App\Models\Workspace;
use Illuminate\Database\Eloquent\Collection;
class LinkedInTokenSynchronizer
{
/**
* Sync tokens from LinkedIn personal to LinkedIn Page (or vice versa).
* When one account gets a new token, update the other if it exists.
* When one account gets a new token, update every linked account in the
* same workspace so multiple pages sharing the same admin keep working.
*/
public function syncTokens(SocialAccount $sourceAccount): void
{
@ -23,20 +25,18 @@ public function syncTokens(SocialAccount $sourceAccount): void
return;
}
$targetAccount = $this->findLinkedAccount($workspace, $sourceAccount, $linkedUserId);
$targets = $this->findLinkedAccounts($workspace, $sourceAccount, $linkedUserId);
if (! $targetAccount) {
return;
}
foreach ($targets as $target) {
$target->update([
'access_token' => $sourceAccount->access_token,
'refresh_token' => $sourceAccount->refresh_token,
'token_expires_at' => $sourceAccount->token_expires_at,
]);
$targetAccount->update([
'access_token' => $sourceAccount->access_token,
'refresh_token' => $sourceAccount->refresh_token,
'token_expires_at' => $sourceAccount->token_expires_at,
]);
if ($targetAccount->isDisconnected()) {
$targetAccount->markAsConnected();
if ($target->isDisconnected()) {
$target->markAsConnected();
}
}
}
@ -59,26 +59,28 @@ private function getLinkedInUserId(SocialAccount $account): ?string
}
/**
* Find the linked LinkedIn account (personal <-> page) in the same workspace.
* Find every linked LinkedIn account (personal <-> page) in the same workspace.
*
* @return Collection<int, SocialAccount>
*/
private function findLinkedAccount(Workspace $workspace, SocialAccount $sourceAccount, string $linkedUserId): ?SocialAccount
private function findLinkedAccounts(Workspace $workspace, SocialAccount $sourceAccount, string $linkedUserId): Collection
{
if ($sourceAccount->platform === Platform::LinkedIn) {
// Source is personal, find page account with same admin_user_id
// Source is personal: every page admin'd by this LinkedIn user.
return $workspace->socialAccounts()
->where('platform', Platform::LinkedInPage->value)
->whereJsonContains('meta->admin_user_id', $linkedUserId)
->first();
->get();
}
if ($sourceAccount->platform === Platform::LinkedInPage) {
// Source is page, find personal account with same platform_user_id
// Source is page: the personal LinkedIn account belonging to its admin.
return $workspace->socialAccounts()
->where('platform', Platform::LinkedIn->value)
->where('platform_user_id', $linkedUserId)
->first();
->get();
}
return null;
return new Collection;
}
}

View file

@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Services\Social;
use App\Models\PostPlatform;
use App\Services\Social\Concerns\HasSocialHttpClient;
use Illuminate\Support\Facades\Log;
class MastodonAnalytics
{
use HasSocialHttpClient;
public function fetchPostMetrics(PostPlatform $postPlatform): array
{
$account = $postPlatform->socialAccount;
if (! $account || ! $postPlatform->platform_post_id) {
return ['unsupported' => true, 'reason' => 'missing_post_id'];
}
$instance = data_get($account->meta, 'instance', 'https://mastodon.social');
$response = $this->socialHttp()
->withToken($account->access_token)
->get("{$instance}/api/v1/statuses/{$postPlatform->platform_post_id}");
if ($response->failed()) {
Log::warning('Mastodon post metrics fetch failed', [
'body' => $this->redactResponseBody($response->body()),
]);
return ['unsupported' => true, 'reason' => 'api_error'];
}
$status = $response->json();
return [
['label' => 'Favourites', 'value' => data_get($status, 'favourites_count', 0)],
['label' => 'Reblogs', 'value' => data_get($status, 'reblogs_count', 0)],
['label' => 'Replies', 'value' => data_get($status, 'replies_count', 0)],
];
}
}

View file

@ -5,6 +5,7 @@
namespace App\Services\Social;
use App\Exceptions\TokenExpiredException;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Services\Social\Concerns\HasSocialHttpClient;
use Carbon\CarbonInterface;
@ -34,6 +35,57 @@ public function getMetrics(SocialAccount $account, ?CarbonInterface $since = nul
});
}
public function fetchPostMetrics(PostPlatform $postPlatform): array
{
$account = $postPlatform->socialAccount;
if (! $account || ! $postPlatform->platform_post_id) {
return ['unsupported' => true, 'reason' => 'missing_post_id'];
}
if ($account->is_token_expired || $account->is_token_expiring_soon) {
$this->refreshTokenWithLock($account, fn () => $this->refreshToken($account));
$account->refresh();
}
$start = now()->subDays(90)->format('Y-m-d');
$end = now()->format('Y-m-d');
$response = $this->socialHttp()
->withToken($account->access_token)
->get("{$this->baseUrl}/pins/{$postPlatform->platform_post_id}/analytics", [
'start_date' => $start,
'end_date' => $end,
'metric_types' => 'IMPRESSION,SAVE,PIN_CLICK,OUTBOUND_CLICK,VIDEO_MRC_VIEW',
]);
if ($response->failed()) {
Log::warning('Pinterest post metrics fetch failed', [
'body' => $this->redactResponseBody($response->body()),
]);
return ['unsupported' => true, 'reason' => 'api_error'];
}
$data = data_get($response->json(), 'all.summary_metrics', []);
$rows = [
'IMPRESSION' => 'Impressions',
'SAVE' => 'Saves',
'PIN_CLICK' => 'Pin clicks',
'OUTBOUND_CLICK' => 'Outbound clicks',
'VIDEO_MRC_VIEW' => 'Video views',
];
return collect($rows)
->map(fn (string $label, string $key) => [
'label' => $label,
'value' => (int) data_get($data, $key, 0),
])
->values()
->all();
}
private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $since, CarbonInterface $until): array
{
if ($account->is_token_expired || $account->is_token_expiring_soon) {

View file

@ -6,6 +6,7 @@
use App\Enums\PostPlatform\ContentType;
use App\Enums\SocialAccount\Platform;
use App\Exceptions\Social\ErrorCategory;
use App\Exceptions\Social\PinterestPublishException;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
@ -38,7 +39,10 @@ public function publish(PostPlatform $postPlatform): array
ContentType::PinterestPin => $this->publishImagePin($postPlatform, $content),
ContentType::PinterestVideoPin => $this->publishVideoPin($postPlatform, $content),
ContentType::PinterestCarousel => $this->publishCarousel($postPlatform, $content),
default => throw new \Exception("Unsupported content type: {$postPlatform->content_type->value}"),
default => throw new PinterestPublishException(
userMessage: "Unsupported content type: {$postPlatform->content_type->value}",
category: ErrorCategory::ContentPolicy,
),
};
}
@ -48,13 +52,19 @@ private function publishImagePin(PostPlatform $postPlatform, ?string $content):
$media = $postPlatform->post->mediaItems->first();
if (! $media) {
throw new \Exception('Pinterest requires at least one image');
throw new PinterestPublishException(
userMessage: 'Pinterest requires at least one image',
category: ErrorCategory::MediaFormat,
);
}
$boardId = data_get($postPlatform->meta, 'board_id') ?? data_get($account->meta, 'default_board_id') ?? null;
if (! $boardId) {
throw new \Exception('Pinterest board_id is required');
throw new PinterestPublishException(
userMessage: 'Pinterest board_id is required',
category: ErrorCategory::ContentPolicy,
);
}
// Download and optimize image
@ -64,7 +74,10 @@ private function publishImagePin(PostPlatform $postPlatform, ?string $content):
$downloadResponse = Http::withOptions(['sink' => $tempFile])->timeout(600)->get($media->url);
if ($downloadResponse->failed()) {
throw new \Exception('Failed to download media: HTTP '.$downloadResponse->status());
throw new PinterestPublishException(
userMessage: 'Failed to download media: HTTP '.$downloadResponse->status(),
category: ErrorCategory::ServerError,
);
}
$detectedMime = mime_content_type($tempFile) ?: '';
@ -130,13 +143,19 @@ private function publishVideoPin(PostPlatform $postPlatform, ?string $content):
$media = $postPlatform->post->mediaItems->first();
if (! $media) {
throw new \Exception('Pinterest requires a video');
throw new PinterestPublishException(
userMessage: 'Pinterest requires a video',
category: ErrorCategory::MediaFormat,
);
}
$boardId = data_get($postPlatform->meta, 'board_id') ?? data_get($account->meta, 'default_board_id') ?? null;
if (! $boardId) {
throw new \Exception('Pinterest board_id is required');
throw new PinterestPublishException(
userMessage: 'Pinterest board_id is required',
category: ErrorCategory::ContentPolicy,
);
}
// Step 1: Register media upload
@ -157,7 +176,10 @@ private function publishVideoPin(PostPlatform $postPlatform, ?string $content):
$mediaId = $registerData['media_id'] ?? null;
if (! $mediaId) {
throw new \Exception('Pinterest media registration failed: no media ID returned');
throw new PinterestPublishException(
userMessage: 'Pinterest media registration failed: no media ID returned',
category: ErrorCategory::ServerError,
);
}
// Step 2: Upload video to S3
@ -165,7 +187,10 @@ private function publishVideoPin(PostPlatform $postPlatform, ?string $content):
$uploadUrl = $registerData['upload_url'] ?? null;
if (! $uploadUrl) {
throw new \Exception('Pinterest did not return upload URL');
throw new PinterestPublishException(
userMessage: 'Pinterest did not return upload URL',
category: ErrorCategory::ServerError,
);
}
// Build multipart form data
@ -182,13 +207,19 @@ private function publishVideoPin(PostPlatform $postPlatform, ?string $content):
$downloadResponse = Http::withOptions(['sink' => $tempFile])->timeout(600)->get($media->url);
if ($downloadResponse->failed()) {
throw new \Exception('Failed to download media: HTTP '.$downloadResponse->status());
throw new PinterestPublishException(
userMessage: 'Failed to download media: HTTP '.$downloadResponse->status(),
category: ErrorCategory::ServerError,
);
}
$videoStream = fopen($tempFile, 'r');
if ($videoStream === false) {
throw new \Exception('Failed to read video file');
throw new PinterestPublishException(
userMessage: 'Failed to read video file',
category: ErrorCategory::ServerError,
);
}
$multipart[] = [
@ -264,13 +295,19 @@ private function publishCarousel(PostPlatform $postPlatform, ?string $content):
$medias = $postPlatform->post->mediaItems;
if ($medias->count() < 2 || $medias->count() > 5) {
throw new \Exception('Pinterest carousel requires 2-5 images');
throw new PinterestPublishException(
userMessage: 'Pinterest carousel requires 2-5 images',
category: ErrorCategory::MediaFormat,
);
}
$boardId = data_get($postPlatform->meta, 'board_id') ?? data_get($account->meta, 'default_board_id') ?? null;
if (! $boardId) {
throw new \Exception('Pinterest board_id is required');
throw new PinterestPublishException(
userMessage: 'Pinterest board_id is required',
category: ErrorCategory::ContentPolicy,
);
}
$items = $medias->map(fn ($media) => [
@ -342,13 +379,20 @@ private function waitForMediaProcessing(SocialAccount $account, string $mediaId,
if ($status === 'failed') {
$failureCode = data_get($data, 'failure_code', 'unknown');
throw new \Exception("Pinterest media processing failed: {$failureCode}");
throw new PinterestPublishException(
userMessage: "Pinterest media processing failed: {$failureCode}",
category: ErrorCategory::ServerError,
platformErrorCode: (string) $failureCode,
);
}
sleep(3);
}
throw new \Exception("Pinterest media processing timeout after {$maxAttempts} attempts");
throw new PinterestPublishException(
userMessage: "Pinterest media processing timeout after {$maxAttempts} attempts",
category: ErrorCategory::ServerError,
);
}
private function refreshToken(SocialAccount $account): void

View file

@ -5,6 +5,7 @@
namespace App\Services\Social;
use App\Exceptions\TokenExpiredException;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Services\Social\Concerns\HasSocialHttpClient;
use Carbon\CarbonInterface;
@ -34,6 +35,44 @@ public function getMetrics(SocialAccount $account, ?CarbonInterface $since = nul
});
}
public function fetchPostMetrics(PostPlatform $postPlatform): array
{
$account = $postPlatform->socialAccount;
if (! $account || ! $postPlatform->platform_post_id) {
return ['unsupported' => true, 'reason' => 'missing_post_id'];
}
if ($account->is_token_expired || $account->is_token_expiring_soon) {
$this->refreshTokenWithLock($account, fn () => $this->refreshToken($account));
$account->refresh();
}
$response = $this->socialHttp()
->withToken($account->access_token)
->get("{$this->baseUrl}/{$postPlatform->platform_post_id}/insights", [
'metric' => 'views,likes,replies,reposts,quotes',
]);
if ($response->failed()) {
Log::warning('Threads post metrics fetch failed', [
'body' => $this->redactResponseBody($response->body()),
]);
return ['unsupported' => true, 'reason' => 'api_error'];
}
$data = data_get($response->json(), 'data', []);
return collect($data)
->map(fn (array $item) => [
'label' => ucfirst(str_replace('_', ' ', data_get($item, 'name', ''))),
'value' => (int) data_get($item, 'values.0.value', 0),
])
->values()
->all();
}
private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $since, CarbonInterface $until): array
{
if ($account->is_token_expired || $account->is_token_expiring_soon) {

View file

@ -4,6 +4,7 @@
namespace App\Services\Social;
use App\Exceptions\Social\ErrorCategory;
use App\Exceptions\Social\TikTokPublishException;
use App\Exceptions\TokenExpiredException;
use App\Models\PostPlatform;
@ -40,7 +41,10 @@ public function publish(PostPlatform $postPlatform): array
$media = $postPlatform->post->mediaItems;
if ($media->isEmpty()) {
throw new \Exception('TikTok requires media (video or photos) to publish.');
throw new TikTokPublishException(
userMessage: 'TikTok requires media (video or photos) to publish.',
category: ErrorCategory::MediaFormat,
);
}
$firstMedia = $media->first();
@ -55,7 +59,10 @@ public function publish(PostPlatform $postPlatform): array
return $this->publishPhotos($postPlatform, $media, $content);
}
throw new \Exception('TikTok only supports video or image content.');
throw new TikTokPublishException(
userMessage: 'TikTok only supports video or image content.',
category: ErrorCategory::MediaFormat,
);
}
private function getHttpClient(): PendingRequest
@ -141,7 +148,10 @@ private function publishVideo(PostPlatform $postPlatform, $media, ?string $conte
$publishId = data_get($data, 'data.publish_id');
if (! $publishId) {
throw new \Exception('TikTok did not return a publish_id');
throw new TikTokPublishException(
userMessage: 'TikTok did not return a publish_id',
category: ErrorCategory::ServerError,
);
}
// Wait for processing and get final status
@ -163,7 +173,10 @@ private function publishPhotos(PostPlatform $postPlatform, $mediaCollection, ?st
->toArray();
if (empty($photoUrls)) {
throw new \Exception('No valid images found for TikTok photo post');
throw new TikTokPublishException(
userMessage: 'No valid images found for TikTok photo post',
category: ErrorCategory::MediaFormat,
);
}
$creatorInfo = $this->queryCreatorInfo($postPlatform->socialAccount);
@ -203,7 +216,10 @@ private function publishPhotos(PostPlatform $postPlatform, $mediaCollection, ?st
$publishId = data_get($data, 'data.publish_id');
if (! $publishId) {
throw new \Exception('TikTok did not return a publish_id');
throw new TikTokPublishException(
userMessage: 'TikTok did not return a publish_id',
category: ErrorCategory::ServerError,
);
}
// Wait for processing and get final status

View file

@ -5,6 +5,7 @@
namespace App\Services\Social;
use App\Exceptions\TokenExpiredException;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Services\Social\Concerns\HasSocialHttpClient;
use Carbon\CarbonInterface;
@ -149,6 +150,46 @@ private function fetchTweetMetrics(array $tweetIds): array
];
}
public function fetchPostMetrics(PostPlatform $postPlatform): array
{
$account = $postPlatform->socialAccount;
if (! $account || ! $postPlatform->platform_post_id) {
return ['unsupported' => true, 'reason' => 'missing_post_id'];
}
if ($account->is_token_expired || $account->is_token_expiring_soon) {
$this->refreshTokenWithLock($account, fn () => $this->refreshToken($account));
$account->refresh();
}
$this->accessToken = $account->access_token;
$response = $this->getHttpClient()
->get("{$this->baseUrl}/tweets/{$postPlatform->platform_post_id}", [
'tweet.fields' => 'public_metrics',
]);
if ($response->failed()) {
Log::warning('X post metrics fetch failed', [
'body' => $this->redactResponseBody($response->body()),
]);
return ['unsupported' => true, 'reason' => 'api_error'];
}
$metrics = data_get($response->json(), 'data.public_metrics', []);
return [
['label' => 'Impressions', 'value' => data_get($metrics, 'impression_count', 0)],
['label' => 'Likes', 'value' => data_get($metrics, 'like_count', 0)],
['label' => 'Retweets', 'value' => data_get($metrics, 'retweet_count', 0)],
['label' => 'Replies', 'value' => data_get($metrics, 'reply_count', 0)],
['label' => 'Quotes', 'value' => data_get($metrics, 'quote_count', 0)],
['label' => 'Bookmarks', 'value' => data_get($metrics, 'bookmark_count', 0)],
];
}
private function getHttpClient(): PendingRequest
{
return $this->socialHttp()->withToken($this->accessToken);

View file

@ -5,6 +5,7 @@
namespace App\Services\Social;
use App\Exceptions\TokenExpiredException;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Services\Social\Concerns\HasSocialHttpClient;
use Carbon\CarbonInterface;
@ -34,6 +35,64 @@ public function getMetrics(SocialAccount $account, ?CarbonInterface $since = nul
});
}
public function fetchPostMetrics(PostPlatform $postPlatform): array
{
$account = $postPlatform->socialAccount;
if (! $account || ! $postPlatform->platform_post_id) {
return ['unsupported' => true, 'reason' => 'missing_post_id'];
}
if ($account->is_token_expired || $account->is_token_expiring_soon) {
$this->refreshTokenWithLock($account, fn () => $this->refreshToken($account));
$account->refresh();
}
$this->accessToken = $account->access_token;
$publishedAt = $postPlatform->published_at ?? $postPlatform->created_at;
$startDate = $publishedAt->format('Y-m-d');
$endDate = now()->format('Y-m-d');
$response = $this->getHttpClient()
->get("{$this->baseUrl}/reports", [
'ids' => 'channel==MINE',
'startDate' => $startDate,
'endDate' => $endDate,
'metrics' => 'views,estimatedMinutesWatched,averageViewDuration,likes,comments,shares',
'filters' => "video=={$postPlatform->platform_post_id}",
]);
if ($response->failed()) {
Log::warning('YouTube post metrics fetch failed', [
'body' => $this->redactResponseBody($response->body()),
]);
return ['unsupported' => true, 'reason' => 'api_error'];
}
$data = $response->json();
$columns = collect(data_get($data, 'columnHeaders', []))->pluck('name')->all();
$row = data_get($data, 'rows.0', []);
$labels = [
'views' => 'Views',
'estimatedMinutesWatched' => 'Minutes watched',
'averageViewDuration' => 'Avg view duration (s)',
'likes' => 'Likes',
'comments' => 'Comments',
'shares' => 'Shares',
];
return collect($columns)
->map(fn (string $name, int $i) => [
'label' => $labels[$name] ?? ucfirst($name),
'value' => (int) ($row[$i] ?? 0),
])
->values()
->all();
}
private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $since, CarbonInterface $until): array
{
if ($account->is_token_expired || $account->is_token_expiring_soon) {

View file

@ -4,12 +4,14 @@
namespace App\Services\Social;
use App\Exceptions\Social\ErrorCategory;
use App\Exceptions\Social\YouTubePublishException;
use App\Exceptions\TokenExpiredException;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Services\Social\Concerns\HasSocialHttpClient;
use Google\Client as GoogleClient;
use Google\Service\Exception;
use Google\Service\YouTube;
use Google\Service\YouTube\Video;
use Google\Service\YouTube\VideoSnippet;
@ -40,13 +42,19 @@ public function publish(PostPlatform $postPlatform): array
$media = $postPlatform->post->mediaItems;
if ($media->isEmpty()) {
throw new \Exception('YouTube Shorts requires a video to publish.');
throw new YouTubePublishException(
userMessage: 'YouTube Shorts requires a video to publish.',
category: ErrorCategory::MediaFormat,
);
}
$firstMedia = $media->first();
if (! $firstMedia->isVideo()) {
throw new \Exception('YouTube Shorts only supports video content.');
throw new YouTubePublishException(
userMessage: 'YouTube Shorts only supports video content.',
category: ErrorCategory::MediaFormat,
);
}
return $this->publishShort($postPlatform, $firstMedia, $account, $content);
@ -80,7 +88,10 @@ private function createGoogleClient(SocialAccount $account): GoogleClient
private function publishShort(PostPlatform $postPlatform, $media, SocialAccount $account, ?string $content): array
{
if (empty($content)) {
throw new \Exception('YouTube Shorts require a title. Please add text to your post.');
throw new YouTubePublishException(
userMessage: 'YouTube Shorts require a title. Please add text to your post.',
category: ErrorCategory::ContentPolicy,
);
}
$title = $this->buildTitle($content);
@ -96,13 +107,19 @@ private function publishShort(PostPlatform $postPlatform, $media, SocialAccount
->get($media->url);
if ($downloadResponse->failed()) {
throw new \Exception('Failed to download video for YouTube upload: HTTP '.$downloadResponse->status());
throw new YouTubePublishException(
userMessage: 'Failed to download video for YouTube upload: HTTP '.$downloadResponse->status(),
category: ErrorCategory::ServerError,
);
}
$fileSize = filesize($tempFile);
if ($fileSize === false || $fileSize < 1024) {
throw new \Exception('Downloaded video is too small or empty ('.$fileSize.' bytes), aborting upload');
throw new YouTubePublishException(
userMessage: 'Downloaded video is too small or empty ('.$fileSize.' bytes), aborting upload',
category: ErrorCategory::MediaFormat,
);
}
// Set up Google Client with deferred mode for resumable upload
@ -143,7 +160,10 @@ private function publishShort(PostPlatform $postPlatform, $media, SocialAccount
$handle = fopen($tempFile, 'r');
if ($handle === false) {
throw new \Exception('Failed to open temp file for YouTube upload');
throw new YouTubePublishException(
userMessage: 'Failed to open temp file for YouTube upload',
category: ErrorCategory::ServerError,
);
}
while (! $uploadStatus && ! feof($handle)) {
@ -157,7 +177,10 @@ private function publishShort(PostPlatform $postPlatform, $media, SocialAccount
$client->setDefer(false);
if (! $uploadStatus instanceof Video) {
throw new \Exception('YouTube upload failed: no video object returned');
throw new YouTubePublishException(
userMessage: 'YouTube upload failed: no video object returned',
category: ErrorCategory::ServerError,
);
}
$videoId = $uploadStatus->getId();
@ -166,7 +189,7 @@ private function publishShort(PostPlatform $postPlatform, $media, SocialAccount
'id' => $videoId,
'url' => "https://www.youtube.com/shorts/{$videoId}",
];
} catch (\Google\Service\Exception $e) {
} catch (Exception $e) {
$this->handleGoogleError($e);
} catch (\Throwable $e) {
Log::error('YouTube upload failed', [
@ -229,7 +252,7 @@ private function refreshToken(SocialAccount $account): void
}
private function handleGoogleError(\Google\Service\Exception $e): never
private function handleGoogleError(Exception $e): never
{
throw YouTubePublishException::fromGoogleException($e);
}

View file

@ -28,4 +28,5 @@
],
'cancel' => 'Cancel',
'loading_more' => 'Loading more...',
];

View file

@ -7,8 +7,16 @@
'new_group' => 'New Group',
'no_groups_yet' => 'No hashtag groups yet',
'no_groups_description' => 'Create hashtag groups to quickly add popular hashtags to your posts',
'no_search_results' => 'No groups match your search',
'try_different_search' => 'Try a different keyword or clear the search.',
'create_first_group' => 'Create your first group',
'hashtags_count' => ':count hashtags',
'table' => [
'name' => 'Name',
'tags' => 'Tags',
'count' => 'Count',
'created_at' => 'Created',
],
'create' => [
'title' => 'Create Hashtag Group',

View file

@ -6,7 +6,13 @@
'search' => 'Search labels...',
'new_label' => 'New Label',
'no_labels_yet' => 'No labels yet',
'no_search_results' => 'No labels match your search',
'try_different_search' => 'Try a different keyword or clear the search.',
'create_first_label' => 'Create your first label',
'table' => [
'name' => 'Name',
'created_at' => 'Created',
],
'create' => [
'title' => 'Create Label',

View file

@ -6,7 +6,17 @@
'all_posts' => 'All Posts',
'new_post' => 'New Post',
'no_posts' => 'No posts found',
'no_search_results' => 'No posts match your search',
'try_different_search' => 'Try a different keyword or clear the search.',
'start_creating' => 'Start by creating your first post.',
'table' => [
'status' => 'Status',
'content' => 'Content',
'platforms' => 'Platforms',
'labels' => 'Labels',
'scheduled_at' => 'Date',
'actions' => '',
],
'manage_posts' => 'Manage all your posts',
'delete_confirm' => 'Are you sure you want to delete this post?',
'by' => 'by',
@ -85,6 +95,13 @@
'reel' => 'Reel',
'story' => 'Story',
],
'aspect_label' => 'Aspect ratio',
'aspect' => [
'square' => 'Square (1:1)',
'portrait' => 'Portrait (4:5)',
'landscape' => 'Landscape (16:9)',
'original' => 'Original',
],
],
'facebook' => [
'settings' => 'Facebook Settings',
@ -128,6 +145,28 @@
'published' => 'Posts already published',
],
'show' => [
'title' => 'Post Details',
'edit' => 'Edit',
'no_content' => 'No caption',
'platforms' => 'Platforms',
'no_platforms' => 'No platforms selected.',
'view_on_platform' => 'View on platform',
'published_on' => 'Published on :date',
'scheduled_for' => 'Scheduled for :date',
'draft' => 'Draft',
'status_pending' => 'Pending',
'metrics' => 'Metrics',
'metrics_loading' => 'Loading metrics…',
'metrics_unavailable' => 'Metrics unavailable for this platform yet.',
'metrics_empty' => 'No metrics returned.',
'summary' => [
'platforms' => 'Platforms',
'published' => 'Published',
'failed' => 'Failed',
],
],
'edit' => [
'title' => 'Edit Post',
'view_title' => 'View Post',
@ -158,6 +197,19 @@
'view_on_platform' => 'View on platform',
'platform_status' => 'Platform status',
'compliance_incomplete' => 'Some platform settings are incomplete or incompatible with the attached media.',
'compliance' => [
'requires_media' => 'Add an image or video to publish here.',
'too_many_files' => 'Only :max file(s) allowed for this format.',
'too_few_files' => 'Add at least :min files for this format.',
'no_videos' => 'Only images are allowed for this format.',
'no_images' => 'Only videos are allowed for this format.',
'no_gifs' => 'GIFs are not supported here.',
'video_too_large' => 'Video exceeds the size limit for this platform.',
'video_too_long' => 'Video must be under :seconds seconds for this format.',
'image_too_large' => 'Image exceeds the size limit for this platform.',
'aspect_ratio_invalid' => 'Aspect ratio is not supported by this format.',
'no_content_type' => 'Pick a content type for this platform.',
],
'publishing' => 'Publishing...',
'publishing_overlay_title' => 'Your post is being published',
'publishing_overlay_subtitle' => 'This can take a few moments. You can safely leave this page.',
@ -345,6 +397,7 @@
'publishing' => 'Post is being published! It may take a few minutes to process and appear on each platform.',
'deleted' => 'Post deleted successfully!',
'cannot_edit_published' => 'Published posts cannot be edited.',
'cannot_delete_published' => 'Published posts cannot be deleted.',
'connect_first' => 'Connect at least one social network before creating a post.',
],

View file

@ -74,7 +74,8 @@
'tabs' => [
'workspace' => 'Workspace',
'brand' => 'Brand',
'users' => 'Users',
'users' => 'Members',
'api_keys' => 'API Keys',
],
'title' => 'Workspace settings',
'logo_heading' => 'Workspace logo',

View file

@ -28,4 +28,5 @@
],
'cancel' => 'Cancelar',
'loading_more' => 'Cargando más...',
];

View file

@ -7,8 +7,16 @@
'new_group' => 'Nuevo grupo',
'no_groups_yet' => 'Aún no hay grupos de hashtags',
'no_groups_description' => 'Crea grupos de hashtags para agregar rápidamente hashtags populares a tus posts',
'no_search_results' => 'Ningún grupo coincide con tu búsqueda',
'try_different_search' => 'Prueba otra palabra clave o limpia la búsqueda.',
'create_first_group' => 'Crea tu primer grupo',
'hashtags_count' => ':count hashtags',
'table' => [
'name' => 'Nombre',
'tags' => 'Hashtags',
'count' => 'Cantidad',
'created_at' => 'Creado',
],
'create' => [
'title' => 'Crear grupo de hashtags',

View file

@ -6,7 +6,13 @@
'search' => 'Buscar etiquetas...',
'new_label' => 'Nueva etiqueta',
'no_labels_yet' => 'Aún no hay etiquetas',
'no_search_results' => 'Ninguna etiqueta coincide con tu búsqueda',
'try_different_search' => 'Prueba otra palabra clave o limpia la búsqueda.',
'create_first_label' => 'Crea tu primera etiqueta',
'table' => [
'name' => 'Nombre',
'created_at' => 'Creado',
],
'create' => [
'title' => 'Crear etiqueta',

View file

@ -6,7 +6,17 @@
'all_posts' => 'Todos los posts',
'new_post' => 'Nuevo post',
'no_posts' => 'No se encontraron posts',
'no_search_results' => 'Ningún post coincide con tu búsqueda',
'try_different_search' => 'Prueba otra palabra clave o limpia la búsqueda.',
'start_creating' => 'Empieza creando tu primer post.',
'table' => [
'status' => 'Estado',
'content' => 'Contenido',
'platforms' => 'Plataformas',
'labels' => 'Etiquetas',
'scheduled_at' => 'Fecha',
'actions' => '',
],
'manage_posts' => 'Administra todos tus posts',
'delete_confirm' => '¿Estás seguro de que deseas eliminar este post?',
'by' => 'por',
@ -85,6 +95,13 @@
'reel' => 'Reel',
'story' => 'Historia',
],
'aspect_label' => 'Proporción',
'aspect' => [
'square' => 'Cuadrado (1:1)',
'portrait' => 'Vertical (4:5)',
'landscape' => 'Horizontal (16:9)',
'original' => 'Original',
],
],
'facebook' => [
'settings' => 'Configuración de Facebook',
@ -128,6 +145,28 @@
'published' => 'Posts ya publicados',
],
'show' => [
'title' => 'Detalles del post',
'edit' => 'Editar',
'no_content' => 'Sin texto',
'platforms' => 'Plataformas',
'no_platforms' => 'Ninguna plataforma seleccionada.',
'view_on_platform' => 'Ver en la plataforma',
'published_on' => 'Publicado el :date',
'scheduled_for' => 'Programado para el :date',
'draft' => 'Borrador',
'status_pending' => 'Pendiente',
'metrics' => 'Métricas',
'metrics_loading' => 'Cargando métricas…',
'metrics_unavailable' => 'Métricas aún no disponibles para esta plataforma.',
'metrics_empty' => 'No se devolvieron métricas.',
'summary' => [
'platforms' => 'Plataformas',
'published' => 'Publicado',
'failed' => 'Falló',
],
],
'edit' => [
'title' => 'Editar post',
'view_title' => 'Ver post',
@ -166,6 +205,19 @@
'view_on_platform' => 'Ver en la plataforma',
'platform_status' => 'Estado de la plataforma',
'compliance_incomplete' => 'Algunas configuraciones de plataforma están incompletas o son incompatibles con los medios adjuntos.',
'compliance' => [
'requires_media' => 'Agrega una imagen o video para publicar aquí.',
'too_many_files' => 'Solo se permiten :max archivo(s) en este formato.',
'too_few_files' => 'Agrega al menos :min archivos para este formato.',
'no_videos' => 'Solo se permiten imágenes en este formato.',
'no_images' => 'Solo se permiten videos en este formato.',
'no_gifs' => 'Los GIFs no son compatibles aquí.',
'video_too_large' => 'El video supera el límite de tamaño de esta plataforma.',
'video_too_long' => 'El video debe durar menos de :seconds segundos en este formato.',
'image_too_large' => 'La imagen supera el límite de tamaño de esta plataforma.',
'aspect_ratio_invalid' => 'La proporción de aspecto no es compatible con este formato.',
'no_content_type' => 'Elige un tipo de contenido para esta plataforma.',
],
'publishing' => 'Publicando...',
'publishing_overlay_title' => 'Tu publicación se está enviando',
'publishing_overlay_subtitle' => 'Esto puede tardar unos momentos. Puedes salir de esta página sin problemas.',
@ -358,6 +410,7 @@
'publishing' => '¡El post se está publicando! Puede tardar unos minutos en procesarse y aparecer en cada plataforma.',
'deleted' => '¡Post eliminado correctamente!',
'cannot_edit_published' => 'Los posts publicados no se pueden editar.',
'cannot_delete_published' => 'Los posts publicados no se pueden eliminar.',
'connect_first' => 'Conecta al menos una red social antes de crear un post.',
],

View file

@ -74,7 +74,8 @@
'tabs' => [
'workspace' => 'Workspace',
'brand' => 'Marca',
'users' => 'Usuarios',
'users' => 'Miembros',
'api_keys' => 'API Keys',
],
'title' => 'Configuración del workspace',
'logo_heading' => 'Logo del workspace',

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -28,4 +28,5 @@
],
'cancel' => 'Cancelar',
'loading_more' => 'Carregando mais...',
];

View file

@ -7,8 +7,16 @@
'new_group' => 'Novo Grupo',
'no_groups_yet' => 'Nenhum grupo de hashtags ainda',
'no_groups_description' => 'Crie grupos de hashtags para adicionar rapidamente hashtags populares aos seus posts',
'no_search_results' => 'Nenhum grupo corresponde à sua busca',
'try_different_search' => 'Tente outra palavra-chave ou limpe a busca.',
'create_first_group' => 'Crie seu primeiro grupo',
'hashtags_count' => ':count hashtags',
'table' => [
'name' => 'Nome',
'tags' => 'Hashtags',
'count' => 'Quantidade',
'created_at' => 'Criado',
],
'create' => [
'title' => 'Criar Grupo de Hashtags',

View file

@ -6,7 +6,13 @@
'search' => 'Buscar etiquetas...',
'new_label' => 'Nova Etiqueta',
'no_labels_yet' => 'Nenhuma etiqueta ainda',
'no_search_results' => 'Nenhuma etiqueta corresponde à sua busca',
'try_different_search' => 'Tente outra palavra-chave ou limpe a busca.',
'create_first_label' => 'Crie sua primeira etiqueta',
'table' => [
'name' => 'Nome',
'created_at' => 'Criado',
],
'create' => [
'title' => 'Criar Etiqueta',

View file

@ -6,7 +6,17 @@
'all_posts' => 'Todos os Posts',
'new_post' => 'Novo Post',
'no_posts' => 'Nenhum post encontrado',
'no_search_results' => 'Nenhum post corresponde à sua busca',
'try_different_search' => 'Tente outra palavra-chave ou limpe a busca.',
'start_creating' => 'Comece criando seu primeiro post.',
'table' => [
'status' => 'Status',
'content' => 'Conteúdo',
'platforms' => 'Plataformas',
'labels' => 'Etiquetas',
'scheduled_at' => 'Data',
'actions' => '',
],
'manage_posts' => 'Gerencie todos os seus posts',
'delete_confirm' => 'Tem certeza que deseja excluir este post?',
'by' => 'por',
@ -85,6 +95,13 @@
'reel' => 'Reel',
'story' => 'Story',
],
'aspect_label' => 'Proporção',
'aspect' => [
'square' => 'Quadrado (1:1)',
'portrait' => 'Retrato (4:5)',
'landscape' => 'Paisagem (16:9)',
'original' => 'Original',
],
],
'facebook' => [
'settings' => 'Configurações do Facebook',
@ -128,6 +145,28 @@
'published' => 'Posts já publicados',
],
'show' => [
'title' => 'Detalhes do Post',
'edit' => 'Editar',
'no_content' => 'Sem legenda',
'platforms' => 'Plataformas',
'no_platforms' => 'Nenhuma plataforma selecionada.',
'view_on_platform' => 'Ver na plataforma',
'published_on' => 'Publicado em :date',
'scheduled_for' => 'Agendado para :date',
'draft' => 'Rascunho',
'status_pending' => 'Pendente',
'metrics' => 'Métricas',
'metrics_loading' => 'Carregando métricas…',
'metrics_unavailable' => 'Métricas ainda não disponíveis para esta plataforma.',
'metrics_empty' => 'Nenhuma métrica retornada.',
'summary' => [
'platforms' => 'Plataformas',
'published' => 'Publicado',
'failed' => 'Falhou',
],
],
'edit' => [
'title' => 'Editar Post',
'view_title' => 'Visualizar Post',
@ -166,6 +205,19 @@
'view_on_platform' => 'Ver na plataforma',
'platform_status' => 'Status da plataforma',
'compliance_incomplete' => 'Algumas configurações de plataforma estão incompletas ou incompatíveis com a mídia anexada.',
'compliance' => [
'requires_media' => 'Adicione uma imagem ou vídeo para publicar aqui.',
'too_many_files' => 'Apenas :max arquivo(s) permitido(s) para este formato.',
'too_few_files' => 'Adicione pelo menos :min arquivos para este formato.',
'no_videos' => 'Apenas imagens são permitidas neste formato.',
'no_images' => 'Apenas vídeos são permitidos neste formato.',
'no_gifs' => 'GIFs não são suportados aqui.',
'video_too_large' => 'Vídeo excede o limite de tamanho desta plataforma.',
'video_too_long' => 'Vídeo deve ter menos de :seconds segundos neste formato.',
'image_too_large' => 'Imagem excede o limite de tamanho desta plataforma.',
'aspect_ratio_invalid' => 'A proporção da imagem não é suportada por este formato.',
'no_content_type' => 'Escolha um tipo de conteúdo para esta plataforma.',
],
'publishing' => 'Publicando...',
'publishing_overlay_title' => 'Seu post está sendo publicado',
'publishing_overlay_subtitle' => 'Isso pode levar alguns instantes. Você pode sair desta página sem problemas.',
@ -358,6 +410,7 @@
'publishing' => 'Post está sendo publicado! Pode levar alguns minutos para processar e aparecer em cada plataforma.',
'deleted' => 'Post excluído com sucesso!',
'cannot_edit_published' => 'Posts publicados não podem ser editados.',
'cannot_delete_published' => 'Posts publicados não podem ser excluídos.',
'connect_first' => 'Conecte pelo menos uma rede social antes de criar um post.',
],

View file

@ -74,7 +74,8 @@
'tabs' => [
'workspace' => 'Workspace',
'brand' => 'Marca',
'users' => 'Usuários',
'users' => 'Membros',
'api_keys' => 'API Keys',
],
'title' => 'Configurações do workspace',
'logo_heading' => 'Logo do workspace',

View file

@ -1,20 +1,29 @@
<script setup lang="ts">
import Breadcrumbs from '@/components/Breadcrumbs.vue';
import { SidebarTrigger } from '@/components/ui/sidebar';
import type { BreadcrumbItem } from '@/types';
withDefaults(
defineProps<{
title?: string;
breadcrumbs?: BreadcrumbItem[];
}>(),
{
title: '',
breadcrumbs: () => [],
},
);
</script>
<template>
<header
class="flex h-14 shrink-0 items-center justify-between gap-2 border-b border-border 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 class="-ml-1" />
<slot name="left">
<h2 v-if="title" class="text-lg font-semibold">{{ title }}</h2>
<Breadcrumbs v-if="breadcrumbs.length > 0" :breadcrumbs="breadcrumbs" />
<h2 v-else-if="title" class="text-lg font-semibold">{{ title }}</h2>
</slot>
</div>
<div class="flex items-center gap-2">

View file

@ -11,7 +11,6 @@ import {
IconFileCheck,
IconFileText,
IconHash,
IconKey,
IconLifebuoy,
IconPhoto,
IconMessageCircle,
@ -50,7 +49,6 @@ import {
} from '@/components/ui/sidebar';
import { accounts, analytics, calendar } from '@/routes/app';
import { edit as accountSettings } from '@/routes/app/account';
import { index as apiKeys } from '@/routes/app/api-keys';
import { index as billing } from '@/routes/app/billing';
import { index as usage } from '@/routes/app/usage';
import { index as assets } from '@/routes/app/assets';
@ -116,38 +114,45 @@ const isOwner = computed(() => {
return role === WorkspaceRole.Owner;
});
const workspaceNavItems = computed<NavItem[]>(() => [
{
title: trans('sidebar.workspace.connections'),
href: accounts.url(),
icon: IconAffiliate,
},
{
title: trans('sidebar.workspace.hashtags'),
href: hashtags.url(),
icon: IconHash,
},
{
title: trans('sidebar.workspace.labels'),
href: labels.url(),
icon: IconTag,
},
{
title: trans('sidebar.workspace.assets'),
href: assets.url(),
icon: IconPhoto,
},
{
title: trans('sidebar.workspace.api_keys'),
href: apiKeys.url(),
icon: IconKey,
},
{
title: trans('sidebar.workspace.settings'),
href: workspaceSettings.url(),
icon: IconSettings,
},
]);
const isAdmin = computed(() => {
const role = auth.value.currentWorkspace?.role;
return role === WorkspaceRole.Owner || role === WorkspaceRole.Admin;
});
const workspaceNavItems = computed<NavItem[]>(() => {
const items: NavItem[] = [
{
title: trans('sidebar.workspace.connections'),
href: accounts.url(),
icon: IconAffiliate,
},
{
title: trans('sidebar.workspace.hashtags'),
href: hashtags.url(),
icon: IconHash,
},
{
title: trans('sidebar.workspace.labels'),
href: labels.url(),
icon: IconTag,
},
{
title: trans('sidebar.workspace.assets'),
href: assets.url(),
icon: IconPhoto,
},
];
if (isAdmin.value) {
items.push({
title: trans('sidebar.workspace.settings'),
href: workspaceSettings.url(),
icon: IconSettings,
});
}
return items;
});
const accountNavItems = computed<NavItem[]>(() => [
{

View file

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

View file

@ -0,0 +1,17 @@
<script setup lang="ts">
defineProps<{
title: string;
description?: string;
total?: number;
}>();
</script>
<template>
<div>
<h2 class="text-xl font-semibold tracking-tight">
{{ title }}
<span v-if="total != null && total > 0" class="text-muted-foreground">({{ total }})</span>
</h2>
<p v-if="description" class="text-sm text-muted-foreground">{{ description }}</p>
</div>
</template>

View file

@ -0,0 +1,78 @@
<script setup lang="ts">
import { useHttp } from '@inertiajs/vue3';
import { IconChartBar, IconLoader2 } from '@tabler/icons-vue';
import { computed, onMounted, ref } from 'vue';
import { metrics as metricsRoute } from '@/routes/app/posts/platforms';
interface Metric {
label: string;
value: number;
}
interface Props {
postId: string;
postPlatformId: string;
}
const props = defineProps<Props>();
const loading = ref(true);
const metrics = ref<Metric[]>([]);
const hasMetrics = computed(() => metrics.value.length > 0);
const formatNumber = (n: number): string => {
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1).replace(/\.0$/, '') + 'M';
if (n >= 1_000) return (n / 1_000).toFixed(1).replace(/\.0$/, '') + 'K';
return n.toString();
};
onMounted(async () => {
try {
const response = await useHttp().get(
metricsRoute.url({ post: props.postId, postPlatform: props.postPlatformId }),
);
const data = response.data;
if (Array.isArray(data)) {
metrics.value = data;
}
} catch {
// Swallow — empty state hides itself.
} finally {
loading.value = false;
}
});
</script>
<template>
<!-- Loading: subtle inline indicator. -->
<div
v-if="loading"
class="flex items-center gap-2 border-t px-4 py-3 text-xs text-muted-foreground"
>
<IconLoader2 class="h-3 w-3 animate-spin" />
{{ $t('posts.show.metrics_loading') }}
</div>
<!-- Loaded with data: full metrics block. -->
<div v-else-if="hasMetrics" class="border-t px-4 py-3">
<div class="mb-2 flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
<IconChartBar class="h-3 w-3" />
{{ $t('posts.show.metrics') }}
</div>
<div class="grid grid-cols-3 gap-2">
<div
v-for="metric in metrics"
:key="metric.label"
class="rounded-md bg-muted/50 px-2.5 py-1.5"
>
<p class="text-[10px] uppercase tracking-wider text-muted-foreground">{{ metric.label }}</p>
<p class="text-sm font-semibold tabular-nums">{{ formatNumber(metric.value) }}</p>
</div>
</div>
</div>
<!-- No data / unsupported: render nothing so the card stays clean. -->
</template>

View file

@ -3,7 +3,8 @@ import { IconAlertTriangle, IconBrandInstagram, IconChevronDown, IconChevronUp }
import { computed, ref } from 'vue';
import { Avatar } from '@/components/ui/avatar';
import { getMediaValidationWarning } from '@/composables/useMedia';
import { getMediaValidationWarning, type MediaItem } from '@/composables/useMedia';
import { ContentType } from '@/enums/content-type';
interface SocialAccount {
id: string;
@ -13,40 +14,52 @@ interface SocialAccount {
avatar_url: string | null;
}
interface MediaItem {
id: string;
type?: string;
mime_type?: string;
}
interface Props {
socialAccount: SocialAccount | null;
contentType: string;
media: MediaItem[];
meta?: Record<string, any>;
disabled?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
disabled: false,
meta: () => ({}),
});
const emit = defineEmits<{
'update:contentType': [value: string];
'update:meta': [meta: Record<string, any>];
}>();
const open = ref(false);
const variants = [
{ value: 'instagram_feed', labelKey: 'posts.form.instagram.variant.feed' },
{ value: 'instagram_reel', labelKey: 'posts.form.instagram.variant.reel' },
{ value: 'instagram_story', labelKey: 'posts.form.instagram.variant.story' },
{ value: ContentType.InstagramFeed, labelKey: 'posts.form.instagram.variant.feed' },
{ value: ContentType.InstagramReel, labelKey: 'posts.form.instagram.variant.reel' },
{ value: ContentType.InstagramStory, labelKey: 'posts.form.instagram.variant.story' },
];
const aspectRatios = [
{ value: '1:1', labelKey: 'posts.form.instagram.aspect.square' },
{ value: '4:5', labelKey: 'posts.form.instagram.aspect.portrait' },
{ value: '16:9', labelKey: 'posts.form.instagram.aspect.landscape' },
{ value: 'original', labelKey: 'posts.form.instagram.aspect.original' },
];
const isFeed = computed(() => props.contentType === ContentType.InstagramFeed);
const selectedAspectRatio = computed(() => props.meta.aspect_ratio ?? '1:1');
const pickVariant = (value: string) => {
if (props.disabled) return;
emit('update:contentType', value);
};
const pickAspectRatio = (value: string) => {
if (props.disabled) return;
emit('update:meta', { ...props.meta, aspect_ratio: value });
};
const warning = computed(() => getMediaValidationWarning(props.contentType, props.media));
</script>
@ -101,6 +114,25 @@ const warning = computed(() => getMediaValidationWarning(props.contentType, prop
</div>
</div>
<div v-if="isFeed" class="space-y-2">
<p class="text-sm font-medium">{{ $t('posts.form.instagram.aspect_label') }}</p>
<div class="flex flex-wrap gap-2">
<button
v-for="ratio in aspectRatios"
:key="ratio.value"
type="button"
class="rounded-full border px-3 py-1.5 text-xs transition-colors"
:class="selectedAspectRatio === ratio.value
? 'border-primary bg-primary/10 text-primary'
: 'border-border text-muted-foreground hover:text-foreground'"
:disabled="disabled"
@click="pickAspectRatio(ratio.value)"
>
{{ $t(ratio.labelKey) }}
</button>
</div>
</div>
<p
v-if="warning"
class="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/5 p-2 text-xs text-destructive"

View file

@ -38,6 +38,7 @@ const props = defineProps<{
content: string;
media: MediaItem[];
platformContentTypes: Record<string, string>;
platformMeta?: Record<string, Record<string, any>>;
}>();
const emit = defineEmits<{
@ -138,6 +139,7 @@ const pickVariant = (value: string) => {
:media="media"
:social-account="activePlatform.social_account"
:content-type="activeContentType"
:meta="platformMeta?.[activePlatform.id] ?? {}"
/>
</PhoneMockup>
</div>

View file

@ -1,5 +1,5 @@
<script setup lang="ts">
import { IconCircleCheck, IconExternalLink, IconLoader2 } from '@tabler/icons-vue';
import { IconAlertCircle, IconCircleCheck, IconExternalLink, IconLoader2 } from '@tabler/icons-vue';
import { computed } from 'vue';
import FacebookSettings from '@/components/posts/editor/FacebookSettings.vue';
@ -80,6 +80,7 @@ const props = defineProps<{
platformConfigs: Record<string, PlatformConfig>;
platformMeta: Record<string, Record<string, any>>;
platformContentTypes: Record<string, string>;
platformIssues?: Record<string, string>;
tiktokCreatorInfos?: Record<string, TikTokCreatorInfo> | null;
media?: MediaItem[];
}>();
@ -143,17 +144,38 @@ const getPlatformAvatar = (pp: PostPlatform): string | null =>
<button
type="button"
class="flex w-20 flex-col items-center gap-1.5 transition-opacity"
:class="selectedPlatformIds.includes(pp.id) ? 'opacity-100' : 'opacity-40 hover:opacity-70'"
:class="[
platformIssues?.[pp.id] && !selectedPlatformIds.includes(pp.id) ? 'cursor-not-allowed opacity-40' : '',
platformIssues?.[pp.id] && selectedPlatformIds.includes(pp.id) ? 'opacity-100' : '',
!platformIssues?.[pp.id] ? 'opacity-100 hover:opacity-90' : '',
]"
:disabled="Boolean(platformIssues?.[pp.id]) && !selectedPlatformIds.includes(pp.id)"
@click="emit('togglePlatform', pp.id)"
>
<div class="relative">
<Avatar :src="getPlatformAvatar(pp)" :name="getPlatformDisplayName(pp)" class="h-10 w-10 shrink-0 rounded-full ring-2 ring-offset-2" :class="selectedPlatformIds.includes(pp.id) ? 'ring-primary' : 'ring-transparent'" />
<Avatar
:src="getPlatformAvatar(pp)"
:name="getPlatformDisplayName(pp)"
class="h-10 w-10 shrink-0 rounded-full ring-2 ring-offset-2"
:class="[
platformIssues?.[pp.id] && selectedPlatformIds.includes(pp.id) ? 'ring-destructive' : '',
!platformIssues?.[pp.id] && selectedPlatformIds.includes(pp.id) ? 'ring-primary' : '',
!selectedPlatformIds.includes(pp.id) ? 'ring-transparent' : '',
]"
/>
<img
:src="getPlatformLogo(pp.platform)"
:alt="pp.platform"
class="absolute -bottom-1.5 -right-1.5 h-5 w-5 rounded-full bg-background object-contain ring-1 ring-border"
/>
<Badge v-if="pp.status === 'published'" variant="default" class="absolute -top-1 -right-1 h-4 w-4 p-0">
<Badge
v-if="platformIssues?.[pp.id] && selectedPlatformIds.includes(pp.id)"
variant="destructive"
class="absolute -top-1 -right-1 h-4 w-4 p-0"
>
<IconAlertCircle class="h-2.5 w-2.5" />
</Badge>
<Badge v-else-if="pp.status === 'published'" variant="default" class="absolute -top-1 -right-1 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 -right-1 h-4 w-4 p-0 text-[9px]">!</Badge>
@ -165,6 +187,9 @@ const getPlatformAvatar = (pp: PostPlatform): string | null =>
<div class="space-y-0.5 text-xs">
<p class="font-semibold">{{ getPlatformDisplayName(pp) }}<span v-if="pp.social_account?.username" class="font-normal opacity-80">&nbsp;·&nbsp;@{{ pp.social_account.username }}</span></p>
<p class="opacity-70">{{ getPlatformLabel(pp.platform) }}</p>
<p v-if="platformIssues?.[pp.id]" class="mt-1 max-w-xs text-destructive-foreground/90">
{{ platformIssues[pp.id] }}
</p>
</div>
</TooltipContent>
</Tooltip>
@ -220,8 +245,10 @@ const getPlatformAvatar = (pp: PostPlatform): string | null =>
:social-account="pp.social_account"
:content-type="platformContentTypes[pp.id] ?? ''"
:media="media ?? []"
:meta="platformMeta[pp.id] ?? {}"
:disabled="isReadOnly"
@update:content-type="emit('update:platformContentType', pp.id, $event)"
@update:meta="emit('update:platformMeta', pp.id, $event)"
/>
</div>

View file

@ -15,6 +15,7 @@ import { computed } from 'vue';
import PostMediaPreview from '@/components/posts/previews/PostMediaPreview.vue';
import type { MediaItem } from '@/composables/useMedia';
import { ContentType } from '@/enums/content-type';
interface SocialAccount {
id: string;
@ -36,6 +37,7 @@ interface Props {
media: MediaItem[];
contentType?: string;
contentTypeOptions?: ContentTypeOption[];
meta?: Record<string, any>;
charCount?: number;
maxLength?: number;
isValid?: boolean;
@ -45,10 +47,17 @@ interface Props {
const props = defineProps<Props>();
// Content type helpers
const isReel = computed(() => props.contentType === 'instagram_reel');
const isStory = computed(() => props.contentType === 'instagram_story');
const isReel = computed(() => props.contentType === ContentType.InstagramReel);
const isStory = computed(() => props.contentType === ContentType.InstagramStory);
const isFeed = computed(() => !isReel.value && !isStory.value);
// Aspect ratio for the feed media frame. Instagram defaults to 1:1.
const feedAspectStyle = computed(() => {
const ratio = props.meta?.aspect_ratio ?? '1:1';
const value = ratio === '4:5' ? '4 / 5' : ratio === '16:9' ? '16 / 9' : ratio === 'original' ? 'auto' : '1 / 1';
return { aspectRatio: value };
});
// Format numbers like Instagram
const formatNumber = (num: number): string => {
if (num >= 1000000) {
@ -107,8 +116,8 @@ const username = computed(() => props.socialAccount.username || props.socialAcco
<IconDots class="w-4 h-4 flex-shrink-0" />
</div>
<!-- Post Media - Fixed height to prevent overflow -->
<div class="flex-1 relative bg-black min-h-0">
<!-- Post Media - Aspect ratio matches user's chosen crop -->
<div class="relative w-full bg-black" :style="feedAspectStyle">
<PostMediaPreview
:media="media"
:placeholder-icon="IconPhoto"

View file

@ -33,6 +33,7 @@ interface Props {
content: string;
media: MediaItem[];
contentType?: string;
meta?: Record<string, any>;
}
const props = defineProps<Props>();
@ -74,5 +75,6 @@ const previewComponent = computed(() => {
:content="content"
:media="media"
:content-type="contentType"
:meta="meta"
/>
</template>

View file

@ -0,0 +1,34 @@
<script setup lang="ts">
import { Link } from '@inertiajs/vue3';
interface Tab {
label: string;
href: string;
name: string;
}
defineProps<{
tabs: Tab[];
active: string;
}>();
</script>
<template>
<div
class="bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]"
>
<Link
v-for="tab in tabs"
:key="tab.name"
:href="tab.href"
:class="[
'inline-flex h-[calc(100%-1px)] items-center justify-center gap-1.5 whitespace-nowrap rounded-md border border-transparent px-3 py-1 text-sm font-medium transition-[color,box-shadow]',
active === tab.name
? 'bg-background text-foreground shadow-sm dark:border-input dark:bg-input/30 dark:text-foreground'
: 'hover:text-foreground',
]"
>
{{ tab.label }}
</Link>
</div>
</template>

View file

@ -0,0 +1,10 @@
<script setup lang="ts">
import { Spinner } from '@/components/ui/spinner';
</script>
<template>
<div class="flex items-center justify-center gap-2 border-t py-3 text-sm text-muted-foreground">
<Spinner />
<span>{{ $t('common.loading_more') }}</span>
</div>
</template>

View file

@ -5,4 +5,5 @@ export { default as TableCell } from "./TableCell.vue"
export { default as TableFooter } from "./TableFooter.vue"
export { default as TableHead } from "./TableHead.vue"
export { default as TableHeader } from "./TableHeader.vue"
export { default as TableLoadMore } from "./TableLoadMore.vue"
export { default as TableRow } from "./TableRow.vue"

View file

@ -16,7 +16,7 @@ export interface MediaItem {
export interface MediaValidationWarning {
key: string; // short key, e.g. 'gif_not_allowed'
params: Record<string, string | number>;
params: Record<string, string>;
}
const formatBytes = (bytes: number): string => {
@ -57,10 +57,10 @@ export const getMediaValidationWarning = (
return { key: 'requires_media', params: {} };
}
if (total > rules.maxFiles) {
return { key: 'max_files_exceeded', params: { max: rules.maxFiles, current: total } };
return { key: 'max_files_exceeded', params: { max: String(rules.maxFiles), current: String(total) } };
}
if (rules.minFiles && total < rules.minFiles) {
return { key: 'min_files_required', params: { min: rules.minFiles, current: total } };
return { key: 'min_files_required', params: { min: String(rules.minFiles), current: String(total) } };
}
if (! rules.acceptVideos && videos.length > 0) {
return { key: 'no_video_allowed', params: {} };

View file

@ -0,0 +1,40 @@
import {
IconAlertCircle,
IconCircleCheck,
IconClock,
IconFileText,
IconLoader2,
} from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
interface StatusConfig {
color: string;
icon: typeof IconFileText;
label: string;
}
const CONFIGS: Record<string, Pick<StatusConfig, 'color' | 'icon'>> = {
draft: { color: 'bg-neutral-100 text-neutral-800 dark:bg-neutral-800 dark:text-neutral-200', icon: IconFileText },
scheduled: { color: 'bg-blue-100 text-blue-800 dark:bg-blue-950 dark:text-blue-300', icon: IconClock },
publishing: { color: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-950 dark:text-yellow-300', icon: IconLoader2 },
published: { color: 'bg-green-100 text-green-800 dark:bg-green-950 dark:text-green-300', icon: IconCircleCheck },
partially_published: { color: 'bg-orange-100 text-orange-800 dark:bg-orange-950 dark:text-orange-300', icon: IconAlertCircle },
failed: { color: 'bg-red-100 text-red-800 dark:bg-red-950 dark:text-red-300', icon: IconAlertCircle },
};
export const getPostStatusConfig = (status: string): StatusConfig => {
const config = CONFIGS[status] ?? CONFIGS.draft;
return { ...config, label: trans(`posts.status.${status}`) };
};
export const getPlatformStatusConfig = (status: string): StatusConfig => {
const map: Record<string, string> = {
pending: 'draft',
publishing: 'publishing',
published: 'published',
failed: 'failed',
};
const key = map[status] ?? 'draft';
const config = CONFIGS[key];
return { ...config, label: trans(`posts.edit.status.${status}`) };
};

View file

@ -0,0 +1,23 @@
export const ContentType = {
InstagramFeed: 'instagram_feed',
InstagramReel: 'instagram_reel',
InstagramStory: 'instagram_story',
LinkedInPost: 'linkedin_post',
LinkedInCarousel: 'linkedin_carousel',
LinkedInPagePost: 'linkedin_page_post',
LinkedInPageCarousel: 'linkedin_page_carousel',
FacebookPost: 'facebook_post',
FacebookReel: 'facebook_reel',
FacebookStory: 'facebook_story',
TikTokVideo: 'tiktok_video',
YouTubeShort: 'youtube_short',
XPost: 'x_post',
ThreadsPost: 'threads_post',
PinterestPin: 'pinterest_pin',
PinterestVideoPin: 'pinterest_video_pin',
PinterestCarousel: 'pinterest_carousel',
BlueskyPost: 'bluesky_post',
MastodonPost: 'mastodon_post',
} as const;
export type ContentTypeValue = (typeof ContentType)[keyof typeof ContentType];

View file

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

View file

@ -7,17 +7,20 @@ import AppSidebar from '@/components/AppSidebar.vue';
import Toast from '@/components/Toast.vue';
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
import { heartbeat as heartbeatRoute } from '@/routes/app/presence';
import type { BreadcrumbItem } from '@/types';
const page = usePage();
const isOpen = page.props.sidebarOpen;
type Props = {
title?: string;
breadcrumbs?: BreadcrumbItem[];
fullWidth?: boolean;
};
withDefaults(defineProps<Props>(), {
title: '',
breadcrumbs: () => [],
fullWidth: false,
});
@ -44,7 +47,11 @@ onBeforeUnmount(() => {
<SidebarProvider :default-open="isOpen">
<AppSidebar />
<SidebarInset class="overflow-x-hidden">
<AppHeader v-if="$slots['header'] || $slots['header-actions'] || title" :title="$slots['header'] ? '' : title">
<AppHeader
v-if="$slots['header'] || $slots['header-actions'] || title || breadcrumbs.length > 0"
:title="$slots['header'] ? '' : title"
:breadcrumbs="breadcrumbs"
>
<template v-if="$slots['header']" #left>
<slot name="header" />
</template>

View file

@ -1,11 +1,13 @@
<script setup lang="ts">
import { Head, router } from '@inertiajs/vue3';
import { IconAffiliate, IconAlertCircle, IconCheck, IconExternalLink, IconPlus, IconRefresh, IconTrash } from '@tabler/icons-vue';
import { ref } from 'vue';
import { trans } from 'laravel-vue-i18n';
import { computed, ref } from 'vue';
import AddSocialDialog, { type AvailablePlatform } from '@/components/accounts/AddSocialDialog.vue';
import ConfirmDeleteModal from '@/components/ConfirmDeleteModal.vue';
import EmptyState from '@/components/EmptyState.vue';
import PageHeader from '@/components/PageHeader.vue';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
@ -15,6 +17,9 @@ import { getPlatformLogo } from '@/composables/usePlatformLogo';
import date from '@/date';
import AppLayout from '@/layouts/AppLayout.vue';
import { disconnect as disconnectAccount, toggle as toggleAccount } from '@/routes/app/accounts';
import type { BreadcrumbItem } from '@/types';
const breadcrumbs = computed<BreadcrumbItem[]>(() => [{ title: trans('accounts.page_title') }]);
interface SocialAccount {
id: string;
platform: string;
@ -95,14 +100,16 @@ const handleDisconnect = (accountId: string) => {
<template>
<Head :title="$t('accounts.page_title')" />
<AppLayout :title="$t('accounts.page_title')">
<template #header-actions>
<Button @click="isAddDialogOpen = true">
{{ $t('accounts.add_social') }}
</Button>
</template>
<AppLayout :breadcrumbs="breadcrumbs">
<div class="flex h-full flex-1 flex-col gap-4 p-4">
<PageHeader :title="$t('accounts.page_title')" />
<div class="flex items-center justify-end gap-3">
<Button @click="isAddDialogOpen = true">
{{ $t('accounts.add_social') }}
</Button>
</div>
<div class="flex flex-col gap-6 p-6">
<EmptyState
v-if="props.accounts.length === 0"
:icon="IconAffiliate"

View file

@ -1,15 +1,22 @@
<script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import { trans } from 'laravel-vue-i18n';
import { computed } from 'vue';
import GalleryBrowser from '@/components/assets/GalleryBrowser.vue';
import PageHeader from '@/components/PageHeader.vue';
import AppLayout from '@/layouts/AppLayout.vue';
import type { BreadcrumbItem } from '@/types';
const breadcrumbs = computed<BreadcrumbItem[]>(() => [{ title: trans('assets.title') }]);
</script>
<template>
<Head :title="$t('assets.title')" />
<AppLayout :title="$t('assets.title')">
<div class="flex flex-col gap-6 p-6">
<AppLayout :breadcrumbs="breadcrumbs">
<div class="flex h-full flex-1 flex-col gap-4 p-4">
<PageHeader :title="$t('assets.title')" />
<GalleryBrowser mode="standalone" />
</div>
</AppLayout>

View file

@ -2,19 +2,30 @@
import { Head, InfiniteScroll, router } from '@inertiajs/vue3';
import { IconHash, IconPencil, IconSearch, IconTrash } from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import { ref, watch } from 'vue';
import { computed, ref, watch } from 'vue';
import ConfirmDeleteModal from '@/components/ConfirmDeleteModal.vue';
import EmptyState from '@/components/EmptyState.vue';
import CreateDialog from '@/components/hashtags/CreateDialog.vue';
import EditDialog from '@/components/hashtags/EditDialog.vue';
import PageHeader from '@/components/PageHeader.vue';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Skeleton } from '@/components/ui/skeleton';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableLoadMore,
TableRow,
} from '@/components/ui/table';
import dayjs from '@/dayjs';
import debounce from '@/debounce';
import AppLayout from '@/layouts/AppLayout.vue';
import { index as hashtagsIndex, destroy as hashtagsDestroy } from '@/routes/app/hashtags';
import { destroy as hashtagsDestroy, index as hashtagsIndex } from '@/routes/app/hashtags';
import type { BreadcrumbItem } from '@/types';
interface Workspace {
id: string;
name: string;
@ -29,17 +40,13 @@ interface Hashtag {
interface ScrollHashtags {
data: Hashtag[];
meta: {
hasNextPage: boolean;
};
meta: { hasNextPage: boolean };
}
interface Props {
workspace: Workspace;
hashtags: ScrollHashtags;
filters: {
search: string;
};
filters: { search: string };
}
const props = defineProps<Props>();
@ -50,16 +57,16 @@ const search = debounce(() => {
router.get(
hashtagsIndex.url(),
{ search: searchQuery.value || undefined },
{
preserveState: true,
preserveScroll: true,
reset: ['hashtags'],
},
{ preserveState: true, preserveScroll: true, reset: ['hashtags'] },
);
}, 300);
watch(searchQuery, () => search());
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
{ title: trans('hashtags.title') },
]);
const deleteModal = ref<InstanceType<typeof ConfirmDeleteModal> | null>(null);
const isCreateDialogOpen = ref(false);
const isEditDialogOpen = ref(false);
@ -71,92 +78,98 @@ const openEditDialog = (hashtag: Hashtag) => {
};
const handleDelete = (hashtagId: string) => {
deleteModal.value?.open({
url: hashtagsDestroy.url(hashtagId),
});
deleteModal.value?.open({ url: hashtagsDestroy.url(hashtagId) });
};
const getHashtagCount = (hashtags: string): number => {
return hashtags.split(/[\s,]+/).filter(tag => tag.startsWith('#') || tag.length > 0).length;
};
const getHashtagCount = (hashtags: string): number =>
hashtags.split(/[\s,]+/).filter((tag) => tag.startsWith('#') || tag.length > 0).length;
const formatDate = (date: string): string => dayjs.utc(date).local().format('D MMM YYYY');
const hasActiveSearch = computed(() => Boolean(searchQuery.value?.trim()));
</script>
<template>
<Head :title="$t('hashtags.title')" />
<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
v-model="searchQuery"
:placeholder="trans('hashtags.search')"
class="w-64 pl-9"
/>
</div>
<Button @click="isCreateDialogOpen = true">
{{ $t('hashtags.new_group') }}
</Button>
</template>
<AppLayout :breadcrumbs="breadcrumbs">
<div class="flex h-full flex-1 flex-col gap-4 p-4">
<PageHeader :title="$t('hashtags.title')" />
<div class="flex flex-col gap-6 p-6">
<div class="flex items-center justify-between gap-3">
<div class="relative">
<IconSearch class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
v-model="searchQuery"
:placeholder="trans('hashtags.search')"
class="w-64 pl-9"
/>
</div>
<Button @click="isCreateDialogOpen = true">{{ $t('hashtags.new_group') }}</Button>
</div>
<EmptyState
v-if="hashtags.data.length === 0"
:icon="IconHash"
:title="$t('hashtags.no_groups_yet')"
:description="$t('hashtags.no_groups_description')"
:title="hasActiveSearch ? $t('hashtags.no_search_results') : $t('hashtags.no_groups_yet')"
:description="hasActiveSearch ? $t('hashtags.try_different_search') : $t('hashtags.no_groups_description')"
/>
<div v-else>
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<Card v-for="hashtag in hashtags.data" :key="hashtag.id">
<CardHeader class="pb-3">
<div class="flex items-center justify-between">
<CardTitle class="text-lg">{{ hashtag.name }}</CardTitle>
<div class="flex items-center gap-1">
<Button variant="ghost" size="icon" class="h-8 w-8" @click="openEditDialog(hashtag)">
<IconPencil class="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon"
class="h-8 w-8 text-destructive hover:text-destructive"
@click="handleDelete(hashtag.id)">
<IconTrash class="h-4 w-4" />
</Button>
</div>
</div>
<CardDescription>
{{ $t('hashtags.hashtags_count', { count: String(getHashtagCount(hashtag.hashtags)) }) }}
</CardDescription>
</CardHeader>
<CardContent>
<p class="text-sm text-muted-foreground line-clamp-3">
{{ hashtag.hashtags }}
</p>
</CardContent>
</Card>
</div>
<InfiniteScroll data="hashtags" #default="{ loading }">
<div v-if="loading" class="grid gap-4 md:grid-cols-2 lg:grid-cols-3 mt-4">
<Card v-for="i in 3" :key="i">
<CardHeader class="pb-3">
<div class="flex items-center justify-between">
<Skeleton class="h-6 w-32" />
<div class="flex gap-1">
<Skeleton class="h-8 w-8" />
<Skeleton class="h-8 w-8" />
<div v-else class="rounded-md border">
<InfiniteScroll data="hashtags" items-element="#hashtags-body" preserve-url>
<Table>
<TableHeader>
<TableRow>
<TableHead>{{ $t('hashtags.table.name') }}</TableHead>
<TableHead>{{ $t('hashtags.table.tags') }}</TableHead>
<TableHead>{{ $t('hashtags.table.count') }}</TableHead>
<TableHead>{{ $t('hashtags.table.created_at') }}</TableHead>
<TableHead class="text-right" />
</TableRow>
</TableHeader>
<TableBody id="hashtags-body">
<TableRow
v-for="hashtag in hashtags.data"
:key="hashtag.id"
class="cursor-pointer"
@click="openEditDialog(hashtag)"
>
<TableCell class="font-medium">{{ hashtag.name }}</TableCell>
<TableCell class="max-w-md">
<p class="truncate text-sm text-muted-foreground">{{ hashtag.hashtags }}</p>
</TableCell>
<TableCell class="text-muted-foreground tabular-nums">
{{ getHashtagCount(hashtag.hashtags) }}
</TableCell>
<TableCell class="text-muted-foreground">{{ formatDate(hashtag.created_at) }}</TableCell>
<TableCell class="text-right" @click.stop>
<div class="flex justify-end gap-1">
<Button
variant="ghost"
size="icon"
class="size-8"
@click="openEditDialog(hashtag)"
>
<IconPencil class="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
class="size-8 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
@click="handleDelete(hashtag.id)"
>
<IconTrash class="h-4 w-4" />
</Button>
</div>
</div>
<Skeleton class="h-4 w-20" />
</CardHeader>
<CardContent>
<Skeleton class="h-4 w-full" />
<Skeleton class="h-4 w-3/4 mt-2" />
</CardContent>
</Card>
</div>
</TableCell>
</TableRow>
</TableBody>
</Table>
<template #next="{ loading }">
<TableLoadMore v-if="loading" />
</template>
</InfiniteScroll>
</div>
</div>
@ -165,7 +178,11 @@ const getHashtagCount = (hashtags: string): number => {
<CreateDialog v-model:open="isCreateDialogOpen" />
<EditDialog v-model:open="isEditDialogOpen" :hashtag="editingHashtag" />
<ConfirmDeleteModal ref="deleteModal" :title="$t('hashtags.delete.title')"
:description="$t('hashtags.delete.description')" :action="$t('hashtags.delete.confirm')"
:cancel="$t('hashtags.delete.cancel')" />
</template>
<ConfirmDeleteModal
ref="deleteModal"
:title="$t('hashtags.delete.title')"
:description="$t('hashtags.delete.description')"
:action="$t('hashtags.delete.confirm')"
:cancel="$t('hashtags.delete.cancel')"
/>
</template>

View file

@ -2,19 +2,30 @@
import { Head, InfiniteScroll, router } from '@inertiajs/vue3';
import { IconPencil, IconSearch, IconTag, IconTrash } from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import { ref, watch } from 'vue';
import { computed, ref, watch } from 'vue';
import ConfirmDeleteModal from '@/components/ConfirmDeleteModal.vue';
import EmptyState from '@/components/EmptyState.vue';
import CreateDialog from '@/components/labels/CreateDialog.vue';
import EditDialog from '@/components/labels/EditDialog.vue';
import PageHeader from '@/components/PageHeader.vue';
import { Button } from '@/components/ui/button';
import { Card, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Skeleton } from '@/components/ui/skeleton';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableLoadMore,
TableRow,
} from '@/components/ui/table';
import dayjs from '@/dayjs';
import debounce from '@/debounce';
import AppLayout from '@/layouts/AppLayout.vue';
import { index as labelsIndex, destroy as labelsDestroy } from '@/routes/app/labels';
import { destroy as labelsDestroy, index as labelsIndex } from '@/routes/app/labels';
import type { BreadcrumbItem } from '@/types';
interface Label {
id: string;
name: string;
@ -24,121 +35,128 @@ interface Label {
interface ScrollLabels {
data: Label[];
meta: {
hasNextPage: boolean;
};
meta: { hasNextPage: boolean };
}
interface Props {
labels: ScrollLabels;
filters: {
search: string;
};
filters: { search: string };
}
const props = defineProps<Props>();
const deleteModal = ref<InstanceType<typeof ConfirmDeleteModal> | null>(null);
const isCreateDialogOpen = ref(false);
const isEditDialogOpen = ref(false);
const editingLabel = ref<Label | null>(null);
const searchQuery = ref(props.filters.search);
const search = debounce(() => {
router.get(
labelsIndex.url(),
{ search: searchQuery.value || undefined },
{
preserveState: true,
preserveScroll: true,
reset: ['labels'],
},
{ preserveState: true, preserveScroll: true, reset: ['labels'] },
);
}, 300);
watch(searchQuery, () => search());
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
{ title: trans('labels.title') },
]);
const deleteModal = ref<InstanceType<typeof ConfirmDeleteModal> | null>(null);
const isCreateDialogOpen = ref(false);
const isEditDialogOpen = ref(false);
const editingLabel = ref<Label | null>(null);
const openEditDialog = (label: Label) => {
editingLabel.value = label;
isEditDialogOpen.value = true;
};
const handleDelete = (labelId: string) => {
deleteModal.value?.open({
url: labelsDestroy.url(labelId),
});
deleteModal.value?.open({ url: labelsDestroy.url(labelId) });
};
const formatDate = (date: string): string => dayjs.utc(date).local().format('D MMM YYYY');
const hasActiveSearch = computed(() => Boolean(searchQuery.value?.trim()));
</script>
<template>
<Head :title="$t('labels.title')" />
<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
v-model="searchQuery"
:placeholder="trans('labels.search')"
class="w-64 pl-9"
/>
</div>
<Button @click="isCreateDialogOpen = true">
{{ $t('labels.new_label') }}
</Button>
</template>
<AppLayout :breadcrumbs="breadcrumbs">
<div class="flex h-full flex-1 flex-col gap-4 p-4">
<PageHeader :title="$t('labels.title')" :description="$t('labels.description')" />
<div class="flex items-center justify-between gap-3">
<div class="relative">
<IconSearch class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
v-model="searchQuery"
:placeholder="trans('labels.search')"
class="w-64 pl-9"
/>
</div>
<Button @click="isCreateDialogOpen = true">{{ $t('labels.new_label') }}</Button>
</div>
<div class="flex flex-col gap-6 p-6">
<EmptyState
v-if="labels.data.length === 0"
:icon="IconTag"
:title="$t('labels.no_labels_yet')"
:description="$t('labels.description')"
:title="hasActiveSearch ? $t('labels.no_search_results') : $t('labels.no_labels_yet')"
:description="hasActiveSearch ? $t('labels.try_different_search') : $t('labels.description')"
/>
<div v-else>
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<Card v-for="label in labels.data" :key="label.id">
<CardHeader class="pb-3">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="h-6 w-6 rounded-md" :style="{ backgroundColor: label.color }" />
<CardTitle class="text-lg">{{ label.name }}</CardTitle>
</div>
<div class="flex items-center gap-1">
<Button variant="ghost" size="icon" class="h-8 w-8" @click="openEditDialog(label)">
<IconPencil class="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon"
class="h-8 w-8 text-destructive hover:text-destructive"
@click="handleDelete(label.id)">
<IconTrash class="h-4 w-4" />
</Button>
</div>
</div>
</CardHeader>
</Card>
</div>
<div v-else class="rounded-md border">
<InfiniteScroll data="labels" items-element="#labels-body" preserve-url>
<Table>
<TableHeader>
<TableRow>
<TableHead class="w-12" />
<TableHead>{{ $t('labels.table.name') }}</TableHead>
<TableHead>{{ $t('labels.table.created_at') }}</TableHead>
<TableHead class="text-right" />
</TableRow>
</TableHeader>
<TableBody id="labels-body">
<TableRow
v-for="label in labels.data"
:key="label.id"
class="cursor-pointer"
@click="openEditDialog(label)"
>
<TableCell>
<div class="size-5 rounded-md" :style="{ backgroundColor: label.color }" />
</TableCell>
<TableCell class="font-medium">{{ label.name }}</TableCell>
<TableCell class="text-muted-foreground">{{ formatDate(label.created_at) }}</TableCell>
<TableCell class="text-right" @click.stop>
<div class="flex justify-end gap-1">
<Button
variant="ghost"
size="icon"
class="size-8"
@click="openEditDialog(label)"
>
<IconPencil class="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
class="size-8 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
@click="handleDelete(label.id)"
>
<IconTrash class="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
</TableBody>
</Table>
<InfiniteScroll data="labels" #default="{ loading }">
<div v-if="loading" class="grid gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 mt-4">
<Card v-for="i in 4" :key="i">
<CardHeader class="pb-3">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<Skeleton class="h-6 w-6 rounded-md" />
<Skeleton class="h-6 w-24" />
</div>
<div class="flex gap-1">
<Skeleton class="h-8 w-8" />
<Skeleton class="h-8 w-8" />
</div>
</div>
</CardHeader>
</Card>
</div>
<template #next="{ loading }">
<TableLoadMore v-if="loading" />
</template>
</InfiniteScroll>
</div>
</div>
@ -147,7 +165,11 @@ const handleDelete = (labelId: string) => {
<CreateDialog v-model:open="isCreateDialogOpen" />
<EditDialog v-model:open="isEditDialogOpen" :label="editingLabel" />
<ConfirmDeleteModal ref="deleteModal" :title="$t('labels.delete.title')"
:description="$t('labels.delete.description')" :action="$t('labels.delete.confirm')"
:cancel="$t('labels.delete.cancel')" />
<ConfirmDeleteModal
ref="deleteModal"
:title="$t('labels.delete.title')"
:description="$t('labels.delete.description')"
:action="$t('labels.delete.confirm')"
:cancel="$t('labels.delete.cancel')"
/>
</template>

View file

@ -12,7 +12,7 @@ import date from '@/date';
import dayjs from '@/dayjs';
import AppLayout from '@/layouts/AppLayout.vue';
import { calendar } from '@/routes/app';
import { store as storePost, edit as editPost } from '@/routes/app/posts';
import { edit as editPost, show as showPost, store as storePost } from '@/routes/app/posts';
interface PostPlatform {
id: string;
@ -229,7 +229,9 @@ const getStatusColor = (status: string): string => {
};
const getPostUrl = (post: Post): string => {
return editPost.url(post.id);
return ['draft', 'scheduled', 'failed'].includes(post.status)
? editPost.url(post.id)
: showPost.url(post.id);
};
const formatTime = (scheduledAt: string): string => {

View file

@ -144,61 +144,68 @@ const updatePlatformContentType = (platformId: string, contentType: string) => {
platformContentTypes.value = { ...platformContentTypes.value, [platformId]: contentType };
};
const isMediaValidForContentType = (contentType: string, mediaItems: MediaItem[]): boolean => {
const getMediaIncompatibilityReason = (contentType: string, mediaItems: MediaItem[]): string | null => {
const rules = getMediaRulesForContentType(contentType);
const videos = mediaItems.filter((m) => m.type === 'video' || m.mime_type?.startsWith('video/'));
const images = mediaItems.filter((m) => m.type === 'image' || m.mime_type?.startsWith('image/'));
const gifs = mediaItems.filter((m) => m.mime_type === 'image/gif');
const total = mediaItems.length;
if (rules.requiresMedia && total === 0) return false;
if (total > rules.maxFiles) return false;
if (rules.minFiles && total < rules.minFiles) return false;
if (! rules.acceptVideos && videos.length > 0) return false;
if (! rules.acceptImages && images.length > 0) return false;
if (! rules.acceptsGif && gifs.length > 0) return false;
// Order matters: type-mismatch errors (image vs video vs gif) are more
// fundamental than count errors. Telling the user "YouTube doesn't accept
// images" is more actionable than "too many files" when they're mixing types.
if (rules.requiresMedia && total === 0) return trans('posts.edit.compliance.requires_media');
if (!rules.acceptVideos && videos.length > 0) return trans('posts.edit.compliance.no_videos');
if (!rules.acceptImages && images.length > 0) return trans('posts.edit.compliance.no_images');
if (!rules.acceptsGif && gifs.length > 0) return trans('posts.edit.compliance.no_gifs');
if (total > rules.maxFiles) return trans('posts.edit.compliance.too_many_files', { max: rules.maxFiles });
if (rules.minFiles && total < rules.minFiles) return trans('posts.edit.compliance.too_few_files', { min: rules.minFiles });
// Size / duration / aspect checks — only when metadata is available.
for (const m of mediaItems) {
const isVideo = m.type === 'video' || m.mime_type?.startsWith('video/');
const size = m.size ?? 0;
const duration = m.meta?.duration ?? 0;
const width = m.meta?.width ?? 0;
const height = m.meta?.height ?? 0;
const duration = m.meta?.duration ?? 0;
if (isVideo) {
if (rules.maxVideoBytes && size > 0 && size > rules.maxVideoBytes) return false;
if (rules.maxVideoDurationSec && duration > 0 && duration > rules.maxVideoDurationSec) return false;
} else {
if (rules.maxImageBytes && size > 0 && size > rules.maxImageBytes) return false;
if (rules.maxVideoBytes && size > 0 && size > rules.maxVideoBytes) return trans('posts.edit.compliance.video_too_large');
if (rules.maxVideoDurationSec && duration > 0 && duration > rules.maxVideoDurationSec) {
return trans('posts.edit.compliance.video_too_long', { seconds: rules.maxVideoDurationSec });
}
} else if (rules.maxImageBytes && size > 0 && size > rules.maxImageBytes) {
return trans('posts.edit.compliance.image_too_large');
}
if (width > 0 && height > 0 && (rules.aspectRatioMin || rules.aspectRatioMax)) {
const ratio = width / height;
if (rules.aspectRatioMin && ratio < rules.aspectRatioMin) return false;
if (rules.aspectRatioMax && ratio > rules.aspectRatioMax) return false;
if (rules.aspectRatioMin && ratio < rules.aspectRatioMin) return trans('posts.edit.compliance.aspect_ratio_invalid');
if (rules.aspectRatioMax && ratio > rules.aspectRatioMax) return trans('posts.edit.compliance.aspect_ratio_invalid');
}
}
return true;
return null;
};
const instagramComplianceValid = computed(() =>
post.value.post_platforms
.filter((pp) => ['instagram', 'instagram-facebook'].includes(pp.platform) && selectedPlatformIds.value.includes(pp.id))
.every((pp) => {
const contentType = platformContentTypes.value[pp.id];
return Boolean(contentType) && isMediaValidForContentType(contentType, media.value);
}),
);
const platformIssues = computed<Record<string, string>>(() => {
const issues: Record<string, string> = {};
const facebookComplianceValid = computed(() =>
post.value.post_platforms
.filter((pp) => pp.platform === 'facebook' && selectedPlatformIds.value.includes(pp.id))
.every((pp) => {
const contentType = platformContentTypes.value[pp.id];
return Boolean(contentType) && isMediaValidForContentType(contentType, media.value);
}),
for (const pp of post.value.post_platforms) {
const contentType = platformContentTypes.value[pp.id];
if (!contentType) {
issues[pp.id] = trans('posts.edit.compliance.no_content_type');
continue;
}
const reason = getMediaIncompatibilityReason(contentType, media.value);
if (reason) issues[pp.id] = reason;
}
return issues;
});
const mediaCompliancePerPlatformValid = computed(
() => selectedPlatformIds.value.every((id) => !platformIssues.value[id]),
);
// TikTok compliance per docs:
@ -216,6 +223,22 @@ const tiktokComplianceValid = computed(() => {
});
});
const canSchedule = computed(
() => mediaCompliancePerPlatformValid.value && tiktokComplianceValid.value,
);
const postActionTooltip = computed(() => {
if (canSchedule.value) return '';
const reasons = post.value.post_platforms
.filter((pp) => selectedPlatformIds.value.includes(pp.id) && platformIssues.value[pp.id])
.map((pp) => `${pp.platform_name ?? pp.platform}: ${platformIssues.value[pp.id]}`);
if (reasons.length === 0) return trans('posts.edit.compliance_incomplete');
return reasons.join('\n');
});
// Schedule
const getLocalSchedule = () => {
if (!post.value.scheduled_at) return '';
@ -238,6 +261,10 @@ const selectedLabelIds = ref<string[]>(post.value.labels?.map((l) => l.id) || []
const isSubmitting = ref(false);
const isSaving = ref(false);
const showSaved = ref(false);
const isPostActionDisabled = computed(
() => isSubmitting.value || selectedPlatformIds.value.length === 0 || !canSchedule.value,
);
const queryParams = typeof window !== 'undefined' ? new URLSearchParams(window.location.search) : null;
const initialTabFromQuery = (() => {
const tab = queryParams?.get('tab');
@ -529,15 +556,15 @@ useEcho(`post.${post.value.id}`, '.PostCommentCreated', (e: any) => {
<PickTimePopover
v-model="scheduledDateTime"
:disabled="isSubmitting || selectedPlatformIds.length === 0 || !tiktokComplianceValid || !instagramComplianceValid || !facebookComplianceValid"
:disabled="isPostActionDisabled"
@confirm="hasPickedTime = true"
>
<Button
type="button"
variant="secondary"
size="sm"
:disabled="isSubmitting || selectedPlatformIds.length === 0 || !tiktokComplianceValid || !instagramComplianceValid || !facebookComplianceValid"
:title="!tiktokComplianceValid || !instagramComplianceValid || !facebookComplianceValid ? $t('posts.edit.compliance_incomplete') : ''"
:disabled="isPostActionDisabled"
:title="postActionTooltip"
>
<IconCalendar class="h-4 w-4" />
{{ pickTimeLabel }}
@ -547,8 +574,8 @@ useEcho(`post.${post.value.id}`, '.PostCommentCreated', (e: any) => {
<Button
type="button"
size="sm"
:disabled="isSubmitting || selectedPlatformIds.length === 0 || !tiktokComplianceValid || !instagramComplianceValid || !facebookComplianceValid"
:title="!tiktokComplianceValid || !instagramComplianceValid || !facebookComplianceValid ? $t('posts.edit.compliance_incomplete') : ''"
:disabled="isPostActionDisabled"
:title="postActionTooltip"
@click="submit(hasPickedTime ? 'scheduled' : 'publishing')"
>
{{ hasPickedTime ? $t('posts.edit.schedule') : $t('posts.edit.post_now') }}
@ -752,6 +779,7 @@ useEcho(`post.${post.value.id}`, '.PostCommentCreated', (e: any) => {
:content="content"
:media="media"
:platform-content-types="platformContentTypes"
:platform-meta="platformMeta"
@update:platform-content-type="updatePlatformContentType"
/>
</TabsContent>
@ -766,6 +794,7 @@ useEcho(`post.${post.value.id}`, '.PostCommentCreated', (e: any) => {
:platform-configs="platformConfigs"
:platform-meta="platformMeta"
:platform-content-types="platformContentTypes"
:platform-issues="platformIssues"
:tiktok-creator-infos="tiktokCreatorInfos"
:media="media"
@toggle-platform="togglePlatform"

View file

@ -1,22 +1,33 @@
<script setup lang="ts">
import { Head, Link, InfiniteScroll, router } from '@inertiajs/vue3';
import { IconClock, IconCircleCheck, IconAlertCircle, IconLoader2, IconFileText, IconEye, IconSearch, IconTrash } from '@tabler/icons-vue';
import { Head, InfiniteScroll, Link, router } from '@inertiajs/vue3';
import { IconFileText, IconSearch, IconTrash } from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import { computed, ref, watch } from 'vue';
import { index as postsIndex, store as storePost, edit as editPost, destroy as destroyPost } from '@/actions/App/Http/Controllers/App/PostController';
import { destroy as destroyPost, edit as editPost, index as postsIndex, show as showPost, store as storePost } from '@/actions/App/Http/Controllers/App/PostController';
import ConfirmDeleteModal from '@/components/ConfirmDeleteModal.vue';
import EmptyState from '@/components/EmptyState.vue';
import PageHeader from '@/components/PageHeader.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Skeleton } from '@/components/ui/skeleton';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableLoadMore,
TableRow,
} from '@/components/ui/table';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { getPlatformLabel, getPlatformLogo } from '@/composables/usePlatformLogo';
import { getPostStatusConfig } from '@/composables/usePostStatus';
import dayjs from '@/dayjs';
import debounce from '@/debounce';
import AppLayout from '@/layouts/AppLayout.vue';
import type { BreadcrumbItem } from '@/types';
interface SocialAccount {
id: string;
platform: string;
@ -35,11 +46,6 @@ interface PostPlatform {
social_account: SocialAccount;
}
interface User {
id: string;
name: string;
}
interface Label {
id: string;
name: string;
@ -52,7 +58,6 @@ interface Post {
scheduled_at: string | null;
published_at: string | null;
post_platforms: PostPlatform[];
user: User;
labels: Label[];
}
@ -89,7 +94,7 @@ const search = debounce(() => {
{
preserveState: true,
preserveScroll: true,
reset: ['posts'],
replace: true,
},
);
}, 300);
@ -98,213 +103,190 @@ watch(searchQuery, () => search());
const pageTitle = computed(() => {
if (props.currentStatus) {
const statusLabel = trans(`posts.status.${props.currentStatus}`);
return `${trans('posts.title')} - ${statusLabel}`;
return trans(`posts.status.${props.currentStatus}`);
}
return trans('posts.all_posts');
});
const getStatusConfig = (status: string) => {
const configs: Record<string, { color: string; icon: typeof IconFileText }> = {
'draft': { color: 'bg-neutral-100 text-neutral-800', icon: IconFileText },
'scheduled': { color: 'bg-blue-100 text-blue-800', icon: IconClock },
'publishing': { color: 'bg-yellow-100 text-yellow-800', icon: IconLoader2 },
'published': { color: 'bg-green-100 text-green-800', icon: IconCircleCheck },
'partially_published': { color: 'bg-orange-100 text-orange-800', icon: IconAlertCircle },
'failed': { color: 'bg-red-100 text-red-800', icon: IconAlertCircle },
};
const config = configs[status] || configs['draft'];
return {
...config,
label: trans(`posts.status.${status}`),
};
};
const breadcrumbs = computed<BreadcrumbItem[]>(() => {
const items: BreadcrumbItem[] = [{ title: trans('posts.title'), href: postsIndex.url() }];
if (props.currentStatus) {
items.push({ title: trans(`posts.status.${props.currentStatus}`) });
}
return items;
});
const formatDateTime = (date: string | null): string => {
if (!date) return '-';
if (!date) return '—';
return dayjs.utc(date).local().format('D MMM YYYY, HH:mm');
};
const getEnabledPlatforms = (post: Post) => {
return post.post_platforms.filter(pp => pp.enabled);
};
const getEnabledPlatforms = (post: Post) => post.post_platforms.filter((pp) => pp.enabled);
const getPostPreview = (post: Post): string => {
const enabledPlatforms = getEnabledPlatforms(post);
const noContent = trans('calendar.no_content');
if (enabledPlatforms.length === 0) return noContent;
const firstPlatform = enabledPlatforms[0];
const content = firstPlatform.content || '';
return content.length > 100 ? content.substring(0, 100) + '...' : content || noContent;
const enabled = getEnabledPlatforms(post);
if (enabled.length === 0) return trans('calendar.no_content');
return enabled[0].content || trans('calendar.no_content');
};
const canEdit = (post: Post): boolean => {
return ['draft', 'scheduled'].includes(post.status);
};
const canEdit = (post: Post): boolean => ['draft', 'scheduled', 'failed'].includes(post.status);
const postUrl = (post: Post): string =>
canEdit(post) ? editPost.url(post.id) : showPost.url(post.id);
const deleteModal = ref<InstanceType<typeof ConfirmDeleteModal> | null>(null);
const handleDelete = (post: Post) => {
deleteModal.value?.open({
url: destroyPost.url(post.id),
});
deleteModal.value?.open({ url: destroyPost.url(post.id) });
};
const hasActiveSearch = computed(() => Boolean(searchQuery.value?.trim()));
</script>
<template>
<Head :title="pageTitle" />
<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
v-model="searchQuery"
:placeholder="trans('posts.search')"
class="w-64 pl-9"
/>
</div>
<Link :href="storePost.url()" method="post">
<Button>
{{ $t('posts.new_post') }}
</Button>
</Link>
</template>
<AppLayout :breadcrumbs="breadcrumbs">
<div class="flex h-full flex-1 flex-col gap-4 p-4">
<PageHeader :title="pageTitle" />
<div class="flex flex-col gap-6 p-6">
<!-- Toolbar -->
<div class="flex items-center justify-between gap-3">
<div class="relative">
<IconSearch class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
v-model="searchQuery"
:placeholder="trans('posts.search')"
class="w-64 pl-9"
/>
</div>
<Link :href="storePost.url()" method="post">
<Button>{{ $t('posts.new_post') }}</Button>
</Link>
</div>
<EmptyState
v-if="posts.data.length === 0"
:icon="IconFileText"
:title="$t('posts.no_posts')"
:description="$t('posts.start_creating')"
:title="hasActiveSearch ? $t('posts.no_search_results') : $t('posts.no_posts')"
:description="hasActiveSearch ? $t('posts.try_different_search') : $t('posts.start_creating')"
/>
<div v-else>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<Card v-for="post in posts.data" :key="post.id" class="flex flex-col py-0">
<CardContent class="p-4 flex-1 flex flex-col">
<div class="flex items-center justify-between gap-2 mb-3">
<div class="flex items-center gap-2 flex-wrap">
<Badge :class="getStatusConfig(post.status).color">
<component :is="getStatusConfig(post.status).icon" class="mr-1 h-3 w-3" />
{{ getStatusConfig(post.status).label }}
<div v-else class="rounded-md border">
<InfiniteScroll data="posts" items-element="#posts-body" preserve-url>
<Table>
<TableHeader>
<TableRow>
<TableHead>{{ $t('posts.table.status') }}</TableHead>
<TableHead>{{ $t('posts.table.content') }}</TableHead>
<TableHead>{{ $t('posts.table.platforms') }}</TableHead>
<TableHead>{{ $t('posts.table.labels') }}</TableHead>
<TableHead>{{ $t('posts.table.scheduled_at') }}</TableHead>
<TableHead class="text-right">{{ $t('posts.table.actions') }}</TableHead>
</TableRow>
</TableHeader>
<TableBody id="posts-body">
<TableRow
v-for="post in posts.data"
:key="post.id"
class="cursor-pointer"
@click="router.visit(postUrl(post))"
>
<TableCell>
<Badge :class="getPostStatusConfig(post.status).color">
<component :is="getPostStatusConfig(post.status).icon" class="mr-1 h-3 w-3" />
{{ getPostStatusConfig(post.status).label }}
</Badge>
<div v-if="post.labels?.length" class="flex items-center gap-1">
<span v-for="label in post.labels.slice(0, 2)" :key="label.id"
class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium"
:style="{ backgroundColor: label.color + '20', color: label.color }">
{{ label.name }}
</span>
</TableCell>
<TableCell class="max-w-md">
<p class="truncate text-sm">{{ getPostPreview(post) }}</p>
</TableCell>
<TableCell>
<div class="flex items-center gap-2">
<div class="flex -space-x-2">
<TooltipProvider
v-for="pp in getEnabledPlatforms(post).slice(0, 4)"
:key="pp.id"
:delay-duration="200"
>
<Tooltip>
<TooltipTrigger as-child>
<img
:src="getPlatformLogo(pp.platform)"
:alt="pp.platform"
class="size-6 rounded-full ring-2 ring-background"
/>
</TooltipTrigger>
<TooltipContent>
<div class="space-y-0.5 text-xs">
<p class="font-semibold">
{{ pp.social_account?.display_name ?? pp.platform }}<span
v-if="pp.social_account?.username"
class="font-normal opacity-80"
>&nbsp;·&nbsp;@{{ pp.social_account.username }}</span>
</p>
<p class="opacity-70">{{ getPlatformLabel(pp.platform) }}</p>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<span
v-if="getEnabledPlatforms(post).length > 4"
class="text-xs text-muted-foreground"
>+{{ getEnabledPlatforms(post).length - 4 }}</span>
</div>
</TableCell>
<TableCell>
<div v-if="post.labels?.length" class="flex flex-wrap items-center gap-1">
<span
v-for="label in post.labels.slice(0, 2)"
:key="label.id"
class="inline-flex items-center rounded px-1.5 py-0.5 text-xs font-medium"
:style="{ backgroundColor: label.color + '20', color: label.color }"
>{{ label.name }}</span>
<span v-if="post.labels.length > 2" class="text-xs text-muted-foreground">
+{{ post.labels.length - 2 }}
</span>
</div>
</div>
<span class="text-xs text-muted-foreground shrink-0">
{{ formatDateTime(post.scheduled_at) }}
</span>
</div>
<span v-else class="text-xs text-muted-foreground">—</span>
</TableCell>
<TableCell class="text-muted-foreground">
{{ formatDateTime(post.scheduled_at ?? post.published_at) }}
</TableCell>
<TableCell class="text-right" @click.stop>
<Tooltip v-if="canEdit(post)">
<TooltipTrigger as-child>
<Button
variant="ghost"
size="icon"
class="size-8 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
@click.stop="handleDelete(post)"
>
<IconTrash class="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ $t('posts.actions.delete') }}</TooltipContent>
</Tooltip>
</TableCell>
</TableRow>
</TableBody>
</Table>
<p class="text-sm text-foreground line-clamp-3 mb-4 flex-1">
{{ getPostPreview(post) }}
</p>
<div class="flex items-center justify-between mt-auto">
<div class="flex items-center gap-2">
<div class="flex -space-x-2">
<TooltipProvider v-for="pp in getEnabledPlatforms(post).slice(0, 4)" :key="pp.id" :delay-duration="200">
<Tooltip>
<TooltipTrigger as-child>
<img
:src="getPlatformLogo(pp.platform)"
:alt="pp.platform"
class="h-6 w-6 rounded-full ring-2 ring-background"
/>
</TooltipTrigger>
<TooltipContent>
<div class="space-y-0.5 text-xs">
<p class="font-semibold">
{{ pp.social_account?.display_name ?? pp.platform }}<span v-if="pp.social_account?.username" class="font-normal opacity-80">&nbsp;·&nbsp;@{{ pp.social_account.username }}</span>
</p>
<p class="opacity-70">{{ getPlatformLabel(pp.platform) }}</p>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<span v-if="getEnabledPlatforms(post).length > 4"
class="text-xs text-muted-foreground">
+{{ getEnabledPlatforms(post).length - 4 }}
</span>
</div>
<TooltipProvider>
<div class="flex items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Link :href="editPost.url(post.id)">
<Button variant="ghost" size="icon" class="h-8 w-8">
<IconEye class="h-4 w-4" />
</Button>
</Link>
</TooltipTrigger>
<TooltipContent>
{{ $t('posts.actions.view') }}
</TooltipContent>
</Tooltip>
<Tooltip v-if="canEdit(post)">
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" class="h-8 w-8"
@click="handleDelete(post)">
<IconTrash class="h-4 w-4 text-red-500" />
</Button>
</TooltipTrigger>
<TooltipContent>
{{ $t('posts.actions.delete') }}
</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
</div>
</CardContent>
</Card>
</div>
<InfiniteScroll data="posts" #default="{ loading }">
<div v-if="loading" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mt-4">
<Card v-for="i in 3" :key="i">
<CardContent class="p-4">
<div class="space-y-3">
<div class="flex items-center justify-between">
<Skeleton class="h-5 w-20" />
<Skeleton class="h-4 w-24" />
</div>
<Skeleton class="h-4 w-full" />
<Skeleton class="h-4 w-full" />
<Skeleton class="h-4 w-3/4" />
<div class="flex items-center justify-between pt-2">
<div class="flex -space-x-2">
<Skeleton class="h-6 w-6 rounded-full" />
<Skeleton class="h-6 w-6 rounded-full" />
<Skeleton class="h-6 w-6 rounded-full" />
</div>
<div class="flex gap-1">
<Skeleton class="h-8 w-8" />
<Skeleton class="h-8 w-8" />
</div>
</div>
</div>
</CardContent>
</Card>
</div>
<template #next="{ loading }">
<TableLoadMore v-if="loading" />
</template>
</InfiniteScroll>
</div>
</div>
</AppLayout>
<ConfirmDeleteModal ref="deleteModal" :title="$t('posts.edit.delete_modal.title')"
:description="$t('posts.edit.delete_modal.description')" :action="$t('posts.edit.delete_modal.action')"
:cancel="$t('posts.edit.delete_modal.cancel')" />
</template>
<ConfirmDeleteModal
ref="deleteModal"
:title="$t('posts.edit.delete_modal.title')"
:description="$t('posts.edit.delete_modal.description')"
:action="$t('posts.edit.delete_modal.action')"
:cancel="$t('posts.edit.delete_modal.cancel')"
/>
</template>

View file

@ -0,0 +1,334 @@
<script setup lang="ts">
import { Head, router } from '@inertiajs/vue3';
import { useEcho } from '@laravel/echo-vue';
import { IconCalendar, IconExternalLink, IconLoader2, IconX } from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import { computed, ref } from 'vue';
import PostPlatformMetrics from '@/components/posts/PostPlatformMetrics.vue';
import { Avatar } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent } from '@/components/ui/card';
import { getPlatformLabel, getPlatformLogo } from '@/composables/usePlatformLogo';
import { getPlatformStatusConfig, getPostStatusConfig } from '@/composables/usePostStatus';
import dayjs from '@/dayjs';
import AppLayout from '@/layouts/AppLayout.vue';
interface MediaItem {
id: 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;
}
interface PostPlatform {
id: string;
platform: string;
platform_name: string | null;
platform_username: string | null;
platform_avatar: string | null;
content_type: string | null;
status: 'pending' | 'publishing' | 'published' | 'failed';
platform_url: string | null;
error_message: string | null;
published_at: string | null;
enabled: boolean;
social_account: SocialAccount | null;
}
interface Post {
id: string;
content: string;
media: MediaItem[];
status: string;
scheduled_at: string | null;
published_at: string | null;
post_platforms: PostPlatform[];
labels?: { id: string; name: string; color: string }[];
}
interface Workspace {
id: string;
name: string;
}
const props = defineProps<{
workspace: Workspace;
post: Post;
}>();
const enabledPlatforms = computed(() => props.post.post_platforms.filter((pp) => pp.enabled));
const publishedCount = computed(() => enabledPlatforms.value.filter((pp) => pp.status === 'published').length);
const failedCount = computed(() => enabledPlatforms.value.filter((pp) => pp.status === 'failed').length);
const isPublishing = computed(() => props.post.status === 'publishing');
const postStatus = computed(() => getPostStatusConfig(props.post.status));
const pageTitle = computed(() => {
const snippet = props.post.content?.trim().split('\n')[0]?.slice(0, 60) ?? '';
return snippet ? `${trans('posts.show.title')} · ${snippet}${props.post.content.length > 60 ? '…' : ''}` : trans('posts.show.title');
});
const getDisplayName = (pp: PostPlatform): string =>
pp.social_account?.display_name ?? pp.platform_name ?? pp.platform;
const getDisplayUsername = (pp: PostPlatform): string | null =>
pp.social_account?.username ?? pp.platform_username;
const getDisplayAvatar = (pp: PostPlatform): string | null =>
pp.social_account?.avatar_url ?? pp.platform_avatar;
const formatDateTime = (date: string | null): string =>
date ? dayjs.utc(date).local().format('D MMM YYYY, HH:mm') : '';
const mediaGridClass = computed(() => {
const count = props.post.media.length;
if (count <= 1) return 'grid-cols-1';
if (count === 2) return 'grid-cols-2';
return 'grid-cols-3';
});
const lightboxOpen = ref(false);
const lightboxIndex = ref(0);
const openLightbox = (i: number) => {
lightboxIndex.value = i;
lightboxOpen.value = true;
};
useEcho(`post.${props.post.id}`, '.PostPlatformStatusUpdated', () => {
router.reload({ only: ['post'] });
});
</script>
<template>
<Head :title="pageTitle" />
<AppLayout :title="pageTitle" full-width>
<!-- Publishing state: clean centered loader, nothing else visible. -->
<div v-if="isPublishing" class="flex flex-1 flex-col items-center justify-center gap-3 p-6">
<IconLoader2 class="h-10 w-10 animate-spin text-primary" />
<p class="text-base font-medium">{{ $t('posts.edit.publishing_overlay_title') }}</p>
<p class="max-w-md text-center text-sm text-muted-foreground">
{{ $t('posts.edit.publishing_overlay_subtitle') }}
</p>
</div>
<div v-else class="grid flex-1 grid-cols-1 lg:grid-cols-2 lg:divide-x">
<!-- LEFT: post preview -->
<div class="border-b p-6 lg:border-b-0">
<div class="flex flex-col gap-4">
<!-- Status row -->
<div class="flex flex-wrap items-center gap-3">
<Badge :class="postStatus.color">
<component :is="postStatus.icon" class="mr-1 h-3 w-3" />
{{ postStatus.label }}
</Badge>
<span class="flex items-center gap-1.5 text-sm text-muted-foreground">
<IconCalendar class="h-4 w-4" />
<span v-if="post.published_at">
{{ $t('posts.show.published_on', { date: formatDateTime(post.published_at) }) }}
</span>
<span v-else-if="post.scheduled_at">
{{ $t('posts.show.scheduled_for', { date: formatDateTime(post.scheduled_at) }) }}
</span>
<span v-else>{{ $t('posts.show.draft') }}</span>
</span>
</div>
<!-- Post preview card -->
<Card class="overflow-hidden py-0">
<CardContent v-if="post.content" class="p-6">
<p class="whitespace-pre-wrap text-sm leading-relaxed">{{ post.content }}</p>
</CardContent>
<div
v-if="post.media.length > 0"
:class="['grid gap-1 bg-black', mediaGridClass, post.content ? 'border-t' : '']"
>
<button
v-for="(item, i) in post.media"
:key="item.id"
type="button"
class="group relative aspect-square overflow-hidden bg-muted transition-opacity hover:opacity-90"
@click="openLightbox(i)"
>
<video
v-if="item.type === 'video' || item.mime_type?.startsWith('video/')"
:src="item.url"
class="h-full w-full object-cover"
muted
/>
<img
v-else
:src="item.url"
:alt="item.original_filename"
class="h-full w-full object-cover"
loading="lazy"
/>
</button>
</div>
<div
v-if="post.labels && post.labels.length > 0"
class="flex flex-wrap gap-2 border-t bg-muted/30 px-6 py-3"
>
<span
v-for="label in post.labels"
:key="label.id"
class="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs font-medium"
:style="{ backgroundColor: label.color + '20', color: label.color }"
>
{{ label.name }}
</span>
</div>
</Card>
</div>
</div>
<!-- RIGHT: platforms breakdown -->
<div class="flex flex-col gap-4 p-6">
<!-- Summary stats -->
<div v-if="enabledPlatforms.length > 0" class="grid grid-cols-3 gap-4">
<Card>
<CardContent class="p-6">
<p class="text-sm text-muted-foreground">{{ $t('posts.show.summary.platforms') }}</p>
<p class="mt-2 text-3xl font-bold tracking-tight tabular-nums">{{ enabledPlatforms.length }}</p>
</CardContent>
</Card>
<Card>
<CardContent class="p-6">
<p class="text-sm text-muted-foreground">{{ $t('posts.show.summary.published') }}</p>
<p class="mt-2 text-3xl font-bold tracking-tight tabular-nums">{{ publishedCount }}</p>
</CardContent>
</Card>
<Card>
<CardContent class="p-6">
<p class="text-sm text-muted-foreground">{{ $t('posts.show.summary.failed') }}</p>
<p class="mt-2 text-3xl font-bold tracking-tight tabular-nums">{{ failedCount }}</p>
</CardContent>
</Card>
</div>
<!-- Platforms list -->
<h2 class="text-sm font-semibold">{{ $t('posts.show.platforms') }}</h2>
<Card v-if="enabledPlatforms.length === 0" class="py-0">
<CardContent class="p-8 text-center text-sm text-muted-foreground">
{{ $t('posts.show.no_platforms') }}
</CardContent>
</Card>
<div v-else class="space-y-3">
<Card v-for="pp in enabledPlatforms" :key="pp.id" class="overflow-hidden py-0">
<CardContent class="p-0">
<div class="flex items-center gap-3 p-4">
<div class="relative shrink-0">
<Avatar
:src="getDisplayAvatar(pp)"
:name="getDisplayName(pp)"
class="size-11 rounded-full"
/>
<img
:src="getPlatformLogo(pp.platform)"
:alt="pp.platform"
class="absolute -bottom-0.5 -right-0.5 size-5 rounded-full bg-background object-contain ring-2 ring-card"
/>
</div>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-semibold">{{ getDisplayName(pp) }}</p>
<p class="truncate text-xs text-muted-foreground">
<span v-if="getDisplayUsername(pp)">@{{ getDisplayUsername(pp) }} · </span>
{{ getPlatformLabel(pp.platform) }}
</p>
</div>
<Badge :class="getPlatformStatusConfig(pp.status).color" class="shrink-0">
<component
:is="getPlatformStatusConfig(pp.status).icon"
class="mr-1 h-3 w-3"
:class="pp.status === 'publishing' ? 'animate-spin' : ''"
/>
{{ getPlatformStatusConfig(pp.status).label }}
</Badge>
</div>
<!-- Failed: error message -->
<div
v-if="pp.status === 'failed' && pp.error_message"
class="border-t bg-destructive/5 px-4 py-3 text-xs text-destructive"
>
{{ pp.error_message }}
</div>
<!-- Published: metrics + footer -->
<template v-if="pp.status === 'published'">
<PostPlatformMetrics
:post-id="post.id"
:post-platform-id="pp.id"
/>
<div class="flex items-center justify-between border-t bg-muted/20 px-4 py-2.5 text-xs">
<span v-if="pp.published_at" class="text-muted-foreground">
{{ formatDateTime(pp.published_at) }}
</span>
<a
v-if="pp.platform_url"
:href="pp.platform_url"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1 font-medium text-primary hover:underline"
>
{{ $t('posts.show.view_on_platform') }}
<IconExternalLink class="size-3.5" />
</a>
</div>
</template>
</CardContent>
</Card>
</div>
</div>
</div>
<!-- Lightbox -->
<Teleport to="body">
<div
v-if="lightboxOpen"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/90 p-4"
@click="lightboxOpen = false"
>
<button
type="button"
class="absolute right-4 top-4 rounded-full bg-white/10 p-2 text-white hover:bg-white/20"
@click.stop="lightboxOpen = false"
>
<IconX class="size-5" />
</button>
<video
v-if="post.media[lightboxIndex]?.type === 'video' || post.media[lightboxIndex]?.mime_type?.startsWith('video/')"
:src="post.media[lightboxIndex]?.url"
class="max-h-full max-w-full"
controls
autoplay
@click.stop
/>
<img
v-else
:src="post.media[lightboxIndex]?.url"
:alt="post.media[lightboxIndex]?.original_filename"
class="max-h-full max-w-full object-contain"
@click.stop
/>
</div>
</Teleport>
</AppLayout>
</template>

View file

@ -9,6 +9,7 @@ import CreateApiKeyDialog from '@/components/api-keys/CreateApiKeyDialog.vue';
import ConfirmDeleteModal from '@/components/ConfirmDeleteModal.vue';
import EmptyState from '@/components/EmptyState.vue';
import HeadingSmall from '@/components/HeadingSmall.vue';
import SettingsTabsNav from '@/components/settings/SettingsTabsNav.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
@ -29,6 +30,10 @@ import {
import date from '@/date';
import AppLayout from '@/layouts/AppLayout.vue';
import { copyToClipboard } from '@/lib/utils';
import { members as membersRoute } from '@/routes/app';
import { index as apiKeysRoute } from '@/routes/app/api-keys';
import { brand as brandRoute, settings as workspaceSettings } from '@/routes/app/workspace';
import type { BreadcrumbItem } from '@/types';
interface ApiToken {
id: string;
name: string;
@ -50,114 +55,120 @@ const newToken = computed(() => (page.props.flash as Record<string, unknown>)?.p
const createDialogOpen = ref(false);
const confirmDeleteModal = ref<InstanceType<typeof ConfirmDeleteModal> | null>(null);
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
{ title: trans('settings.workspace.title'), href: workspaceSettings.url() },
{ title: trans('settings.workspace.tabs.api_keys') },
]);
const tabs = computed(() => [
{ name: 'workspace', label: trans('settings.workspace.tabs.workspace'), href: workspaceSettings.url() },
{ name: 'brand', label: trans('settings.workspace.tabs.brand'), href: brandRoute.url() },
{ name: 'members', label: trans('settings.workspace.tabs.users'), href: membersRoute.url() },
{ name: 'api-keys', label: trans('settings.workspace.tabs.api_keys'), href: apiKeysRoute.url() },
]);
</script>
<template>
<AppLayout :title="$t('settings.api_keys.page_title')">
<Head :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>
<AppLayout :breadcrumbs="breadcrumbs">
<div class="mx-auto max-w-4xl space-y-6 px-4 py-6">
<SettingsTabsNav :tabs="tabs" active="api-keys" />
<div class="mx-auto max-w-4xl px-4 py-6 space-y-12">
<div class="flex flex-col space-y-6">
<div class="flex items-center justify-between">
<HeadingSmall
:title="$t('settings.api_keys.heading')"
:description="$t('settings.api_keys.description')"
/>
<Button @click="createDialogOpen = true">
{{ $t('settings.api_keys.create') }}
<div class="flex items-center justify-between">
<HeadingSmall
:title="$t('settings.api_keys.heading')"
:description="$t('settings.api_keys.description')"
/>
<Button @click="createDialogOpen = true">
{{ $t('settings.api_keys.create') }}
</Button>
</div>
<!-- New token alert -->
<div
v-if="newToken"
class="rounded-lg border border-green-200 bg-green-50 p-4 dark:border-green-900 dark:bg-green-950"
>
<p class="mb-2 text-sm font-medium text-green-800 dark:text-green-200">
{{ $t('settings.api_keys.new_token_message') }}
</p>
<div class="flex items-center gap-2">
<code class="flex-1 rounded bg-white px-3 py-2 font-mono text-sm dark:bg-black">
{{ newToken }}
</code>
<Button variant="outline" size="sm" @click="copyToClipboard(newToken!, trans('settings.api_keys.new_token_message'))">
{{ $t('settings.api_keys.copy') }}
</Button>
</div>
<!-- New token alert -->
<div
v-if="newToken"
class="rounded-lg border border-green-200 bg-green-50 p-4 dark:border-green-900 dark:bg-green-950"
>
<p class="mb-2 text-sm font-medium text-green-800 dark:text-green-200">
{{ $t('settings.api_keys.new_token_message') }}
</p>
<div class="flex items-center gap-2">
<code class="flex-1 rounded bg-white px-3 py-2 font-mono text-sm dark:bg-black">
{{ newToken }}
</code>
<Button variant="outline" size="sm" @click="copyToClipboard(newToken!, trans('settings.api_keys.new_token_message'))">
{{ $t('settings.api_keys.copy') }}
</Button>
</div>
</div>
<div v-if="apiTokens.length > 0" class="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>{{ $t('settings.api_keys.table.name') }}</TableHead>
<TableHead>{{ $t('settings.api_keys.table.key') }}</TableHead>
<TableHead>{{ $t('settings.api_keys.table.status') }}</TableHead>
<TableHead>{{ $t('settings.api_keys.table.expires') }}</TableHead>
<TableHead>{{ $t('settings.api_keys.table.last_used') }}</TableHead>
<TableHead class="w-10" />
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="token in apiTokens" :key="token.id">
<TableCell class="font-medium">
{{ token.name }}
</TableCell>
<TableCell>
<code class="text-xs text-muted-foreground">
{{ token.key_hint }}
</code>
</TableCell>
<TableCell>
<Badge :variant="token.status === 'active' ? 'default' : 'secondary'">
{{ token.status }}
</Badge>
</TableCell>
<TableCell class="text-muted-foreground">
{{ token.expires_at ? date.formatDateTime(token.expires_at) : $t('settings.api_keys.table.never') }}
</TableCell>
<TableCell class="text-muted-foreground">
{{ token.last_used_at ? date.diffForHumans(token.last_used_at) : $t('settings.api_keys.table.never') }}
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost" size="icon" class="h-8 w-8">
<IconDots class="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
@click="copyToClipboard(token.id, trans('settings.api_keys.actions.copy_id_success'))"
>
<IconCopy class="size-4" />
{{ $t('settings.api_keys.actions.copy_id') }}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
@click="confirmDeleteModal?.open({ url: ApiKeyController.destroy.url(token), confirmText: token.name })"
>
<IconTrash class="size-4" />
{{ $t('settings.api_keys.actions.delete') }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
<EmptyState
v-else-if="!newToken"
:icon="IconKey"
:title="$t('settings.api_keys.empty.title')"
:description="$t('settings.api_keys.empty.description')"
/>
</div>
<div v-if="apiTokens.length > 0" class="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>{{ $t('settings.api_keys.table.name') }}</TableHead>
<TableHead>{{ $t('settings.api_keys.table.key') }}</TableHead>
<TableHead>{{ $t('settings.api_keys.table.status') }}</TableHead>
<TableHead>{{ $t('settings.api_keys.table.expires') }}</TableHead>
<TableHead>{{ $t('settings.api_keys.table.last_used') }}</TableHead>
<TableHead class="w-10" />
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="token in apiTokens" :key="token.id">
<TableCell class="font-medium">{{ token.name }}</TableCell>
<TableCell>
<code class="text-xs text-muted-foreground">{{ token.key_hint }}</code>
</TableCell>
<TableCell>
<Badge :variant="token.status === 'active' ? 'default' : 'secondary'">
{{ token.status }}
</Badge>
</TableCell>
<TableCell class="text-muted-foreground">
{{ token.expires_at ? date.formatDateTime(token.expires_at) : $t('settings.api_keys.table.never') }}
</TableCell>
<TableCell class="text-muted-foreground">
{{ token.last_used_at ? date.diffForHumans(token.last_used_at) : $t('settings.api_keys.table.never') }}
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost" size="icon" class="h-8 w-8">
<IconDots class="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
@click="copyToClipboard(token.id, trans('settings.api_keys.actions.copy_id_success'))"
>
<IconCopy class="size-4" />
{{ $t('settings.api_keys.actions.copy_id') }}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
@click="confirmDeleteModal?.open({ url: ApiKeyController.destroy.url(token), confirmText: token.name })"
>
<IconTrash class="size-4" />
{{ $t('settings.api_keys.actions.delete') }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
<EmptyState
v-else-if="!newToken"
:icon="IconKey"
:title="$t('settings.api_keys.empty.title')"
:description="$t('settings.api_keys.empty.description')"
/>
</div>
</AppLayout>

View file

@ -0,0 +1,51 @@
<script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import { trans } from 'laravel-vue-i18n';
import { computed } from 'vue';
import BrandTab from '@/components/settings/BrandTab.vue';
import SettingsTabsNav from '@/components/settings/SettingsTabsNav.vue';
import AppLayout from '@/layouts/AppLayout.vue';
import { index as apiKeysRoute } from '@/routes/app/api-keys';
import { members as membersRoute } from '@/routes/app';
import { brand as brandRoute, settings as workspaceSettings } from '@/routes/app/workspace';
import type { BreadcrumbItem } from '@/types';
interface Workspace {
id: string;
name: string;
brand_website: string | null;
brand_description: string | null;
brand_tone: string;
brand_voice_notes: string | null;
content_language: string;
}
defineProps<{
workspace: Workspace;
}>();
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
{ title: trans('settings.workspace.title'), href: workspaceSettings.url() },
{ title: trans('settings.workspace.tabs.brand') },
]);
const tabs = computed(() => [
{ name: 'workspace', label: trans('settings.workspace.tabs.workspace'), href: workspaceSettings.url() },
{ name: 'brand', label: trans('settings.workspace.tabs.brand'), href: brandRoute.url() },
{ name: 'members', label: trans('settings.workspace.tabs.users'), href: membersRoute.url() },
{ name: 'api-keys', label: trans('settings.workspace.tabs.api_keys'), href: apiKeysRoute.url() },
]);
</script>
<template>
<Head :title="$t('settings.workspace.tabs.brand')" />
<AppLayout :breadcrumbs="breadcrumbs">
<div class="mx-auto max-w-4xl space-y-6 px-4 py-6">
<SettingsTabsNav :tabs="tabs" active="brand" />
<BrandTab :workspace="workspace" />
</div>
</AppLayout>
</template>

View file

@ -1,19 +1,16 @@
<script setup lang="ts">
import { Head, useForm, router } from '@inertiajs/vue3';
import { IconUserPlus, IconUsers, IconMail, IconTrash, IconCrown, IconUser, IconShield } from '@tabler/icons-vue';
import { Head } from '@inertiajs/vue3';
import { trans } from 'laravel-vue-i18n';
import { computed } from 'vue';
import { WorkspaceRole } from '@/enums/workspace-role';
import HeadingSmall from '@/components/HeadingSmall.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import SettingsTabsNav from '@/components/settings/SettingsTabsNav.vue';
import UsersTab from '@/components/settings/UsersTab.vue';
import AppLayout from '@/layouts/AppLayout.vue';
import { destroy as destroyInvite, store as storeInvite } from '@/routes/app/invites';
import { remove as removeMember } from '@/routes/app/members';
import { members as membersRoute } from '@/routes/app';
import { index as apiKeysRoute } from '@/routes/app/api-keys';
import { brand as brandRoute, settings as workspaceSettings } from '@/routes/app/workspace';
import type { BreadcrumbItem } from '@/types';
interface Workspace {
id: string;
name: string;
@ -37,217 +34,35 @@ interface Role {
label: string;
}
interface Props {
defineProps<{
workspace: Workspace;
owner: Member;
members: Member[];
invites: Invite[];
roles: Role[];
}
}>();
defineProps<Props>();
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
{ title: trans('settings.workspace.title'), href: workspaceSettings.url() },
{ title: trans('settings.workspace.tabs.users') },
]);
const form = useForm({
email: '',
role: WorkspaceRole.Member,
});
const submitInvite = () => {
form.post(storeInvite.url(), {
preserveScroll: true,
onSuccess: () => {
form.reset();
},
});
};
const cancelInvite = (inviteId: string) => {
if (confirm(trans('settings.members.invite.cancel_confirm'))) {
router.delete(destroyInvite.url(inviteId), {
preserveScroll: true,
});
}
};
const handleRemoveMember = (memberId: string) => {
if (confirm(trans('settings.members.list.remove_confirm'))) {
router.delete(removeMember.url(memberId), {
preserveScroll: true,
});
}
};
const getRoleLabel = (role: string): string => {
return trans(`settings.members.roles.${role}`);
};
const getRoleIcon = (role: string) => {
if (role === WorkspaceRole.Admin) return IconShield;
return IconUser;
};
const tabs = computed(() => [
{ name: 'workspace', label: trans('settings.workspace.tabs.workspace'), href: workspaceSettings.url() },
{ name: 'brand', label: trans('settings.workspace.tabs.brand'), href: brandRoute.url() },
{ name: 'members', label: trans('settings.workspace.tabs.users'), href: membersRoute.url() },
{ name: 'api-keys', label: trans('settings.workspace.tabs.api_keys'), href: apiKeysRoute.url() },
]);
</script>
<template>
<AppLayout :title="$t('settings.members.title')">
<Head :title="$t('settings.members.title')" />
<Head :title="$t('settings.members.title')" />
<h1 class="sr-only">{{ $t('settings.members.title') }}</h1>
<AppLayout :breadcrumbs="breadcrumbs">
<div class="mx-auto max-w-4xl space-y-6 px-4 py-6">
<SettingsTabsNav :tabs="tabs" active="members" />
<div class="mx-auto max-w-4xl px-4 py-6 space-y-12">
<div class="flex flex-col space-y-6">
<HeadingSmall
:title="$t('settings.members.heading')"
:description="$t('settings.members.description')"
/>
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2">
<IconUserPlus class="h-5 w-5" />
{{ $t('settings.members.invite.title') }}
</CardTitle>
<CardDescription>
{{ $t('settings.members.invite.description') }}
</CardDescription>
</CardHeader>
<CardContent>
<form @submit.prevent="submitInvite" class="space-y-4">
<div class="space-y-2">
<Label for="email">{{ $t('settings.members.invite.email') }}</Label>
<Input
id="email"
v-model="form.email"
type="email"
:placeholder="trans('settings.members.invite.email_placeholder')"
:class="{ 'border-red-500': form.errors.email }"
/>
<p v-if="form.errors.email" class="text-sm text-red-500">
{{ form.errors.email }}
</p>
</div>
<div class="space-y-2">
<Label for="role">{{ $t('settings.members.invite.role') }}</Label>
<Select v-model="form.role">
<SelectTrigger>
<SelectValue :placeholder="trans('settings.members.invite.role_placeholder')" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="role in roles"
:key="role.value"
:value="role.value"
>
{{ role.label }}
</SelectItem>
</SelectContent>
</Select>
</div>
<Button type="submit" :disabled="form.processing" class="w-full">
<IconMail class="mr-2 h-4 w-4" />
{{ $t('settings.members.invite.submit') }}
</Button>
</form>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2">
<IconMail class="h-5 w-5" />
{{ $t('settings.members.pending.title') }}
</CardTitle>
<CardDescription>
{{ $t('settings.members.pending.description') }}
</CardDescription>
</CardHeader>
<CardContent>
<div v-if="invites.length === 0" class="text-center py-6 text-muted-foreground">
{{ $t('settings.members.pending.empty') }}
</div>
<div v-else class="space-y-3">
<div
v-for="invite in invites"
:key="invite.id"
class="flex items-center justify-between p-3 rounded-lg border"
>
<div class="flex-1 min-w-0">
<p class="font-medium truncate">{{ invite.email }}</p>
<Badge variant="outline" class="text-xs mt-1">
{{ getRoleLabel(invite.role) }}
</Badge>
</div>
<Button
variant="ghost"
size="icon"
@click="cancelInvite(invite.id)"
>
<IconTrash class="h-4 w-4 text-red-500" />
</Button>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2">
<IconUsers class="h-5 w-5" />
{{ $t('settings.members.list.title') }}
</CardTitle>
<CardDescription>
{{ $t('settings.members.list.description') }}
</CardDescription>
</CardHeader>
<CardContent>
<div class="space-y-3">
<div class="flex items-center justify-between p-3 rounded-lg border bg-muted/50">
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-primary text-primary-foreground">
<IconCrown class="h-5 w-5" />
</div>
<div>
<p class="font-medium">{{ owner.name }}</p>
<p class="text-sm text-muted-foreground">{{ owner.email }}</p>
</div>
</div>
<Badge>{{ $t('settings.members.roles.owner') }}</Badge>
</div>
<div
v-for="member in members"
:key="member.id"
class="flex items-center justify-between p-3 rounded-lg border"
>
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-secondary text-secondary-foreground">
<component :is="getRoleIcon(member.role)" class="h-5 w-5" />
</div>
<div>
<p class="font-medium">{{ member.name }}</p>
<p class="text-sm text-muted-foreground">{{ member.email }}</p>
</div>
</div>
<div class="flex items-center gap-2">
<Badge variant="outline">{{ getRoleLabel(member.role) }}</Badge>
<Button
variant="ghost"
size="icon"
@click="handleRemoveMember(member.id)"
>
<IconTrash class="h-4 w-4 text-red-500" />
</Button>
</div>
</div>
<div v-if="members.length === 0" class="text-center py-6 text-muted-foreground">
{{ $t('settings.members.list.empty') }}
</div>
</div>
</CardContent>
</Card>
</div>
<UsersTab :members="members" :invitations="invites" />
</div>
</AppLayout>
</template>

View file

@ -1,11 +1,15 @@
<script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import { trans } from 'laravel-vue-i18n';
import { computed } from 'vue';
import BrandTab from '@/components/settings/BrandTab.vue';
import UsersTab from '@/components/settings/UsersTab.vue';
import SettingsTabsNav from '@/components/settings/SettingsTabsNav.vue';
import WorkspaceTab from '@/components/settings/WorkspaceTab.vue';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import AppLayout from '@/layouts/AppLayout.vue';
import { index as apiKeysRoute } from '@/routes/app/api-keys';
import { members as membersRoute } from '@/routes/app';
import { brand as brandRoute, settings as workspaceSettings } from '@/routes/app/workspace';
import type { BreadcrumbItem } from '@/types';
interface Workspace {
id: string;
@ -19,52 +23,30 @@ interface Workspace {
content_language: string;
}
interface Member {
id: string;
name: string;
email: string;
role: string;
}
interface Invitation {
id: string;
email: string;
role: string;
}
defineProps<{
workspace: Workspace;
members: Member[];
invitations: Invitation[];
}>();
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
{ title: trans('settings.workspace.title') },
]);
const tabs = computed(() => [
{ name: 'workspace', label: trans('settings.workspace.tabs.workspace'), href: workspaceSettings.url() },
{ name: 'brand', label: trans('settings.workspace.tabs.brand'), href: brandRoute.url() },
{ name: 'members', label: trans('settings.workspace.tabs.users'), href: membersRoute.url() },
{ name: 'api-keys', label: trans('settings.workspace.tabs.api_keys'), href: apiKeysRoute.url() },
]);
</script>
<template>
<AppLayout :title="$t('settings.workspace.title')">
<Head :title="$t('settings.workspace.title')" />
<Head :title="$t('settings.workspace.title')" />
<h1 class="sr-only">{{ $t('settings.workspace.title') }}</h1>
<AppLayout :breadcrumbs="breadcrumbs">
<div class="mx-auto max-w-4xl space-y-6 px-4 py-6">
<SettingsTabsNav :tabs="tabs" active="workspace" />
<div class="mx-auto max-w-4xl px-4 py-6">
<Tabs default-value="workspace">
<TabsList>
<TabsTrigger value="workspace">{{ $t('settings.workspace.tabs.workspace') }}</TabsTrigger>
<TabsTrigger value="brand">{{ $t('settings.workspace.tabs.brand') }}</TabsTrigger>
<TabsTrigger value="users">{{ $t('settings.workspace.tabs.users') }}</TabsTrigger>
</TabsList>
<TabsContent value="workspace" class="mt-6">
<WorkspaceTab :workspace="workspace" />
</TabsContent>
<TabsContent value="brand" class="mt-6">
<BrandTab :workspace="workspace" />
</TabsContent>
<TabsContent value="users" class="mt-6">
<UsersTab :members="members" :invitations="invitations" />
</TabsContent>
</Tabs>
<WorkspaceTab :workspace="workspace" />
</div>
</AppLayout>
</template>

View file

@ -60,3 +60,8 @@ export interface User {
[key: string]: unknown;
}
export type BreadcrumbItem = {
title: string;
href?: string;
};

View file

@ -7,7 +7,6 @@
use App\Http\Controllers\App\AssetController;
use App\Http\Controllers\App\BillingController;
use App\Http\Controllers\App\GiphyController;
use App\Http\Controllers\App\MediaController;
use App\Http\Controllers\App\NotificationController;
use App\Http\Controllers\App\PostAssistantController;
use App\Http\Controllers\App\PostCommentController;
@ -126,6 +125,9 @@
Route::post('settings/workspace/logo', [WorkspaceController::class, 'uploadLogo'])->name('app.workspace.upload-logo');
Route::delete('settings/workspace/logo', [WorkspaceController::class, 'deleteLogo'])->name('app.workspace.delete-logo');
// Brand settings
Route::get('settings/brand', [WorkspaceController::class, 'brandSettings'])->name('app.workspace.brand');
// Social Accounts
Route::get('accounts', [SocialController::class, 'index'])->name('app.accounts');
Route::delete('accounts/{account}', [SocialController::class, 'disconnect'])->name('app.accounts.disconnect');
@ -142,6 +144,8 @@
Route::get('posts/{status?}', [PostController::class, 'index'])->name('app.posts.index')->where('status', 'draft|scheduled|published');
Route::post('posts', [PostController::class, 'store'])->name('app.posts.store');
Route::get('posts/{post}/edit', [PostController::class, 'edit'])->name('app.posts.edit');
Route::get('posts/{post}', [PostController::class, 'show'])->name('app.posts.show');
Route::get('posts/{post}/platforms/{postPlatform}/metrics', [PostController::class, 'platformMetrics'])->name('app.posts.platforms.metrics');
Route::put('posts/{post}', [PostController::class, 'update'])->name('app.posts.update');
Route::delete('posts/{post}', [PostController::class, 'destroy'])->name('app.posts.destroy');
@ -158,15 +162,8 @@
Route::post('posts/{post}/assistant', [PostAssistantController::class, 'store'])->name('app.posts.assistant.store');
});
// Media
Route::post('medias', [MediaController::class, 'store'])->name('app.medias.store');
Route::post('medias/chunked', [MediaController::class, 'storeChunked'])->name('app.medias.store-chunked');
Route::post('medias/reorder', [MediaController::class, 'reorder'])->name('app.medias.reorder');
Route::post('medias/{media}/duplicate', [MediaController::class, 'duplicate'])->name('app.medias.duplicate');
Route::delete('medias/{modelId}/{media}', [MediaController::class, 'destroy'])->name('app.medias.destroy');
// Members
Route::get('settings/members', fn () => redirect()->route('app.workspace.settings'))->name('app.members');
Route::get('settings/members', [WorkspaceInviteController::class, 'index'])->name('app.members');
Route::post('settings/members/invites', [WorkspaceInviteController::class, 'store'])->name('app.invites.store');
Route::delete('settings/members/invites/{invite}', [WorkspaceInviteController::class, 'destroy'])->name('app.invites.destroy');
Route::delete('settings/members/{user}', [WorkspaceInviteController::class, 'removeMember'])->name('app.members.remove');

View file

@ -3,6 +3,7 @@
declare(strict_types=1);
use App\Actions\Workspace\CreateWorkspace;
use App\Enums\UserWorkspace\Role;
use App\Models\Account;
use App\Models\User;
@ -29,7 +30,7 @@
expect($workspace->user_id)->toBe($user->id);
});
test('CreateWorkspace switches user current workspace and attaches as member', function () {
test('CreateWorkspace switches user current workspace and attaches as admin', function () {
$account = Account::factory()->create();
$user = User::factory()->create(['account_id' => $account->id, 'current_workspace_id' => null]);
@ -38,6 +39,9 @@
$user->refresh();
expect($user->current_workspace_id)->toBe($workspace->id);
expect($workspace->members->contains($user))->toBeTrue();
$member = $workspace->members()->where('user_id', $user->id)->first();
expect($member?->pivot->role)->toBe(Role::Admin->value);
});
test('CreateWorkspace ignores unknown extra keys like logo_url', function () {

View file

@ -164,6 +164,98 @@
$response->assertJsonPath('type', 'image');
});
test('chunked upload completes with single chunk', function () {
$content = str_repeat('x', 1000);
$response = $this->actingAs($this->user)->call(
'POST',
route('app.assets.store-chunked'),
[], [], [],
[
'HTTP_CONTENT_RANGE' => 'bytes 0-999/1000',
'HTTP_X_FILE_NAME' => 'test-video.mp4',
'HTTP_ACCEPT' => 'application/json',
'CONTENT_TYPE' => 'application/octet-stream',
],
$content,
);
$response->assertSuccessful();
$response->assertJson(['done' => true]);
$response->assertJsonStructure(['done', 'id', 'path', 'url', 'type', 'mime_type', 'original_filename', 'size']);
expect($this->workspace->getMedia('assets')->count())->toBe(1);
});
test('chunked upload reports progress on intermediate chunks', function () {
$response = $this->actingAs($this->user)->call(
'POST',
route('app.assets.store-chunked'),
[], [], [],
[
'HTTP_CONTENT_RANGE' => 'bytes 0-499/1000',
'HTTP_X_FILE_NAME' => 'test-video.mp4',
'HTTP_ACCEPT' => 'application/json',
'CONTENT_TYPE' => 'application/octet-stream',
],
str_repeat('a', 500),
);
$response->assertSuccessful();
$response->assertJson(['done' => false, 'progress' => 50]);
expect(Media::count())->toBe(0);
});
test('chunked upload rejects unsupported file extension', function () {
$response = $this->actingAs($this->user)->call(
'POST',
route('app.assets.store-chunked'),
[], [], [],
[
'HTTP_CONTENT_RANGE' => 'bytes 0-99/100',
'HTTP_X_FILE_NAME' => 'malware.exe',
'HTTP_ACCEPT' => 'application/json',
'CONTENT_TYPE' => 'application/octet-stream',
],
str_repeat('x', 100),
);
$response->assertUnprocessable();
});
test('chunked upload rejects invalid Content-Range header', function () {
$response = $this->actingAs($this->user)->call(
'POST',
route('app.assets.store-chunked'),
[], [], [],
[
'HTTP_CONTENT_RANGE' => 'invalid',
'HTTP_X_FILE_NAME' => 'test.jpg',
'HTTP_ACCEPT' => 'application/json',
'CONTENT_TYPE' => 'application/octet-stream',
],
'data',
);
$response->assertStatus(400);
});
test('chunked upload rejects unauthenticated', function () {
$response = $this->call(
'POST',
route('app.assets.store-chunked'),
[], [], [],
[
'HTTP_CONTENT_RANGE' => 'bytes 0-99/100',
'HTTP_X_FILE_NAME' => 'test.jpg',
'HTTP_ACCEPT' => 'application/json',
'CONTENT_TYPE' => 'application/octet-stream',
],
str_repeat('x', 100),
);
$response->assertUnauthorized();
});
test('unsplash search returns results', function () {
$this->mock(UnsplashService::class)
->shouldReceive('search')

View file

@ -1,242 +0,0 @@
<?php
declare(strict_types=1);
use App\Enums\UserWorkspace\Role;
use App\Models\Account;
use App\Models\Media;
use App\Models\User;
use App\Models\Workspace;
use Illuminate\Support\Facades\Storage;
beforeEach(function () {
Storage::fake();
$this->user = User::factory()->create([
]);
$this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
$this->workspace->members()->attach($this->user->id, ['role' => Role::Member->value]);
$this->user->account->subscriptions()->create([
'type' => Account::SUBSCRIPTION_NAME,
'stripe_id' => 'sub_123',
'stripe_status' => 'active',
'stripe_price' => 'price_123',
]);
$this->user->update(['current_workspace_id' => $this->workspace->id]);
});
test('chunked upload completes with single chunk', function () {
$content = str_repeat('x', 1000);
$response = $this->actingAs($this->user)->call(
'POST',
route('app.medias.store-chunked'),
[],
[],
[],
[
'HTTP_CONTENT_RANGE' => 'bytes 0-999/1000',
'HTTP_X_MODEL' => 'workspace',
'HTTP_X_MODEL_ID' => $this->workspace->id,
'HTTP_X_COLLECTION' => 'default',
'HTTP_X_FILE_NAME' => 'test-video.mp4',
'HTTP_ACCEPT' => 'application/json',
'CONTENT_TYPE' => 'application/octet-stream',
],
$content,
);
$response->assertSuccessful();
$response->assertJson(['done' => true]);
$response->assertJsonStructure(['done', 'id', 'group_id', 'url', 'type', 'original_filename']);
expect(Media::count())->toBe(1);
});
test('chunked upload reports progress on intermediate chunks', function () {
$chunkSize = 500;
$totalSize = 1000;
$firstChunk = str_repeat('a', $chunkSize);
$response = $this->actingAs($this->user)->call(
'POST',
route('app.medias.store-chunked'),
[],
[],
[],
[
'HTTP_CONTENT_RANGE' => "bytes 0-499/{$totalSize}",
'HTTP_X_MODEL' => 'workspace',
'HTTP_X_MODEL_ID' => $this->workspace->id,
'HTTP_X_COLLECTION' => 'default',
'HTTP_X_FILE_NAME' => 'test-video.mp4',
'HTTP_ACCEPT' => 'application/json',
'CONTENT_TYPE' => 'application/octet-stream',
],
$firstChunk,
);
$response->assertSuccessful();
$response->assertJson([
'done' => false,
'progress' => 50,
]);
expect(Media::count())->toBe(0);
});
test('chunked upload completes with multiple chunks', function () {
$totalSize = 1000;
$firstChunk = str_repeat('a', 500);
$secondChunk = str_repeat('b', 500);
// First chunk
$this->actingAs($this->user)->call(
'POST',
route('app.medias.store-chunked'),
[],
[],
[],
[
'HTTP_CONTENT_RANGE' => "bytes 0-499/{$totalSize}",
'HTTP_X_MODEL' => 'workspace',
'HTTP_X_MODEL_ID' => $this->workspace->id,
'HTTP_X_COLLECTION' => 'default',
'HTTP_X_FILE_NAME' => 'multi-chunk.mp4',
'HTTP_ACCEPT' => 'application/json',
'CONTENT_TYPE' => 'application/octet-stream',
],
$firstChunk,
)->assertJson(['done' => false]);
// Second chunk (last)
$response = $this->actingAs($this->user)->call(
'POST',
route('app.medias.store-chunked'),
[],
[],
[],
[
'HTTP_CONTENT_RANGE' => "bytes 500-999/{$totalSize}",
'HTTP_X_MODEL' => 'workspace',
'HTTP_X_MODEL_ID' => $this->workspace->id,
'HTTP_X_COLLECTION' => 'default',
'HTTP_X_FILE_NAME' => 'multi-chunk.mp4',
'HTTP_ACCEPT' => 'application/json',
'CONTENT_TYPE' => 'application/octet-stream',
],
$secondChunk,
);
$response->assertSuccessful();
$response->assertJson(['done' => true]);
expect(Media::count())->toBe(1);
});
test('chunked upload fails without content range header', function () {
$response = $this->actingAs($this->user)->call(
'POST',
route('app.medias.store-chunked'),
[],
[],
[],
[
'HTTP_X_MODEL' => 'workspace',
'HTTP_X_MODEL_ID' => $this->workspace->id,
'HTTP_X_FILE_NAME' => 'test.mp4',
'HTTP_ACCEPT' => 'application/json',
'CONTENT_TYPE' => 'application/octet-stream',
],
'content',
);
$response->assertUnprocessable();
$response->assertJsonValidationErrors('content_range');
});
test('chunked upload fails with invalid model type', function () {
$response = $this->actingAs($this->user)->call(
'POST',
route('app.medias.store-chunked'),
[],
[],
[],
[
'HTTP_CONTENT_RANGE' => 'bytes 0-999/1000',
'HTTP_X_MODEL' => 'invalid',
'HTTP_X_MODEL_ID' => $this->workspace->id,
'HTTP_X_FILE_NAME' => 'test.mp4',
'HTTP_ACCEPT' => 'application/json',
'CONTENT_TYPE' => 'application/octet-stream',
],
str_repeat('x', 1000),
);
$response->assertUnprocessable();
$response->assertJsonValidationErrors('model');
});
test('chunked upload fails without model header', function () {
$response = $this->actingAs($this->user)->call(
'POST',
route('app.medias.store-chunked'),
[],
[],
[],
[
'HTTP_CONTENT_RANGE' => 'bytes 0-999/1000',
'HTTP_X_FILE_NAME' => 'test.mp4',
'HTTP_ACCEPT' => 'application/json',
'CONTENT_TYPE' => 'application/octet-stream',
],
str_repeat('x', 1000),
);
$response->assertUnprocessable();
$response->assertJsonValidationErrors('model');
});
test('chunked upload fails without file name header', function () {
$response = $this->actingAs($this->user)->call(
'POST',
route('app.medias.store-chunked'),
[],
[],
[],
[
'HTTP_CONTENT_RANGE' => 'bytes 0-999/1000',
'HTTP_X_MODEL' => 'workspace',
'HTTP_X_MODEL_ID' => $this->workspace->id,
'HTTP_ACCEPT' => 'application/json',
'CONTENT_TYPE' => 'application/octet-stream',
],
str_repeat('x', 1000),
);
// X-File-Name defaults to 'upload' which has no extension, so it should fail validation
$response->assertUnprocessable();
$response->assertJsonValidationErrors(['file_name']);
});
test('chunked upload cleans up temp file after completion', function () {
$content = str_repeat('x', 1000);
$identifier = md5('cleanup-test.mp4'.'1000');
$tempPath = storage_path("app/private/chunks/{$identifier}");
$this->actingAs($this->user)->call(
'POST',
route('app.medias.store-chunked'),
[],
[],
[],
[
'HTTP_CONTENT_RANGE' => 'bytes 0-999/1000',
'HTTP_X_MODEL' => 'workspace',
'HTTP_X_MODEL_ID' => $this->workspace->id,
'HTTP_X_COLLECTION' => 'default',
'HTTP_X_FILE_NAME' => 'cleanup-test.mp4',
'HTTP_ACCEPT' => 'application/json',
'CONTENT_TYPE' => 'application/octet-stream',
],
$content,
)->assertJson(['done' => true]);
expect(file_exists($tempPath))->toBeFalse();
});

View file

@ -1,227 +0,0 @@
<?php
declare(strict_types=1);
use App\Enums\UserWorkspace\Role;
use App\Models\Account;
use App\Models\Media;
use App\Models\Post;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Models\User;
use App\Models\Workspace;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
beforeEach(function () {
Storage::fake('local');
$this->user = User::factory()->create([]);
$this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
$this->workspace->members()->attach($this->user->id, ['role' => Role::Member->value]);
$this->user->account->subscriptions()->create([
'type' => Account::SUBSCRIPTION_NAME,
'stripe_id' => 'sub_test_'.fake()->uuid(),
'stripe_status' => 'active',
'stripe_price' => 'price_123',
]);
$this->user->update(['current_workspace_id' => $this->workspace->id]);
$this->socialAccount = SocialAccount::factory()->create(['workspace_id' => $this->workspace->id]);
$this->post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
]);
$this->postPlatform = PostPlatform::factory()->create([
'post_id' => $this->post->id,
'social_account_id' => $this->socialAccount->id,
]);
});
// Store tests
test('store media requires authentication', function () {
$response = $this->post(route('app.medias.store'), [
'model' => 'postPlatform',
'model_id' => $this->postPlatform->id,
'media' => UploadedFile::fake()->image('test.jpg'),
]);
$response->assertRedirect(route('login'));
});
test('store media uploads file', function () {
$response = $this->actingAs($this->user)->post(route('app.medias.store'), [
'model' => 'workspace',
'model_id' => $this->workspace->id,
'media' => UploadedFile::fake()->image('test.jpg'),
]);
$response->assertOk();
$response->assertJsonStructure(['id', 'url', 'type', 'original_filename']);
});
test('store media validates required fields', function () {
$response = $this->actingAs($this->user)->post(route('app.medias.store'), [
'model' => '',
'model_id' => '',
]);
$response->assertSessionHasErrors(['model', 'model_id', 'media']);
});
// Destroy tests
test('destroy media requires authentication', function () {
$media = Media::factory()->create([
'mediable_id' => $this->postPlatform->id,
'mediable_type' => 'postPlatform',
]);
$response = $this->delete(route('app.medias.destroy', [$this->postPlatform->id, $media]));
$response->assertRedirect(route('login'));
});
test('destroy media deletes the media', function () {
$media = Media::factory()->create([
'mediable_id' => $this->postPlatform->id,
'mediable_type' => 'postPlatform',
]);
$response = $this->actingAs($this->user)->delete(route('app.medias.destroy', [$this->postPlatform->id, $media]));
$response->assertOk();
$response->assertJson(['success' => true]);
expect(Media::find($media->id))->toBeNull();
});
test('destroy media returns 403 for mismatched model', function () {
$otherPostPlatform = PostPlatform::factory()->create([
'post_id' => $this->post->id,
'social_account_id' => $this->socialAccount->id,
]);
$media = Media::factory()->create([
'mediable_id' => $otherPostPlatform->id,
'mediable_type' => 'postPlatform',
]);
$response = $this->actingAs($this->user)->delete(route('app.medias.destroy', [$this->postPlatform->id, $media]));
$response->assertForbidden();
});
// Duplicate tests
test('duplicate media requires authentication', function () {
$media = Media::factory()->create([
'mediable_id' => $this->postPlatform->id,
'mediable_type' => 'postPlatform',
]);
$response = $this->post(route('app.medias.duplicate', $media), [
'targets' => [],
]);
$response->assertRedirect(route('login'));
});
test('duplicate media creates copies', function () {
$media = Media::factory()->create([
'mediable_id' => $this->workspace->id,
'mediable_type' => 'workspace',
'collection' => 'assets',
]);
$response = $this->actingAs($this->user)->post(route('app.medias.duplicate', $media), [
'targets' => [
[
'model' => 'workspace',
'model_id' => $this->workspace->id,
'collection' => 'assets',
],
],
]);
$response->assertOk();
$response->assertJsonCount(1);
expect(Media::where('mediable_id', $this->workspace->id)->where('collection', 'assets')->count())->toBe(2);
});
// Reorder tests
test('reorder media updates order', function () {
$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' => [
['id' => $media1->id, 'order' => 1],
['id' => $media2->id, 'order' => 0],
],
]);
$response->assertOk();
expect($media1->refresh()->order)->toBe(1);
expect($media2->refresh()->order)->toBe(0);
});
test('reorder media rejects media from other workspace', function () {
$otherUser = User::factory()->create([]);
$otherWorkspace = Workspace::factory()->create(['user_id' => $otherUser->id]);
$otherUser->update(['current_workspace_id' => $otherWorkspace->id]);
$otherPost = Post::factory()->create([
'workspace_id' => $otherWorkspace->id,
'user_id' => $otherUser->id,
]);
$otherAccount = SocialAccount::factory()->create([
'workspace_id' => $otherWorkspace->id,
]);
$otherPlatform = PostPlatform::factory()->create([
'post_id' => $otherPost->id,
'social_account_id' => $otherAccount->id,
]);
$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' => [
['id' => $otherMedia->id, 'order' => 0],
],
]);
$response->assertForbidden();
});
test('reorder media validates required fields', function () {
$response = $this->actingAs($this->user)->postJson(route('app.medias.reorder'), []);
$response->assertUnprocessable();
$response->assertJsonValidationErrors(['media']);
});
test('reorder media validates media items have id and order', function () {
$response = $this->actingAs($this->user)->postJson(route('app.medias.reorder'), [
'media' => [
['invalid' => 'data'],
],
]);
$response->assertUnprocessable();
});

View file

@ -4,6 +4,7 @@
use App\Enums\Post\Status as PostStatus;
use App\Enums\PostPlatform\ContentType;
use App\Enums\PostPlatform\Status;
use App\Enums\SocialAccount\Platform;
use App\Enums\UserWorkspace\Role;
use App\Models\Post;
@ -12,6 +13,7 @@
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceLabel;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Mail;
beforeEach(function () {
@ -157,25 +159,42 @@
$response->assertNotFound();
});
test('edit post shows published posts in read-only mode', function () {
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
'status' => PostStatus::Published,
]);
test('edit redirects to show for non-editable statuses', function () {
foreach ([PostStatus::Published, PostStatus::PartiallyPublished, PostStatus::Publishing] as $status) {
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
'status' => $status,
]);
PostPlatform::factory()->create([
'post_id' => $post->id,
'social_account_id' => $this->socialAccount->id,
]);
PostPlatform::factory()->create([
'post_id' => $post->id,
'social_account_id' => $this->socialAccount->id,
]);
$response = $this->actingAs($this->user)->get(route('app.posts.edit', $post));
$this->actingAs($this->user)
->get(route('app.posts.edit', $post))
->assertRedirect(route('app.posts.show', $post));
}
});
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->component('posts/Edit')
->has('post')
);
test('edit allows draft, scheduled, and failed posts', function () {
foreach ([PostStatus::Draft, PostStatus::Scheduled, PostStatus::Failed] as $status) {
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
'status' => $status,
]);
PostPlatform::factory()->create([
'post_id' => $post->id,
'social_account_id' => $this->socialAccount->id,
]);
$this->actingAs($this->user)
->get(route('app.posts.edit', $post))
->assertOk();
}
});
// Update tests
@ -481,6 +500,397 @@
expect($post->labels->pluck('id')->toArray())->toEqualCanonicalizing([$label2->id, $label3->id]);
});
test('platform metrics returns unsupported when post not published', function () {
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
'status' => PostStatus::Draft,
]);
$pp = PostPlatform::factory()->create([
'post_id' => $post->id,
'social_account_id' => $this->socialAccount->id,
'status' => Status::Pending,
]);
$response = $this->actingAs($this->user)
->getJson(route('app.posts.platforms.metrics', ['post' => $post->id, 'postPlatform' => $pp->id]));
$response->assertOk();
$response->assertJson(['unsupported' => true, 'reason' => 'not_published']);
});
test('platform metrics returns 404 for post in another workspace', function () {
$otherWorkspace = Workspace::factory()->create();
$otherPost = Post::factory()->create(['workspace_id' => $otherWorkspace->id]);
$pp = PostPlatform::factory()->create([
'post_id' => $otherPost->id,
'social_account_id' => $this->socialAccount->id,
]);
$this->actingAs($this->user)
->getJson(route('app.posts.platforms.metrics', ['post' => $otherPost->id, 'postPlatform' => $pp->id]))
->assertNotFound();
});
test('platform metrics returns 404 when post platform belongs to different post', function () {
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
]);
$otherPost = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
]);
$pp = PostPlatform::factory()->create([
'post_id' => $otherPost->id,
'social_account_id' => $this->socialAccount->id,
]);
$this->actingAs($this->user)
->getJson(route('app.posts.platforms.metrics', ['post' => $post->id, 'postPlatform' => $pp->id]))
->assertNotFound();
});
test('platform metrics dispatches X analytics for X platform', function () {
$xAccount = SocialAccount::factory()->create([
'workspace_id' => $this->workspace->id,
'platform' => Platform::X,
]);
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
]);
$pp = PostPlatform::factory()->create([
'post_id' => $post->id,
'social_account_id' => $xAccount->id,
'platform' => Platform::X,
'status' => Status::Published,
'platform_post_id' => '1234567890',
]);
Http::fake([
'https://api.x.com/2/tweets/1234567890*' => Http::response([
'data' => [
'public_metrics' => [
'impression_count' => 500,
'like_count' => 42,
'retweet_count' => 7,
'reply_count' => 3,
'quote_count' => 1,
'bookmark_count' => 4,
],
],
], 200),
]);
$response = $this->actingAs($this->user)
->getJson(route('app.posts.platforms.metrics', ['post' => $post->id, 'postPlatform' => $pp->id]));
$response->assertOk();
$response->assertJsonFragment(['label' => 'Impressions', 'value' => 500]);
$response->assertJsonFragment(['label' => 'Likes', 'value' => 42]);
});
test('show page renders for non-editable posts', function () {
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
'status' => PostStatus::Published,
'content' => 'Hello world',
]);
PostPlatform::factory()->create([
'post_id' => $post->id,
'social_account_id' => $this->socialAccount->id,
'enabled' => true,
'platform_url' => 'https://linkedin.com/posts/abc',
]);
$response = $this->actingAs($this->user)->get(route('app.posts.show', $post));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->component('posts/Show', false)
->has('post.post_platforms', 1)
);
});
test('show page redirects editable posts to edit', function () {
foreach ([PostStatus::Draft, PostStatus::Scheduled, PostStatus::Failed] as $status) {
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
'status' => $status,
]);
$this->actingAs($this->user)
->get(route('app.posts.show', $post))
->assertRedirect(route('app.posts.edit', $post));
}
});
test('destroy blocks published posts', function () {
foreach ([PostStatus::Publishing, PostStatus::Published, PostStatus::PartiallyPublished] as $status) {
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
'status' => $status,
]);
$this->actingAs($this->user)
->delete(route('app.posts.destroy', $post))
->assertRedirect();
expect(Post::find($post->id))->not->toBeNull();
}
});
test('show page returns 404 for post in another workspace', function () {
$otherWorkspace = Workspace::factory()->create();
$post = Post::factory()->create([
'workspace_id' => $otherWorkspace->id,
'user_id' => $this->user->id,
]);
$this->actingAs($this->user)
->get(route('app.posts.show', $post))
->assertNotFound();
});
test('update post redirects to show page after publishing', function () {
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
'status' => PostStatus::Draft,
'content' => 'Test',
]);
$postPlatform = PostPlatform::factory()->create([
'post_id' => $post->id,
'social_account_id' => $this->socialAccount->id,
]);
$response = $this->actingAs($this->user)->put(route('app.posts.update', $post), [
'status' => 'publishing',
'content' => 'Test',
'platforms' => [
['id' => $postPlatform->id, 'content_type' => ContentType::LinkedInPost->value],
],
]);
$response->assertRedirect(route('app.posts.show', $post));
});
test('update post rejects scheduling youtube short with image', function () {
$youtubeAccount = SocialAccount::factory()->create([
'workspace_id' => $this->workspace->id,
'platform' => Platform::YouTube,
]);
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
'status' => PostStatus::Draft,
'content' => 'Test',
]);
$postPlatform = PostPlatform::factory()->create([
'post_id' => $post->id,
'social_account_id' => $youtubeAccount->id,
]);
$response = $this->actingAs($this->user)->put(route('app.posts.update', $post), [
'status' => 'scheduled',
'scheduled_at' => now()->addDay()->toIso8601String(),
'media' => [
[
'id' => 'media-1',
'path' => 'media/foo.jpg',
'url' => 'https://example.com/foo.jpg',
'type' => 'image',
'mime_type' => 'image/jpeg',
],
],
'platforms' => [
[
'id' => $postPlatform->id,
'content_type' => ContentType::YouTubeShort->value,
],
],
]);
$response->assertSessionHasErrors('platforms.0.content_type');
});
test('update post rejects scheduling instagram reel with no media', function () {
$instagramAccount = SocialAccount::factory()->create([
'workspace_id' => $this->workspace->id,
'platform' => Platform::Instagram,
]);
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
'status' => PostStatus::Draft,
'content' => 'Test',
]);
$postPlatform = PostPlatform::factory()->create([
'post_id' => $post->id,
'social_account_id' => $instagramAccount->id,
]);
$response = $this->actingAs($this->user)->put(route('app.posts.update', $post), [
'status' => 'scheduled',
'scheduled_at' => now()->addDay()->toIso8601String(),
'platforms' => [
[
'id' => $postPlatform->id,
'content_type' => ContentType::InstagramReel->value,
],
],
]);
$response->assertSessionHasErrors('platforms.0.content_type');
});
test('update post rejects invalid instagram aspect_ratio meta', function () {
$instagramAccount = SocialAccount::factory()->create([
'workspace_id' => $this->workspace->id,
'platform' => Platform::Instagram,
]);
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
'status' => PostStatus::Draft,
]);
$postPlatform = PostPlatform::factory()->create([
'post_id' => $post->id,
'social_account_id' => $instagramAccount->id,
]);
$response = $this->actingAs($this->user)->put(route('app.posts.update', $post), [
'status' => 'draft',
'platforms' => [
[
'id' => $postPlatform->id,
'content_type' => ContentType::InstagramFeed->value,
'meta' => ['aspect_ratio' => '2:1'],
],
],
]);
$response->assertSessionHasErrors('platforms.0.meta.aspect_ratio');
});
test('update post accepts valid instagram aspect_ratio meta', function () {
$instagramAccount = SocialAccount::factory()->create([
'workspace_id' => $this->workspace->id,
'platform' => Platform::Instagram,
]);
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
'status' => PostStatus::Draft,
]);
$postPlatform = PostPlatform::factory()->create([
'post_id' => $post->id,
'social_account_id' => $instagramAccount->id,
]);
$response = $this->actingAs($this->user)->put(route('app.posts.update', $post), [
'status' => 'draft',
'platforms' => [
[
'id' => $postPlatform->id,
'content_type' => ContentType::InstagramFeed->value,
'meta' => ['aspect_ratio' => '4:5'],
],
],
]);
$response->assertSessionDoesntHaveErrors('platforms.0.meta.aspect_ratio');
$postPlatform->refresh();
expect(data_get($postPlatform->meta, 'aspect_ratio'))->toBe('4:5');
});
test('scheduling without content_type per platform fails', function () {
$youtubeAccount = SocialAccount::factory()->create([
'workspace_id' => $this->workspace->id,
'platform' => Platform::YouTube,
]);
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
'status' => PostStatus::Draft,
'content' => 'Test',
]);
$postPlatform = PostPlatform::factory()->create([
'post_id' => $post->id,
'social_account_id' => $youtubeAccount->id,
]);
$response = $this->actingAs($this->user)->put(route('app.posts.update', $post), [
'status' => 'scheduled',
'scheduled_at' => now()->addDay()->toIso8601String(),
'platforms' => [
['id' => $postPlatform->id],
],
]);
$response->assertSessionHasErrors('platforms.0.content_type');
});
test('draft post does not enforce media-vs-content-type compatibility', function () {
$youtubeAccount = SocialAccount::factory()->create([
'workspace_id' => $this->workspace->id,
'platform' => Platform::YouTube,
]);
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
'status' => PostStatus::Draft,
]);
$postPlatform = PostPlatform::factory()->create([
'post_id' => $post->id,
'social_account_id' => $youtubeAccount->id,
]);
$response = $this->actingAs($this->user)->put(route('app.posts.update', $post), [
'status' => 'draft',
'media' => [
[
'id' => 'media-1',
'path' => 'media/foo.jpg',
'url' => 'https://example.com/foo.jpg',
'type' => 'image',
'mime_type' => 'image/jpeg',
],
],
'platforms' => [
[
'id' => $postPlatform->id,
'content_type' => ContentType::YouTubeShort->value,
],
],
]);
$response->assertSessionDoesntHaveErrors('platforms.0.content_type');
});
test('update post validates label_ids exist', function () {
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,

View file

@ -2,8 +2,11 @@
declare(strict_types=1);
use App\Enums\Post\Status;
use App\Enums\SocialAccount\Platform;
use App\Enums\UserWorkspace\Role;
use App\Models\Post;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Models\User;
use App\Models\Workspace;
@ -64,6 +67,43 @@
expect(SocialAccount::find($account->id))->toBeNull();
});
test('disconnect deletes pending platform rows from drafts and keeps published history', function () {
$account = SocialAccount::factory()->create(['workspace_id' => $this->workspace->id]);
$draftPost = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
'status' => Status::Draft,
]);
$publishedPost = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
'status' => Status::Published,
]);
$pendingPlatform = PostPlatform::factory()->create([
'post_id' => $draftPost->id,
'social_account_id' => $account->id,
'status' => App\Enums\PostPlatform\Status::Pending,
]);
$publishedPlatform = PostPlatform::factory()->create([
'post_id' => $publishedPost->id,
'social_account_id' => $account->id,
'status' => App\Enums\PostPlatform\Status::Published,
'platform_name' => 'Snapshot Name',
'platform_avatar' => 'avatars/snapshot.jpg',
]);
$this->actingAs($this->user)->delete(route('app.accounts.disconnect', $account));
expect(PostPlatform::find($pendingPlatform->id))->toBeNull();
$publishedPlatform->refresh();
expect($publishedPlatform->social_account_id)->toBeNull();
expect($publishedPlatform->platform_name)->toBe('Snapshot Name');
expect($publishedPlatform->display_avatar)->toContain('avatars/snapshot.jpg');
});
test('disconnect returns 403 for other workspace account', function () {
$otherWorkspace = Workspace::factory()->create();
$account = SocialAccount::factory()->create(['workspace_id' => $otherWorkspace->id]);

View file

@ -168,15 +168,23 @@
$response->assertRedirect(route('login'));
});
test('workspace settings shows settings page with members and invitations', function () {
test('workspace settings shows the workspace settings page', function () {
$response = $this->actingAs($this->user)->get(route('app.workspace.settings'));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->component('settings/Workspace', false)
->has('workspace')
->has('members')
->has('invitations')
);
});
test('brand settings shows the brand settings page', function () {
$response = $this->actingAs($this->user)->get(route('app.workspace.brand'));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->component('settings/Brand', false)
->has('workspace')
);
});

View file

@ -34,21 +34,23 @@
$response->assertRedirect(route('login'));
});
test('workspace settings shows members and invites', function () {
test('members page shows members and invites', function () {
Invite::factory()->create([
'account_id' => $this->account->id,
'invited_by' => $this->user->id,
'workspaces' => [$this->workspace->id],
]);
$response = $this->actingAs($this->user)->get(route('app.workspace.settings'));
$response = $this->actingAs($this->user)->get(route('app.members'));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->component('settings/Workspace', false)
->component('settings/Members', false)
->has('workspace')
->has('members')
->has('invitations')
->has('invites')
->has('owner')
->has('roles')
);
});

View file

@ -83,9 +83,14 @@
expect(ContentType::InstagramFeed->requiresMedia())->toBeTrue();
expect(ContentType::InstagramReel->requiresMedia())->toBeTrue();
expect(ContentType::TikTokVideo->requiresMedia())->toBeTrue();
expect(ContentType::YouTubeShort->requiresMedia())->toBeTrue();
expect(ContentType::PinterestPin->requiresMedia())->toBeTrue();
expect(ContentType::LinkedInPost->requiresMedia())->toBeFalse();
expect(ContentType::FacebookPost->requiresMedia())->toBeFalse();
expect(ContentType::XPost->requiresMedia())->toBeFalse();
expect(ContentType::ThreadsPost->requiresMedia())->toBeFalse();
expect(ContentType::BlueskyPost->requiresMedia())->toBeFalse();
expect(ContentType::MastodonPost->requiresMedia())->toBeFalse();
});
test('can get content types for platform', function () {

View file

@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
use App\Enums\Media\Type as MediaType;
use App\Enums\PostPlatform\ContentType;
use App\Rules\ContentTypeCompatibleWithMedia;
function runMediaRule(string $contentType, array $media): array
{
$errors = [];
$rule = (new ContentTypeCompatibleWithMedia)->setData(['media' => $media]);
$rule->validate('platforms.0.content_type', $contentType, function (string $message) use (&$errors): void {
$errors[] = $message;
});
return $errors;
}
test('passes when content type does not require media and none provided', function () {
expect(runMediaRule(ContentType::LinkedInPost->value, []))->toBe([]);
expect(runMediaRule(ContentType::FacebookPost->value, []))->toBe([]);
expect(runMediaRule(ContentType::XPost->value, []))->toBe([]);
});
test('fails when content type requires media and none provided', function () {
$errors = runMediaRule(ContentType::InstagramReel->value, []);
expect($errors)->toHaveCount(1);
expect($errors[0])->toContain('requires at least one media file');
});
test('fails when content type does not support images and an image is present', function () {
$media = [['type' => MediaType::Image->value, 'mime_type' => 'image/jpeg']];
$errors = runMediaRule(ContentType::TikTokVideo->value, $media);
expect($errors)->toHaveCount(1);
expect($errors[0])->toContain('does not support images');
});
test('fails when content type does not support video and a video is present', function () {
$media = [['type' => MediaType::Video->value, 'mime_type' => 'video/mp4']];
$errors = runMediaRule(ContentType::PinterestPin->value, $media);
expect($errors)->toHaveCount(1);
expect($errors[0])->toContain('does not support videos');
});
test('youtube short rejects images', function () {
$media = [['type' => MediaType::Image->value, 'mime_type' => 'image/jpeg']];
$errors = runMediaRule(ContentType::YouTubeShort->value, $media);
expect($errors[0])->toContain('does not support images');
});
test('passes when image-only content type receives an image', function () {
$media = [['type' => MediaType::Image->value, 'mime_type' => 'image/png']];
expect(runMediaRule(ContentType::PinterestPin->value, $media))->toBe([]);
});
test('passes when video-only content type receives a video', function () {
$media = [['type' => MediaType::Video->value, 'mime_type' => 'video/mp4']];
expect(runMediaRule(ContentType::TikTokVideo->value, $media))->toBe([]);
expect(runMediaRule(ContentType::YouTubeShort->value, $media))->toBe([]);
expect(runMediaRule(ContentType::InstagramReel->value, $media))->toBe([]);
});
test('detects media type from mime when type field is missing', function () {
$media = [['mime_type' => 'image/jpeg']];
$errors = runMediaRule(ContentType::TikTokVideo->value, $media);
expect($errors)->toHaveCount(1);
});
test('does nothing for invalid content type values', function () {
expect(runMediaRule('not_a_real_content_type', []))->toBe([]);
});

View file

@ -137,3 +137,63 @@ function createTestImage(int $width, int $height, string $format = 'image/jpeg')
expect(file_exists($result))->toBeTrue()
->and(filesize($result))->toBeGreaterThan(0);
});
it('crops a wide image to a 1:1 square', function () use (&$tempFiles) {
$source = createTestImage(1920, 1080);
$tempFiles[] = $source;
$optimizer = new MediaOptimizer;
$result = $optimizer->cropToAspectRatio($source, 1.0);
$tempFiles[] = $result;
$manager = new ImageManager(Driver::class);
$cropped = $manager->decodePath($result);
expect($cropped->width())->toBe($cropped->height());
expect($cropped->height())->toBe(1080);
});
it('crops a tall image to a 4:5 portrait', function () use (&$tempFiles) {
$source = createTestImage(1000, 2000);
$tempFiles[] = $source;
$optimizer = new MediaOptimizer;
$result = $optimizer->cropToAspectRatio($source, 4 / 5);
$tempFiles[] = $result;
$manager = new ImageManager(Driver::class);
$cropped = $manager->decodePath($result);
$ratio = $cropped->width() / $cropped->height();
expect(abs($ratio - 0.8))->toBeLessThan(0.01);
});
it('crops a square image to a 16:9 landscape', function () use (&$tempFiles) {
$source = createTestImage(1080, 1080);
$tempFiles[] = $source;
$optimizer = new MediaOptimizer;
$result = $optimizer->cropToAspectRatio($source, 16 / 9);
$tempFiles[] = $result;
$manager = new ImageManager(Driver::class);
$cropped = $manager->decodePath($result);
$ratio = $cropped->width() / $cropped->height();
expect(abs($ratio - 16 / 9))->toBeLessThan(0.01);
});
it('returns a copy when image already matches the target ratio', function () use (&$tempFiles) {
$source = createTestImage(800, 800);
$tempFiles[] = $source;
$optimizer = new MediaOptimizer;
$result = $optimizer->cropToAspectRatio($source, 1.0);
$tempFiles[] = $result;
$manager = new ImageManager(Driver::class);
$cropped = $manager->decodePath($result);
expect($cropped->width())->toBe(800);
expect($cropped->height())->toBe(800);
});

View file

@ -12,6 +12,17 @@
use App\Models\Workspace;
use App\Services\Social\InstagramPublisher;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use Intervention\Image\Drivers\Gd\Driver;
use Intervention\Image\ImageManager;
function fakeJpegBytes(int $width = 1200, int $height = 800): string
{
$manager = new ImageManager(Driver::class);
$image = $manager->createImage($width, $height)->fill('888888');
return (string) $image->encodeUsingMediaType('image/jpeg', quality: 80);
}
beforeEach(function () {
$this->user = User::factory()->create();
@ -714,3 +725,165 @@
expect(fn () => $this->publisher->publish($this->postPlatform))
->toThrow(Exception::class);
});
test('feed image is cropped to chosen aspect ratio before publishing', function () {
Storage::fake();
$this->postPlatform->update(['meta' => ['aspect_ratio' => '4:5']]);
$this->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([
'https://example.com/media/test.jpg' => Http::response(fakeJpegBytes(1200, 800), 200),
'https://graph.instagram.com/v25.0/ig_123456789/media' => Http::response(['id' => 'container-123'], 200),
'https://graph.instagram.com/v25.0/container-123*' => Http::response(['status_code' => 'FINISHED'], 200),
'https://graph.instagram.com/v25.0/ig_123456789/media_publish' => Http::response(['id' => 'media-1'], 200),
'https://graph.instagram.com/v25.0/media-1*' => Http::response(['permalink' => 'https://www.instagram.com/p/X/'], 200),
]);
$this->publisher->publish($this->postPlatform);
$cropped = collect(Storage::allFiles())->first(fn (string $path) => str_starts_with($path, 'instagram-crops/'));
expect($cropped)->not->toBeNull();
$manager = new ImageManager(Driver::class);
$tempFile = tempnam(sys_get_temp_dir(), 'verify_');
file_put_contents($tempFile, Storage::get($cropped));
$image = $manager->decodePath($tempFile);
$ratio = $image->width() / $image->height();
expect(abs($ratio - 0.8))->toBeLessThan(0.01);
@unlink($tempFile);
Http::assertSent(function ($request) {
if (! str_ends_with($request->url(), '/ig_123456789/media')) {
return false;
}
$imageUrl = $request['image_url'] ?? '';
return str_contains($imageUrl, 'instagram-crops/')
&& ! str_contains($imageUrl, 'example.com/media/test.jpg');
});
});
test('feed image with original aspect ratio bypasses crop', function () {
Storage::fake();
$this->postPlatform->update(['meta' => ['aspect_ratio' => 'original']]);
$this->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([
'https://graph.instagram.com/v25.0/ig_123456789/media' => Http::response(['id' => 'container-123'], 200),
'https://graph.instagram.com/v25.0/container-123*' => Http::response(['status_code' => 'FINISHED'], 200),
'https://graph.instagram.com/v25.0/ig_123456789/media_publish' => Http::response(['id' => 'media-1'], 200),
'https://graph.instagram.com/v25.0/media-1*' => Http::response(['permalink' => 'https://www.instagram.com/p/X/'], 200),
]);
$this->publisher->publish($this->postPlatform);
expect(Storage::allFiles())->toBeEmpty();
Http::assertSent(function ($request) {
if (! str_ends_with($request->url(), '/ig_123456789/media')) {
return false;
}
return ($request['image_url'] ?? '') === 'https://example.com/media/test.jpg';
});
});
test('feed image without aspect_ratio meta uses original URL', function () {
Storage::fake();
$this->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([
'https://graph.instagram.com/v25.0/ig_123456789/media' => Http::response(['id' => 'container-123'], 200),
'https://graph.instagram.com/v25.0/container-123*' => Http::response(['status_code' => 'FINISHED'], 200),
'https://graph.instagram.com/v25.0/ig_123456789/media_publish' => Http::response(['id' => 'media-1'], 200),
'https://graph.instagram.com/v25.0/media-1*' => Http::response(['permalink' => 'https://www.instagram.com/p/X/'], 200),
]);
$this->publisher->publish($this->postPlatform);
expect(Storage::allFiles())->toBeEmpty();
Http::assertSent(function ($request) {
if (! str_ends_with($request->url(), '/ig_123456789/media')) {
return false;
}
return ($request['image_url'] ?? '') === 'https://example.com/media/test.jpg';
});
});
test('carousel applies aspect ratio crop to every image', function () {
Storage::fake();
$this->postPlatform->update(['meta' => ['aspect_ratio' => '1:1']]);
$this->post->update([
'media' => [
['id' => 'm1', 'path' => 'media/a.jpg', 'url' => 'https://example.com/media/a.jpg', 'mime_type' => 'image/jpeg', 'original_filename' => 'a.jpg'],
['id' => 'm2', 'path' => 'media/b.jpg', 'url' => 'https://example.com/media/b.jpg', 'mime_type' => 'image/jpeg', 'original_filename' => 'b.jpg'],
],
]);
Http::fake([
'https://example.com/media/a.jpg' => Http::response(fakeJpegBytes(1600, 900), 200),
'https://example.com/media/b.jpg' => Http::response(fakeJpegBytes(900, 1600), 200),
'https://graph.instagram.com/v25.0/ig_123456789/media' => Http::sequence()
->push(['id' => 'child-1'], 200)
->push(['id' => 'child-2'], 200)
->push(['id' => 'carousel-1'], 200),
'https://graph.instagram.com/v25.0/child-1*' => Http::response(['status_code' => 'FINISHED'], 200),
'https://graph.instagram.com/v25.0/child-2*' => Http::response(['status_code' => 'FINISHED'], 200),
'https://graph.instagram.com/v25.0/carousel-1*' => Http::response(['status_code' => 'FINISHED'], 200),
'https://graph.instagram.com/v25.0/ig_123456789/media_publish' => Http::response(['id' => 'media-1'], 200),
'https://graph.instagram.com/v25.0/media-1*' => Http::response(['permalink' => 'https://www.instagram.com/p/X/'], 200),
]);
$this->publisher->publish($this->postPlatform);
$crops = collect(Storage::allFiles())->filter(fn (string $path) => str_starts_with($path, 'instagram-crops/'));
expect($crops)->toHaveCount(2);
$manager = new ImageManager(Driver::class);
foreach ($crops as $cropPath) {
$tempFile = tempnam(sys_get_temp_dir(), 'verify_');
file_put_contents($tempFile, Storage::get($cropPath));
$image = $manager->decodePath($tempFile);
expect($image->width())->toBe($image->height());
@unlink($tempFile);
}
});

View file

@ -123,6 +123,66 @@
expect($personalAccount->access_token)->toBe('access-token');
});
test('syncs tokens to every linked page admin\'d by the same user', function () {
$linkedInUserId = 'linkedin-user-multi';
$personalAccount = SocialAccount::factory()->linkedin()->create([
'workspace_id' => $this->workspace->id,
'platform_user_id' => $linkedInUserId,
'access_token' => 'new-access-token',
'refresh_token' => 'new-refresh-token',
'token_expires_at' => now()->addDays(60),
]);
$pageOne = SocialAccount::factory()->linkedinPage()->create([
'workspace_id' => $this->workspace->id,
'platform_user_id' => 'org-one',
'access_token' => 'old-token-1',
'refresh_token' => 'old-refresh-1',
'meta' => [
'organization_id' => 'org-one',
'admin_user_id' => $linkedInUserId,
'admin_name' => 'Test User',
],
]);
$pageTwo = SocialAccount::factory()->linkedinPage()->create([
'workspace_id' => $this->workspace->id,
'platform_user_id' => 'org-two',
'access_token' => 'old-token-2',
'refresh_token' => 'old-refresh-2',
'meta' => [
'organization_id' => 'org-two',
'admin_user_id' => $linkedInUserId,
'admin_name' => 'Test User',
],
]);
$unrelatedPage = SocialAccount::factory()->linkedinPage()->create([
'workspace_id' => $this->workspace->id,
'platform_user_id' => 'org-other-admin',
'access_token' => 'untouched-token',
'refresh_token' => 'untouched-refresh',
'meta' => [
'organization_id' => 'org-other-admin',
'admin_user_id' => 'someone-else',
'admin_name' => 'Other User',
],
]);
$this->synchronizer->syncTokens($personalAccount);
$pageOne->refresh();
$pageTwo->refresh();
$unrelatedPage->refresh();
expect($pageOne->access_token)->toBe('new-access-token');
expect($pageOne->refresh_token)->toBe('new-refresh-token');
expect($pageTwo->access_token)->toBe('new-access-token');
expect($pageTwo->refresh_token)->toBe('new-refresh-token');
expect($unrelatedPage->access_token)->toBe('untouched-token');
});
test('does not sync across different workspaces', function () {
$linkedInUserId = 'linkedin-user-cross';
$otherWorkspace = Workspace::factory()->create(['user_id' => $this->user->id]);