Merge pull request #22 from trypostit/feature/posts-realtime-status

feat: AI image generation overhaul + post creation flow + realtime broadcast
This commit is contained in:
Paulo Castellano 2026-05-08 20:14:59 -03:00 committed by GitHub
commit 26e7688e96
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
144 changed files with 2632 additions and 2041 deletions

View file

@ -295,6 +295,13 @@ ## 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`.
## String Interpolation
- When injecting variables into strings, prefer **double-quoted interpolation** with curly braces over concatenation with `.`.
- PHP: `"workspace.{$workspace->id}"` instead of `'workspace.'.$workspace->id`.
- Use curly braces `{}` even for simple variables to keep the boundary explicit and to allow object/array access without ambiguity.
- Single quotes are still preferred when the string has no interpolation.
## TryPost.it Documentation
- All our documentation to final user it's under https://docs.trypost.it

View file

@ -5,6 +5,7 @@
namespace App\Actions\Post;
use App\Enums\Post\Status as PostStatus;
use App\Events\PostCreated;
use App\Models\Post;
use App\Models\User;
use App\Models\Workspace;
@ -38,7 +39,7 @@ public static function execute(Workspace $workspace, User $user, array $data): P
{
$scheduledAt = self::resolveScheduledAt($data);
return DB::transaction(function () use ($workspace, $user, $data, $scheduledAt): Post {
$post = DB::transaction(function () use ($workspace, $user, $data, $scheduledAt): Post {
$post = $workspace->posts()->create([
'user_id' => $user->id,
'content' => data_get($data, 'content', ''),
@ -72,6 +73,10 @@ public static function execute(Workspace $workspace, User $user, array $data): P
return $post;
});
PostCreated::dispatch($post);
return $post;
}
/**

View file

@ -4,12 +4,18 @@
namespace App\Actions\Post;
use App\Events\PostDeleted;
use App\Models\Post;
class DeletePost
{
public static function execute(Post $post): void
{
$postId = $post->id;
$workspaceId = $post->workspace_id;
$post->delete();
PostDeleted::dispatch($postId, $workspaceId);
}
}

View file

@ -62,7 +62,7 @@ public function instructions(): string
'brand_description' => $this->workspace->brand_description ?? '',
'brand_tone' => $this->workspace->brand_tone ?? '',
'brand_voice_notes' => $this->workspace->brand_voice_notes ?? '',
'content_language' => $this->workspace->content_language ?? 'en',
'content_language' => $this->workspace->content_language,
'current_content' => $this->currentContent,
'format' => $this->format,
'slide_count' => $this->slideCount,

View file

@ -34,7 +34,7 @@ public function instructions(): string
'brand_name' => $this->workspace->name ?? '',
'brand_tone' => $this->workspace->brand_tone ?? '',
'brand_voice_notes' => $this->workspace->brand_voice_notes ?? '',
'content_language' => $this->workspace->content_language ?? 'en',
'content_language' => $this->workspace->content_language,
'format' => $this->format,
])->render();
}

View file

@ -27,7 +27,7 @@ public function instructions(): string
'brand_name' => $this->workspace->name ?? '',
'brand_tone' => $this->workspace->brand_tone ?? '',
'brand_voice_notes' => $this->workspace->brand_voice_notes ?? '',
'content_language' => $this->workspace->content_language ?? 'en',
'content_language' => $this->workspace->content_language,
])->render();
}

View file

@ -35,7 +35,7 @@ public function instructions(): string
'brand_description' => $this->workspace->brand_description ?? '',
'brand_tone' => $this->workspace->brand_tone ?? '',
'brand_voice_notes' => $this->workspace->brand_voice_notes ?? '',
'content_language' => $this->workspace->content_language ?? 'en',
'content_language' => $this->workspace->content_language,
'current_content' => $this->currentContent,
'format' => 'single',
'slide_count' => 1,

View file

@ -8,8 +8,8 @@
class UserAiCreationChannel
{
public function join(User $user, string $userId, string $creationId): bool
public function join(User $user, User $owner, string $creationId): bool
{
return $user->id === $userId;
return $user->is($owner);
}
}

View file

@ -8,8 +8,8 @@
class UserAiGenerationChannel
{
public function join(User $user, string $userId, string $generationId): bool
public function join(User $user, User $owner, string $generationId): bool
{
return $user->id === $userId;
return $user->is($owner);
}
}

View file

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Broadcasting;
use App\Models\User;
use App\Models\Workspace;
class WorkspaceChannel
{
public function join(User $user, Workspace $workspace): bool
{
return $workspace->hasMember($user);
}
}

View file

@ -4,14 +4,21 @@
namespace App\DataTransferObjects;
use App\Enums\Media\Source;
class MediaItem
{
/**
* @param array<string, mixed>|null $source_meta
*/
public function __construct(
public readonly string $id,
public readonly string $path,
public readonly string $url,
public readonly ?string $mime_type = null,
public readonly ?string $original_filename = null,
public readonly ?Source $source = null,
public readonly ?array $source_meta = null,
) {}
public function isVideo(): bool
@ -60,12 +67,19 @@ public static function fromArray(array $data): self
};
}
$sourceValue = data_get($data, 'source');
$source = is_string($sourceValue) ? Source::tryFrom($sourceValue) : null;
$sourceMeta = data_get($data, 'source_meta');
return new self(
id: data_get($data, 'id', ''),
path: $path,
url: data_get($data, 'url', ''),
mime_type: $mimeType,
original_filename: data_get($data, 'original_filename'),
source: $source,
source_meta: is_array($sourceMeta) ? $sourceMeta : null,
);
}
}

View file

@ -8,4 +8,5 @@ enum UsageType: string
{
case Template = 'template';
case Text = 'text';
case Image = 'image';
}

View file

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Enums\Media;
/**
* Origin of a media attachment on a post. `null`/absent means "uploaded by
* the user" (legacy/unknown). The enum is forward-compatible so the regen
* UI can decide on a per-source basis what actions are available (only `Ai`
* exposes a "regenerate" button today, but `Unsplash`/`Giphy` may surface
* "fetch alternate" or attribution UIs later).
*/
enum Source: string
{
case Ai = 'ai';
case Unsplash = 'unsplash';
case Giphy = 'giphy';
}

View file

@ -9,6 +9,7 @@ enum Type: string
case PostPublished = 'post_published';
case PostFailed = 'post_failed';
case PostPartiallyPublished = 'post_partially_published';
case PostReady = 'post_ready';
case AccountDisconnected = 'account_disconnected';
case InviteReceived = 'invite_received';
case MemberJoined = 'member_joined';

View file

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Enums\Workspace;
/**
* Visual style the AI image generator will lean into when rendering slides
* and cover images for a workspace's posts. Each case maps to a prompt
* template strategy in the image generation pipeline.
*/
enum ImageStyle: string
{
case Cinematic = 'cinematic';
case Illustration = 'illustration';
case Isometric3D = 'isometric_3d';
case Cartoon = 'cartoon';
case Typographic = 'typographic';
case Infographic = 'infographic';
case Minimalist = 'minimalist';
case Mockup = 'mockup';
public const DEFAULT = self::Cinematic;
/**
* @return array<int, string>
*/
public static function values(): array
{
return array_map(fn (self $s) => $s->value, self::cases());
}
}

View file

@ -6,31 +6,29 @@
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class PostCreationReady implements ShouldBroadcastNow
class PostCreationReady implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(
public string $userId,
public string $creationId,
public ?string $content,
public ?string $postId = null,
public ?string $error = null,
public ?string $imageTitle = null,
public ?string $imageBody = null,
) {}
public function broadcastOn(): PrivateChannel
{
return new PrivateChannel("users.{$this->userId}.ai-creation.{$this->creationId}");
}
public function broadcastAs(): string
{
return 'PostCreationReady';
return 'ai.creation.completed';
}
public function broadcastOn(): PrivateChannel
{
return new PrivateChannel("user.{$this->userId}.ai-creation.{$this->creationId}");
}
/**
@ -40,10 +38,13 @@ public function broadcastWith(): array
{
return [
'creation_id' => $this->creationId,
'content' => $this->content,
'image_title' => $this->imageTitle,
'image_body' => $this->imageBody,
'post_id' => $this->postId,
'error' => $this->error,
];
}
public function broadcastQueue(): string
{
return 'broadcasts';
}
}

View file

@ -7,27 +7,30 @@
use App\Models\Notification;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
class NotificationCreated implements ShouldBroadcastNow
class NotificationCreated implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets;
public function __construct(public Notification $notification) {}
public function broadcastAs(): string
{
return 'notification.created';
}
public function broadcastOn(): array
{
return [
new PrivateChannel('workspace.'.$this->notification->workspace_id.'.user.'.$this->notification->user_id),
new PrivateChannel("workspace.{$this->notification->workspace_id}.user.{$this->notification->user_id}"),
];
}
public function broadcastAs(): string
{
return 'NotificationCreated';
}
/**
* @return array<string, mixed>
*/
public function broadcastWith(): array
{
return [
@ -47,4 +50,9 @@ public function broadcastWith(): array
],
];
}
public function broadcastQueue(): string
{
return 'broadcasts';
}
}

View file

@ -9,27 +9,30 @@
use App\Support\MentionParser;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
class PostCommentCreated implements ShouldBroadcastNow
class PostCommentCreated implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets;
public function __construct(public PostComment $comment) {}
public function broadcastAs(): string
{
return 'post.comment.created';
}
public function broadcastOn(): array
{
return [
new PrivateChannel('post.'.$this->comment->post_id),
new PrivateChannel("post.{$this->comment->post_id}"),
];
}
public function broadcastAs(): string
{
return 'PostCommentCreated';
}
/**
* @return array<string, mixed>
*/
public function broadcastWith(): array
{
$mentionedIds = MentionParser::extractUserIds($this->comment->body ?? '');
@ -59,4 +62,9 @@ public function broadcastWith(): array
'mentioned_users' => $mentionedUsers,
];
}
public function broadcastQueue(): string
{
return 'broadcasts';
}
}

View file

@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Events;
use App\Models\Post;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class PostCreated implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(public Post $post) {}
public function broadcastAs(): string
{
return 'post.created';
}
public function broadcastOn(): array
{
return [
new PrivateChannel("workspace.{$this->post->workspace_id}"),
];
}
/**
* @return array<string, string>
*/
public function broadcastWith(): array
{
return [
'post_id' => $this->post->id,
];
}
public function broadcastQueue(): string
{
return 'broadcasts';
}
}

View file

@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Events;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
class PostDeleted implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets;
public function __construct(
public string $postId,
public string $workspaceId,
) {}
public function broadcastAs(): string
{
return 'post.deleted';
}
public function broadcastOn(): array
{
return [
new PrivateChannel("workspace.{$this->workspaceId}"),
];
}
/**
* @return array<string, string>
*/
public function broadcastWith(): array
{
return [
'post_id' => $this->postId,
];
}
public function broadcastQueue(): string
{
return 'broadcasts';
}
}

View file

@ -7,11 +7,11 @@
use App\Models\PostPlatform;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class PostPlatformStatusUpdated implements ShouldBroadcastNow
class PostPlatformStatusUpdated implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
@ -19,33 +19,29 @@ public function __construct(public PostPlatform $postPlatform) {}
public function broadcastAs(): string
{
return 'PostPlatformStatusUpdated';
return 'post.platform.status.updated';
}
public function broadcastOn(): array
{
return [
new PrivateChannel('post.'.$this->postPlatform->post_id),
new PrivateChannel("post.{$this->postPlatform->post_id}"),
new PrivateChannel("workspace.{$this->postPlatform->post->workspace_id}"),
];
}
/**
* @return array<string, string>
*/
public function broadcastWith(): array
{
$post = $this->postPlatform->post->fresh();
return [
'post_platform' => [
'id' => $this->postPlatform->id,
'status' => $this->postPlatform->status->value,
'platform_url' => $this->postPlatform->platform_url,
'error_message' => $this->postPlatform->error_message,
'published_at' => $this->postPlatform->published_at?->toISOString(),
],
'post' => [
'id' => $post->id,
'status' => $post->status->value,
'published_at' => $post->published_at?->toISOString(),
],
'post_id' => $this->postPlatform->post_id,
];
}
public function broadcastQueue(): string
{
return 'broadcasts';
}
}

View file

@ -1,34 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Events;
use App\Models\Account;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class SubscriptionCreated implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(public Account $account) {}
public function broadcastOn(): array
{
return [
new PrivateChannel('users.'.$this->account->owner_id),
];
}
public function broadcastWith(): array
{
return [
'status' => 'success',
'message' => 'Subscription created successfully',
];
}
}

View file

@ -4,19 +4,15 @@
namespace App\Http\Controllers\App;
use App\Actions\Post\CreatePost;
use App\Enums\Media\Type as MediaType;
use App\Enums\PostPlatform\ContentType;
use App\Http\Requests\App\Ai\StartPostCreationRequest;
use App\Jobs\Ai\StreamPostCreation;
use App\Models\SocialAccount;
use App\Models\Workspace;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Inertia\Inertia;
use Inertia\Response as InertiaResponse;
use Symfony\Component\HttpFoundation\Response;
class PostAiCreateController extends Controller
@ -59,165 +55,18 @@ public function start(StartPostCreationRequest $request): JsonResponse
return response()->json([
'creation_id' => $creationId,
'channel' => "users.{$request->user()->id}.ai-creation.{$creationId}",
'channel' => "user.{$request->user()->id}.ai-creation.{$creationId}",
], Response::HTTP_ACCEPTED);
}
public function finalize(Request $request, string $creationId): JsonResponse
public function loading(Request $request, string $creationId): InertiaResponse
{
$workspace = $request->user()->currentWorkspace;
$this->authorize('createPost', $workspace);
$state = Cache::get("ai-creation:{$creationId}");
if (! $state || data_get($state, 'user_id') !== $request->user()->id) {
abort(Response::HTTP_NOT_FOUND);
}
$format = data_get($state, 'format');
$socialAccountId = data_get($state, 'social_account_id');
$contentType = $format ? ContentType::tryFrom($format) : null;
// The wizard's preview is editable. Frontend sends whichever fields it
// showed: caption for normal formats, image_title + image_body for
// stories. We override the cached state with whatever was edited.
if ($request->filled('content')) {
$state['content'] = (string) $request->input('content');
}
if ($request->filled('image_title')) {
$state['image_title'] = (string) $request->input('image_title');
}
if ($request->filled('image_body')) {
$state['image_body'] = (string) $request->input('image_body');
}
$media = $this->buildMediaArray($workspace, $state);
$caption = ($contentType && ! $contentType->supportsCaption())
? ''
: (string) data_get($state, 'content', '');
$post = CreatePost::execute($workspace, $request->user(), [
'content' => $caption,
'media' => $media,
'date' => data_get($state, 'date'),
return Inertia::render('posts/ai/Loading', [
'creationId' => $creationId,
'channel' => "user.{$request->user()->id}.ai-creation.{$creationId}",
'imageCount' => (int) $request->query('images', '0'),
'format' => (string) $request->query('format', ''),
'prompt' => (string) $request->query('prompt', ''),
]);
// Sync the post_platform with the wizard choice: set content_type to
// match the format (so the editor surfaces "Story" instead of "Post"
// for instance) and aspect_ratio so the preview matches the rendered
// image exactly. Carousel collapses to feed since Instagram doesn't
// expose carousel as a separate content_type at the API level.
if ($contentType && $socialAccountId) {
$aspectRatio = $this->aspectRatioFor($contentType);
$platformContentType = $contentType === ContentType::InstagramCarousel
? ContentType::InstagramFeed
: $contentType;
$post->postPlatforms()
->where('social_account_id', $socialAccountId)
->each(function ($platform) use ($aspectRatio, $platformContentType): void {
$meta = $platform->meta ?? [];
if ($aspectRatio !== null) {
$meta['aspect_ratio'] = $aspectRatio;
}
$platform->meta = $meta;
$platform->content_type = $platformContentType->value;
$platform->enabled = true;
$platform->save();
});
}
Cache::forget("ai-creation:{$creationId}");
return response()->json([
'post_id' => $post->id,
'redirect_url' => route('app.posts.edit', $post),
]);
}
/**
* Map the AI image dimensions to the aspect_ratio string the editor's
* preview understands. Returns null when the size doesn't match a known
* preview ratio (Instagram preview supports 1:1, 4:5, 16:9, original).
*/
private function aspectRatioFor(ContentType $type): ?string
{
$dims = $type->aiImageDimensions();
$ratio = $dims['width'] / $dims['height'];
return match (true) {
abs($ratio - 1.0) < 0.01 => '1:1',
abs($ratio - 4 / 5) < 0.01 => '4:5',
abs($ratio - 16 / 9) < 0.01 => '16:9',
default => null,
};
}
/**
* Build the media array for post creation from the AI creation state.
*
* @param array<string, mixed> $state
* @return array<int, array<string, mixed>>
*/
private function buildMediaArray(Workspace $workspace, array $state): array
{
$media = [];
if (data_get($state, 'format') === 'instagram_carousel') {
foreach (data_get($state, 'slides', []) as $slide) {
$path = data_get($slide, 'image_path');
if ($path) {
$media[] = $this->createMediaItem($workspace, $path, [
'slide_title' => data_get($slide, 'title'),
'slide_body' => data_get($slide, 'body'),
'slide_keywords' => data_get($slide, 'image_keywords', []),
'is_closing' => (bool) data_get($slide, 'is_closing', false),
]);
}
}
} else {
$path = data_get($state, 'image_path');
if ($path) {
$media[] = $this->createMediaItem($workspace, $path, [
'slide_title' => data_get($state, 'image_title'),
'slide_body' => data_get($state, 'image_body'),
'slide_keywords' => data_get($state, 'image_keywords', []),
]);
}
}
return $media;
}
/**
* Create a Media record for an AI-generated image and return it as an array.
*
* @param array<string, mixed> $meta
* @return array<string, mixed>
*/
private function createMediaItem(Workspace $workspace, string $path, array $meta = []): array
{
// Use the relationship (not `mediable_type = Workspace::class`) so
// the morph map alias `'workspace'` is persisted instead of the FQCN.
$media = $workspace->media()->create([
'collection' => 'ai-generated',
'type' => MediaType::Image,
'path' => $path,
'original_filename' => basename($path),
'mime_type' => 'image/webp',
'size' => Storage::size($path),
'order' => 0,
]);
return [
'id' => $media->id,
'path' => $media->path,
'url' => $media->url,
'type' => 'image',
'mime_type' => 'image/webp',
'meta' => $meta,
];
}
}

View file

@ -39,7 +39,7 @@ public function generate(GeneratePostContentRequest $request, Post $post): JsonR
return response()->json([
'generation_id' => $generationId,
'channel' => "users.{$request->user()->id}.ai-gen.{$generationId}",
'channel' => "user.{$request->user()->id}.ai-gen.{$generationId}",
], Response::HTTP_ACCEPTED);
}
}

View file

@ -69,11 +69,8 @@ public function apply(ApplyPostTemplateRequest $request, string $slug, TemplateI
$media = [];
if ($socialAccount && $template->slides) {
foreach ($template->slides as $i => $slide) {
// Even-indexed slides use Template A (full-bleed cover); odd-indexed slides use Template B.
$tmpl = $i % 2 === 0 ? 'A' : 'B';
$path = $generator->render(
template: $tmpl,
foreach ($template->slides as $slide) {
$rendered = $generator->render(
workspace: $workspace,
socialAccount: $socialAccount,
title: $this->interpolate(data_get($slide, 'title', ''), $workspace),
@ -81,8 +78,8 @@ public function apply(ApplyPostTemplateRequest $request, string $slug, TemplateI
imageKeywords: data_get($slide, 'image_keywords', []),
);
if ($path) {
$mediaItem = $this->createMediaItem($workspace, $path);
if ($rendered) {
$mediaItem = $this->createMediaItem($workspace, $rendered['path']);
$media[] = $mediaItem;
}
}

View file

@ -8,6 +8,7 @@
use App\Actions\Workspace\CreateWorkspace;
use App\Actions\Workspace\DeleteWorkspace;
use App\Enums\Workspace\BrandFont;
use App\Enums\Workspace\ImageStyle;
use App\Http\Requests\App\Workspace\StoreWorkspaceRequest;
use App\Http\Requests\App\Workspace\UpdateWorkspaceRequest;
use App\Http\Resources\App\WorkspaceMemberResource;
@ -77,7 +78,10 @@ public function create(Request $request): Response|RedirectResponse
return back()->with('flash.error', __('workspaces.limit_reached'));
}
return Inertia::render('workspaces/Create');
return Inertia::render('workspaces/Create', [
'availableFonts' => BrandFont::values(),
'availableImageStyles' => ImageStyle::values(),
]);
}
public function autofillBrand(Request $request, AutofillBrand $autofill): JsonResponse
@ -173,6 +177,7 @@ public function brandSettings(Request $request): Response|RedirectResponse
return Inertia::render('settings/workspace/Brand', [
'workspace' => $workspace,
'availableFonts' => BrandFont::values(),
'availableImageStyles' => ImageStyle::values(),
]);
}

View file

@ -28,7 +28,6 @@ public function rules(): array
],
'social_account_id' => ['nullable', 'uuid'],
'image_count' => ['nullable', 'integer', 'min:0', 'max:10'],
// Stories accept 1 image, no carousel — the wizard handles this client-side too.
'prompt' => ['required', 'string', 'max:2000'],
'date' => ['nullable', 'date_format:Y-m-d'],
];

View file

@ -5,6 +5,7 @@
namespace App\Http\Requests\App\Workspace;
use App\Enums\Workspace\BrandFont;
use App\Enums\Workspace\ImageStyle;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
@ -29,6 +30,7 @@ public function rules(): array
'background_color' => $hex,
'text_color' => $hex,
'brand_font' => ['sometimes', 'string', Rule::in(BrandFont::values())],
'image_style' => ['sometimes', 'string', Rule::in(ImageStyle::values())],
'content_language' => ['nullable', 'string', 'in:en,pt-BR,es'],
'logo_url' => ['nullable', 'url', 'max:1024'],
];

View file

@ -5,6 +5,7 @@
namespace App\Http\Requests\App\Workspace;
use App\Enums\Workspace\BrandFont;
use App\Enums\Workspace\ImageStyle;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
@ -29,6 +30,7 @@ public function rules(): array
'background_color' => $hex,
'text_color' => $hex,
'brand_font' => ['required', 'string', Rule::in(BrandFont::values())],
'image_style' => ['required', 'string', Rule::in(ImageStyle::values())],
'content_language' => ['sometimes', 'string', 'in:en,pt-BR,es'],
];
}

View file

@ -38,7 +38,7 @@ public function handle(): void
currentContent: $this->currentContent,
);
$channel = new PrivateChannel("users.{$this->userId}.ai-gen.{$this->generationId}");
$channel = new PrivateChannel("user.{$this->userId}.ai-gen.{$this->generationId}");
try {
$response = $agent->broadcast($this->prompt, $channel, now: true);

View file

@ -4,23 +4,31 @@
namespace App\Jobs\Ai;
use App\Actions\Post\CreatePost;
use App\Ai\Agents\PostContentGenerator;
use App\Ai\Agents\PostContentHumanizer;
use App\Enums\Media\Source;
use App\Enums\Media\Type as MediaType;
use App\Enums\Notification\Channel as NotificationChannel;
use App\Enums\Notification\Type as NotificationType;
use App\Enums\PostPlatform\ContentType;
use App\Events\Ai\PostCreationReady;
use App\Jobs\SendNotification;
use App\Models\Post;
use App\Models\SocialAccount;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Ai\AiImageClient;
use App\Services\Ai\RecordAiUsage;
use App\Services\Image\BrandColorMapper;
use App\Services\Image\TemplateImageGenerator;
use App\Services\Unsplash\UnsplashClient;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
class StreamPostCreation implements ShouldQueue
{
@ -46,6 +54,7 @@ public function handle(): void
$isCarousel = $this->format === 'instagram_carousel';
$agentFormat = $isCarousel ? 'carousel' : 'single';
$slideCount = $isCarousel && $this->imageCount > 0 ? $this->imageCount : 1;
$agent = new PostContentGenerator(
@ -182,27 +191,19 @@ private function humanize(Workspace $workspace, array $structured, string $forma
return $structured;
}
/**
* @param array<string, mixed> $structured
*/
private function handleCarousel(Workspace $workspace, ?SocialAccount $socialAccount, array $structured): void
{
$caption = data_get($structured, 'caption', '');
$caption = (string) data_get($structured, 'caption', '');
$slides = data_get($structured, 'slides', []);
$renderedSlides = [];
$media = [];
if ($socialAccount) {
$generator = new TemplateImageGenerator(new UnsplashClient, new BrandColorMapper);
$generator = new TemplateImageGenerator(new BrandColorMapper, new AiImageClient);
['width' => $width, 'height' => $height] = $this->dimensionsForFormat();
foreach ($slides as $i => $slide) {
// First slide is always Template A (full-bleed cover); subsequent slides
// alternate so even-indexed are A and odd-indexed are B.
$template = $i === 0 ? 'A' : ($i % 2 === 0 ? 'A' : 'B');
$path = $generator->render(
template: $template,
foreach ($slides as $slide) {
$rendered = $generator->render(
workspace: $workspace,
socialAccount: $socialAccount,
title: data_get($slide, 'title', ''),
@ -212,51 +213,15 @@ private function handleCarousel(Workspace $workspace, ?SocialAccount $socialAcco
height: $height,
);
$renderedSlides[] = [
'title' => data_get($slide, 'title', ''),
'body' => data_get($slide, 'body', ''),
'image_path' => $path,
];
}
// Auto-append closing slide (Template C) at the end of the carousel.
$closingPath = $generator->renderClosing(
workspace: $workspace,
socialAccount: $socialAccount,
width: $width,
height: $height,
);
if ($closingPath) {
$renderedSlides[] = [
'title' => null,
'body' => null,
'image_path' => $closingPath,
'is_closing' => true,
];
}
} else {
foreach ($slides as $slide) {
$renderedSlides[] = [
'title' => data_get($slide, 'title', ''),
'body' => data_get($slide, 'body', ''),
'image_path' => null,
];
if ($rendered) {
$media[] = $this->buildAiMediaItem($workspace, $rendered);
}
}
}
Cache::put("ai-creation:{$this->creationId}", [
'workspace_id' => $this->workspaceId,
'user_id' => $this->userId,
'format' => $this->format,
'social_account_id' => $this->socialAccountId,
'content' => $caption,
'slides' => $renderedSlides,
'date' => $this->date,
'created_at' => now()->toIso8601String(),
], now()->addMinutes(30));
$post = $this->createPost($workspace, $caption, $media, $socialAccount);
PostCreationReady::dispatch($this->userId, $this->creationId, $caption);
$this->notifyReady($workspace, $post);
}
/**
@ -264,19 +229,21 @@ private function handleCarousel(Workspace $workspace, ?SocialAccount $socialAcco
*/
private function handleSingle(Workspace $workspace, ?SocialAccount $socialAccount, array $structured): void
{
$content = data_get($structured, 'content', data_get($structured, 'text', ''));
$imageTitle = data_get($structured, 'image_title', '');
$imageBody = data_get($structured, 'image_body', '');
$contentType = ContentType::tryFrom($this->format);
$supportsCaption = $contentType?->supportsCaption() ?? true;
$rawContent = (string) data_get($structured, 'content', data_get($structured, 'text', ''));
$imageTitle = (string) data_get($structured, 'image_title', '');
$imageBody = (string) data_get($structured, 'image_body', '');
$keywords = data_get($structured, 'image_keywords', []);
$imagePath = null;
$media = [];
if ($this->imageCount > 0 && $socialAccount) {
$generator = new TemplateImageGenerator(new UnsplashClient, new BrandColorMapper);
$generator = new TemplateImageGenerator(new BrandColorMapper, new AiImageClient);
['width' => $width, 'height' => $height] = $this->dimensionsForFormat();
$imagePath = $generator->render(
template: 'A',
$rendered = $generator->render(
workspace: $workspace,
socialAccount: $socialAccount,
title: $imageTitle,
@ -285,32 +252,114 @@ private function handleSingle(Workspace $workspace, ?SocialAccount $socialAccoun
width: $width,
height: $height,
);
if ($rendered) {
$media[] = $this->buildAiMediaItem($workspace, $rendered);
}
}
Cache::put("ai-creation:{$this->creationId}", [
'workspace_id' => $this->workspaceId,
'user_id' => $this->userId,
'format' => $this->format,
'social_account_id' => $this->socialAccountId,
'image_count' => $this->imageCount,
'content' => $content,
'image_title' => $imageTitle,
'image_body' => $imageBody,
'image_keywords' => $keywords,
'image_path' => $imagePath,
'date' => $this->date,
'created_at' => now()->toIso8601String(),
], now()->addMinutes(30));
$caption = $supportsCaption ? $rawContent : '';
$post = $this->createPost($workspace, $caption, $media, $socialAccount);
// Broadcast all three fields — the frontend renders caption (when the
// format supports it) or title+body separately (for caption-less story
// formats). No string parsing on the way back.
$this->notifyReady($workspace, $post);
}
/**
* @param array<int, array<string, mixed>> $media
*/
private function createPost(Workspace $workspace, string $content, array $media, ?SocialAccount $socialAccount): Post
{
$user = User::findOrFail($this->userId);
$post = CreatePost::execute($workspace, $user, [
'content' => $content,
'media' => $media,
'date' => $this->date,
]);
$contentType = ContentType::tryFrom($this->format);
if ($contentType && $socialAccount) {
$aspectRatio = $this->aspectRatioFor($contentType);
$platformContentType = $contentType === ContentType::InstagramCarousel
? ContentType::InstagramFeed
: $contentType;
$post->postPlatforms()
->where('social_account_id', $socialAccount->id)
->each(function ($platform) use ($aspectRatio, $platformContentType): void {
$meta = $platform->meta ?? [];
if ($aspectRatio !== null) {
$meta['aspect_ratio'] = $aspectRatio;
}
$platform->meta = $meta;
$platform->content_type = $platformContentType->value;
$platform->enabled = true;
$platform->save();
});
}
return $post;
}
private function notifyReady(Workspace $workspace, Post $post): void
{
PostCreationReady::dispatch(
userId: $this->userId,
creationId: $this->creationId,
content: $content,
imageTitle: $imageTitle,
imageBody: $imageBody,
postId: $post->id,
);
$user = User::findOrFail($this->userId);
SendNotification::dispatch(
user: $user,
workspaceId: $workspace->id,
type: NotificationType::PostReady,
channel: NotificationChannel::InApp,
title: trans('notifications.post_ready.title', [], $workspace->content_language),
body: trans('notifications.post_ready.body', [], $workspace->content_language),
data: ['post_id' => $post->id],
);
}
private function aspectRatioFor(ContentType $type): ?string
{
$dims = $type->aiImageDimensions();
$ratio = $dims['width'] / $dims['height'];
return match (true) {
abs($ratio - 1.0) < 0.01 => '1:1',
abs($ratio - 4 / 5) < 0.01 => '4:5',
abs($ratio - 16 / 9) < 0.01 => '16:9',
default => null,
};
}
/**
* @param array{path: string, source_meta: array<string, mixed>} $rendered
* @return array<string, mixed>
*/
private function buildAiMediaItem(Workspace $workspace, array $rendered): array
{
$media = $workspace->media()->create([
'collection' => 'ai-generated',
'type' => MediaType::Image,
'path' => $rendered['path'],
'original_filename' => basename($rendered['path']),
'mime_type' => 'image/webp',
'size' => Storage::size($rendered['path']),
'order' => 0,
]);
return [
'id' => $media->id,
'path' => $media->path,
'url' => $media->url,
'type' => 'image',
'mime_type' => 'image/webp',
'source' => Source::Ai->value,
'source_meta' => $rendered['source_meta'],
];
}
}

View file

@ -13,16 +13,6 @@
use Illuminate\Support\Facades\Log;
use PostHog\PostHog;
/**
* Low-level PostHog dispatch worker. Forwards a single API call (`capture`,
* `identify`, `groupIdentify`) to the PostHog SDK. Higher-level jobs in this
* namespace (`SyncUser`, `TrackBilling`) build the payloads and queue this
* job to do the network work.
*
* No-op when `POSTHOG_ENABLED` is false (or `POSTHOG_API_KEY` is unset),
* via `PostHogService::isEnabled()`, so self-hosted installs never reach
* the SDK.
*/
class SendEvent implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

View file

@ -12,22 +12,6 @@
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
/**
* Centralised PostHog sync for a single user. Dispatched from places the
* frontend can't observe (signup, Stripe webhooks) so the person profile
* and account/workspace groups carry up-to-date properties without blocking
* the calling request. Inertia navigations refresh group counts on the
* client (see `syncPostHogContext` in resources/js/posthog.ts), so this job
* is not needed on every domain trigger.
*
* Hierarchy mirrors the domain model:
* - person → User
* - group `account` → Account (billing/plan, parent of workspaces)
* - group `workspace` → Workspace (collaboration unit, child of account)
*
* No-op when PostHog is disabled (`POSTHOG_ENABLED=false` or no API key)
* via `PostHogService::isEnabled()`, so self-hosted installs are unaffected.
*/
class SyncUser implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

View file

@ -13,14 +13,6 @@
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
/**
* Captures a Stripe billing lifecycle event on the account owner profile
* with the `account` group attached, then re-queues `SyncUser` so the
* group properties (plan, has_active_subscription, is_on_trial) reflect
* the new Stripe state. Dispatched by `StripeEventListener`.
*
* No-op when PostHog is disabled (`POSTHOG_ENABLED=false` or no API key).
*/
class TrackBilling implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

View file

@ -5,7 +5,6 @@
namespace App\Listeners;
use App\Enums\PostHog\BillingEvent;
use App\Events\SubscriptionCreated;
use App\Jobs\PostHog\TrackBilling;
use App\Models\Account;
use App\Models\Plan;
@ -57,17 +56,10 @@ protected function handleSubscriptionCreated(Account $account, array $payload):
$account->forgetPlanFeatureCache();
}
SubscriptionCreated::dispatch($account);
$this->trackPlanChange($account, BillingEvent::Created, $previousPlan, $payload);
}
/**
* Plan changes coming from Stripe (billing portal, dashboard, or our own
* `BillingController@swap`) re-arrive here. Re-resolving the plan from
* the price ids guarantees the local state matches Stripe even when a
* change happens out-of-band.
*
* @param array<string, mixed> $payload
*/
protected function handleSubscriptionUpdated(Account $account, array $payload): void
@ -83,19 +75,10 @@ protected function handleSubscriptionUpdated(Account $account, array $payload):
}
/**
* Subscription fully terminated (period ended after cancellation, or
* cancelled immediately). Clear the local plan so the UI and
* authorisation checks (`Account::hasActiveSubscription`) reflect
* "no plan" instead of keeping the previous one attached.
*
* @param array<string, mixed> $payload
*/
protected function handleSubscriptionDeleted(Account $account, array $payload): void
{
// Stripe re-delivers webhooks on transient failures; if the plan is
// already cleared we skip the rest so the cancellation event isn't
// captured twice and the (idempotent but pointless) cache flush
// doesn't run again.
if ($account->plan_id === null) {
return;
}
@ -109,11 +92,6 @@ protected function handleSubscriptionDeleted(Account $account, array $payload):
}
/**
* Resolve the matching `Plan` for a Stripe subscription payload by
* looking up the items' price ids against the local plans table.
* Logs a warning when no match is found so price/plan drift between
* Stripe and the local DB is visible.
*
* @param array<string, mixed> $payload
*/
private function resolvePlanFromSubscriptionItems(array $payload, Account $account): ?Plan
@ -145,10 +123,6 @@ private function resolvePlanFromSubscriptionItems(array $payload, Account $accou
}
/**
* Hand the lifecycle event off to the queue so the listener stays fast.
* Skip dispatch when PostHog is disabled — `TrackBilling::handle` would
* no-op anyway, but enqueuing it still costs queue worker cycles.
*
* @param array<string, mixed> $payload
*/
private function trackPlanChange(Account $account, BillingEvent $event, ?string $previousPlan, array $payload): void

View file

@ -32,13 +32,6 @@ class Account extends Model
'plan_id',
];
/**
* Drop the cached Pennant feature values for the plan-scoped limits so
* the next read resolves against the current plan. Call this right after
* any `plan_id` change. Centralised here (instead of as a model hook) so
* self-hosted installs — where Stripe webhooks never fire and the billing
* controllers redirect away — never trigger this path.
*/
public function forgetPlanFeatureCache(): void
{
Feature::for($this)->forget([

View file

@ -66,14 +66,8 @@ public function featureLimits(): array
}
/**
* The `(int)` cast on the cached value is load-bearing: Laravel's
* RedisStore skips serialize()/unserialize() for is_numeric values so
* they can be incremented atomically — the side effect is that an int
* stored via `Cache::put` comes back as a string on read. Without the
* cast, the strict `: int` return type throws a TypeError under the
* Redis cache driver (production). File/array/database drivers don't
* have this optimisation and preserve the type, which is why the bug
* never surfaced in tests or local dev.
* The `(int)` cast is load-bearing: Laravel's RedisStore stores numeric
* values raw (for atomic INCR) and returns them as strings on read.
*
* @param array<int, string> $workspaceIds
*/

View file

@ -4,6 +4,7 @@
namespace App\Models;
use App\Enums\Workspace\ImageStyle;
use App\Models\Traits\HasMedia;
use Database\Factories\WorkspaceFactory;
use Illuminate\Database\Eloquent\Collection;
@ -31,9 +32,17 @@ class Workspace extends Model
'background_color',
'text_color',
'brand_font',
'image_style',
'content_language',
];
protected function casts(): array
{
return [
'image_style' => ImageStyle::class,
];
}
protected $appends = ['has_logo', 'logo_url'];
public function getHasLogoAttribute(): bool

View file

@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace App\Services\Ai;
use App\Enums\Workspace\ImageStyle;
use App\Support\HexColorName;
use Illuminate\Support\Facades\Log;
use Laravel\Ai\Image;
use Throwable;
class AiImageClient
{
public const MODEL = 'gpt-image-2';
private const BRAND_DESCRIPTION_MAX = 200;
/**
* Generate raw image bytes via OpenAI gpt-image-2. Returns null on any
* failure so the caller can fall back to a stock photo without throwing.
*
* @param array<int, string> $keywords
*/
public function generate(
array $keywords,
ImageStyle $style,
string $orientation = 'portrait',
string $language = 'en',
?string $brandColor = null,
?string $brandDescription = null,
string $quality = 'low',
int $timeout = 180,
): ?string {
$clean = array_values(array_filter(array_map('trim', $keywords)));
if ($clean === []) {
return null;
}
$brandColorName = $brandColor !== null
? HexColorName::approximate($brandColor)
: null;
$brandContext = null;
if ($brandDescription !== null) {
$trimmed = trim($brandDescription);
if ($trimmed !== '') {
$brandContext = mb_strlen($trimmed) > self::BRAND_DESCRIPTION_MAX
? mb_substr($trimmed, 0, self::BRAND_DESCRIPTION_MAX).'…'
: $trimmed;
}
}
$prompt = view('prompts.post_image.generator', [
'style' => $style->value,
'scene' => implode(', ', $clean),
'language_name' => $this->languageName($language),
'brand_color_name' => $brandColorName,
'brand_context' => $brandContext,
])->render();
try {
$builder = Image::of($prompt)->quality($quality)->timeout($timeout);
$builder = match ($orientation) {
'portrait' => $builder->portrait(),
'landscape' => $builder->landscape(),
default => $builder->square(),
};
$image = $builder->generate(model: self::MODEL);
} catch (Throwable $e) {
Log::warning('AiImageClient: generation failed', [
'style' => $style->value,
'orientation' => $orientation,
'error' => $e->getMessage(),
]);
return null;
}
$bytes = (string) $image;
return $bytes !== '' ? $bytes : null;
}
private function languageName(string $code): string
{
return match ($code) {
'pt-BR' => 'Brazilian Portuguese',
'es' => 'Spanish',
default => 'English',
};
}
}

View file

@ -55,6 +55,37 @@ public static function recordText(
);
}
/**
* Record a usage entry for an AI image generation (gpt-image-* etc.).
* Credits are flat per call via CreditCost::forImage($model).
*
* @param array<string, mixed> $metadata
*/
public static function recordImage(
Workspace $workspace,
string $provider,
string $model,
?string $userId = null,
?string $postId = null,
array $metadata = [],
): void {
$credits = CreditCost::forImage($model);
self::persist(
workspace: $workspace,
type: UsageType::Image,
credits: $credits,
provider: $provider,
model: $model,
promptTokens: 0,
completionTokens: 0,
totalTokens: 0,
userId: $userId,
postId: $postId,
metadata: $metadata,
);
}
/**
* Record a usage entry for an image-template generation. Templates do not
* call an LLM (composed via Unsplash + branding) so we charge zero credits.

View file

@ -0,0 +1,253 @@
<?php
declare(strict_types=1);
namespace App\Services\Brand;
/**
* Extracts the most representative non-neutral colour from a CSS blob by
* counting occurrences and clustering perceptually similar shades together
* in CIE LAB space. Solves the Tailwind/utility-CSS case where the brand
* colour appears hundreds of times as `bg-blue-700`/`border-blue-700`/etc
* but no semantic --primary variable is exposed.
*
* Pipeline:
* 1. Regex-extract every #hex/rgb()/rgba()/hsl()/hsla() value from the CSS
* 2. Normalise each to lowercase #rrggbb
* 3. Convert to LAB and cluster colours within `delta E (CIE76) < 12`
* 4. Drop clusters whose centre is neutral (low channel spread)
* 5. Return the centre of the largest remaining cluster
*
* CIE76 (Euclidean ΔE in LAB) is intentional — DE2000 is the print-grade
* gold standard but adds complexity for marginal accuracy at the "are
* these the same brand colour?" task we're solving here.
*/
final class CssColorFrequencyExtractor
{
private const int CLUSTER_THRESHOLD = 12;
private const int NEUTRAL_CHANNEL_DELTA = 18;
public function extract(string $css): ?string
{
$occurrences = $this->countOccurrences($css);
if ($occurrences === []) {
return null;
}
$clusters = $this->clusterPerceptually($occurrences);
// Drop clusters whose representative colour is neutral.
$clusters = array_values(array_filter(
$clusters,
fn (array $cluster): bool => ! $this->isNeutral($cluster['hex']),
));
if ($clusters === []) {
return null;
}
usort($clusters, fn (array $a, array $b): int => $b['count'] <=> $a['count']);
return $clusters[0]['hex'];
}
/**
* @return array<string, int> Hex colour => occurrence count.
*/
private function countOccurrences(string $css): array
{
$counts = [];
$patterns = [
'/#([0-9a-fA-F]{8})\b/' => fn (array $m): ?string => $this->normaliseHex(substr($m[1], 0, 6)),
'/#([0-9a-fA-F]{6})\b/' => fn (array $m): ?string => $this->normaliseHex($m[1]),
'/#([0-9a-fA-F]{3})\b/' => function (array $m): ?string {
$h = $m[1];
return $this->normaliseHex($h[0].$h[0].$h[1].$h[1].$h[2].$h[2]);
},
'/rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})/i' => fn (array $m): ?string => $this->rgbToHex((int) $m[1], (int) $m[2], (int) $m[3]),
'/hsla?\(\s*(\d{1,3}(?:\.\d+)?)\s*,?\s*(\d{1,3}(?:\.\d+)?)%\s*,?\s*(\d{1,3}(?:\.\d+)?)%/i' => fn (array $m): ?string => $this->hslToHex((float) $m[1], (float) $m[2], (float) $m[3]),
];
foreach ($patterns as $pattern => $convert) {
if (preg_match_all($pattern, $css, $matches, PREG_SET_ORDER) === false) {
continue;
}
foreach ($matches as $match) {
$hex = $convert($match);
if ($hex !== null) {
$counts[$hex] = ($counts[$hex] ?? 0) + 1;
}
}
}
return $counts;
}
/**
* Group colours by perceptual similarity (CIE76 ΔE) and sum their counts.
*
* @param array<string, int> $occurrences
* @return list<array{hex: string, count: int}>
*/
private function clusterPerceptually(array $occurrences): array
{
// Sort entries so the most frequent colour anchors each cluster.
arsort($occurrences);
$clusters = [];
foreach ($occurrences as $hex => $count) {
$lab = $this->hexToLab($hex);
$merged = false;
foreach ($clusters as &$cluster) {
if ($this->deltaE76($lab, $cluster['lab']) < self::CLUSTER_THRESHOLD) {
$cluster['count'] += $count;
$merged = true;
break;
}
}
unset($cluster);
if (! $merged) {
$clusters[] = ['hex' => $hex, 'lab' => $lab, 'count' => $count];
}
}
return array_map(
fn (array $c): array => ['hex' => $c['hex'], 'count' => $c['count']],
$clusters,
);
}
private function isNeutral(string $hex): bool
{
$hex = ltrim($hex, '#');
$r = (int) hexdec(substr($hex, 0, 2));
$g = (int) hexdec(substr($hex, 2, 2));
$b = (int) hexdec(substr($hex, 4, 2));
$maxDelta = max(abs($r - $g), abs($g - $b), abs($r - $b));
return $maxDelta < self::NEUTRAL_CHANNEL_DELTA;
}
private function normaliseHex(string $hex6): ?string
{
if (! ctype_xdigit($hex6) || strlen($hex6) !== 6) {
return null;
}
return '#'.strtolower($hex6);
}
private function rgbToHex(int $r, int $g, int $b): ?string
{
if ($r > 255 || $g > 255 || $b > 255) {
return null;
}
return sprintf('#%02x%02x%02x', $r, $g, $b);
}
/**
* @return string|null Hex colour.
*/
private function hslToHex(float $h, float $s, float $l): ?string
{
if ($h > 360 || $s > 100 || $l > 100) {
return null;
}
$h /= 360;
$s /= 100;
$l /= 100;
if ($s === 0.0) {
$r = $g = $b = $l;
} else {
$q = $l < 0.5 ? $l * (1 + $s) : $l + $s - $l * $s;
$p = 2 * $l - $q;
$r = $this->hueToRgb($p, $q, $h + 1 / 3);
$g = $this->hueToRgb($p, $q, $h);
$b = $this->hueToRgb($p, $q, $h - 1 / 3);
}
return $this->rgbToHex(
(int) round($r * 255),
(int) round($g * 255),
(int) round($b * 255),
);
}
private function hueToRgb(float $p, float $q, float $t): float
{
if ($t < 0) {
$t += 1;
}
if ($t > 1) {
$t -= 1;
}
if ($t < 1 / 6) {
return $p + ($q - $p) * 6 * $t;
}
if ($t < 1 / 2) {
return $q;
}
if ($t < 2 / 3) {
return $p + ($q - $p) * (2 / 3 - $t) * 6;
}
return $p;
}
/**
* Convert hex to CIE LAB. Goes via sRGB → XYZ (D65) → LAB.
*
* @return array{0: float, 1: float, 2: float}
*/
private function hexToLab(string $hex): array
{
$hex = ltrim($hex, '#');
$r = hexdec(substr($hex, 0, 2)) / 255;
$g = hexdec(substr($hex, 2, 2)) / 255;
$b = hexdec(substr($hex, 4, 2)) / 255;
// sRGB → linear RGB
$r = $r > 0.04045 ? (($r + 0.055) / 1.055) ** 2.4 : $r / 12.92;
$g = $g > 0.04045 ? (($g + 0.055) / 1.055) ** 2.4 : $g / 12.92;
$b = $b > 0.04045 ? (($b + 0.055) / 1.055) ** 2.4 : $b / 12.92;
// Linear RGB → XYZ (D65)
$x = ($r * 0.4124564 + $g * 0.3575761 + $b * 0.1804375) / 0.95047;
$y = ($r * 0.2126729 + $g * 0.7151522 + $b * 0.0721750) / 1.00000;
$z = ($r * 0.0193339 + $g * 0.1191920 + $b * 0.9503041) / 1.08883;
$f = fn (float $t): float => $t > 0.008856 ? $t ** (1 / 3) : (7.787 * $t) + 16 / 116;
$fx = $f($x);
$fy = $f($y);
$fz = $f($z);
return [
116 * $fy - 16,
500 * ($fx - $fy),
200 * ($fy - $fz),
];
}
/**
* @param array{0: float, 1: float, 2: float} $lab1
* @param array{0: float, 1: float, 2: float} $lab2
*/
private function deltaE76(array $lab1, array $lab2): float
{
return sqrt(
($lab1[0] - $lab2[0]) ** 2 +
($lab1[1] - $lab2[1]) ** 2 +
($lab1[2] - $lab2[2]) ** 2,
);
}
}

View file

@ -15,6 +15,10 @@ final class HomepageMetaExtractor
{
private const array TITLE_SEPARATORS = [' | ', ' - ', ' — ', ' – '];
public function __construct(
private readonly CssColorFrequencyExtractor $cssColorFrequency = new CssColorFrequencyExtractor,
) {}
public function extract(string $html, string $baseUrl, string $extraCss = ''): BrandMetadata
{
$crawler = new Crawler($html, $baseUrl);
@ -77,6 +81,14 @@ private function extractColors(Crawler $crawler, string $html, string $extraCss
$brand = $this->matchCssVar($css, ['primary', 'brand', 'brand-primary', 'accent', 'color-primary', 'main', 'theme']);
}
// Final fallback: scan all colour values in the collected CSS, cluster
// perceptually similar shades and pick the most frequent non-neutral
// cluster. Catches Tailwind/utility-CSS sites where no semantic var
// exposes the brand colour.
if ($brand === null || $this->normalizeHex($brand) === null) {
$brand = $this->cssColorFrequency->extract($css);
}
$background = $this->matchCssVar($css, ['background', 'bg', 'background-color', 'surface', 'color-bg', 'body-bg']);
$text = $this->matchCssVar($css, ['foreground', 'text', 'color-text', 'on-background', 'body-color']);

View file

@ -4,10 +4,11 @@
namespace App\Services\Image;
use App\Enums\Workspace\ImageStyle;
use App\Models\SocialAccount;
use App\Models\Workspace;
use App\Services\Ai\AiImageClient;
use App\Services\Ai\RecordAiUsage;
use App\Services\Unsplash\UnsplashClient;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Intervention\Image\Drivers\Gd\Driver;
@ -29,100 +30,19 @@ class TemplateImageGenerator
private int $height = self::DEFAULT_HEIGHT;
public function __construct(
private UnsplashClient $unsplash,
private BrandColorMapper $colorMapper,
private AiImageClient $aiImage,
) {}
/**
* Render a slide using Template A (full bleed) or Template B (photo card).
* Render a slide and return the storage path plus the source meta needed
* to regenerate it later. Returns null on failure (e.g. the AI client
* could not produce a usable image).
*
* @param array<int, string> $imageKeywords
* @return string|null The storage path, or null on failure.
* @return array{path: string, source_meta: array<string, mixed>}|null
*/
/**
* Render a closing/CTA slide (Template C) for the end of a carousel.
* Solid brand-colored background with centered avatar + display name +
* short divider + @handle.
*/
public function renderClosing(
Workspace $workspace,
SocialAccount $socialAccount,
int $width = self::DEFAULT_WIDTH,
int $height = self::DEFAULT_HEIGHT,
): ?string {
$this->width = $width;
$this->height = $height;
$bgColor = $workspace->background_color ?? '#0F172A';
$brandColor = $workspace->brand_color ?? '#D4AF37';
$textColor = $workspace->text_color ?? '#FFFFFF';
$manager = new ImageManager(Driver::class);
$canvas = $manager->createImage($this->width, $this->height)->fill($bgColor);
$core = $canvas->core()->native();
$fontBold = $this->fontPath('Inter-Bold.ttf');
$fontMedium = $this->fontPath('Inter-Medium.ttf');
// Centered avatar — size scales with the smaller canvas dimension.
$avatarSize = (int) round(min($this->width, $this->height) * 0.14);
$avatarX = (int) (($this->width - $avatarSize) / 2);
$avatarY = (int) ($this->height / 2 - $avatarSize - 10);
$avatarBinary = $this->fetchAvatarBinary($socialAccount);
if ($avatarBinary) {
$this->drawCircularAvatar($canvas, $avatarBinary, $avatarX, $avatarY, $avatarSize);
}
// Display name (bold uppercase, letter-spaced, centered).
$name = strtoupper((string) ($socialAccount->display_name ?? ''));
$nameSize = (int) round(min($this->width, $this->height) * 0.037);
$nameBaselineY = $avatarY + $avatarSize + 60 + (int) round($nameSize * 0.82);
if ($fontBold && $name !== '') {
$nameWidth = $this->measureLetterSpacedWidth($name, $fontBold, $nameSize, 6);
$nameX = (int) (($this->width - $nameWidth) / 2);
$this->drawTextAt($core, $name, $fontBold, $nameSize, $textColor, $nameX, $nameBaselineY, letterSpacing: 6);
}
// Short divider line under the name.
$smallDividerY = $nameBaselineY + 30;
$smallDividerWidth = 80;
$smallDividerX = (int) (($this->width - $smallDividerWidth) / 2);
$this->drawHorizontalLine($core, $smallDividerX, $smallDividerX + $smallDividerWidth, $smallDividerY, $brandColor, 1.0);
// @handle (lighter weight, lighter color, centered).
$handle = $socialAccount->username ? '@'.$socialAccount->username : '';
$handleSize = (int) round($nameSize * 0.65);
$handleBaselineY = $smallDividerY + 30 + (int) round($handleSize * 0.82);
if ($fontMedium && $handle !== '') {
// @handle uses text_color blended with the bg at 0.8 opacity —
// legible on any brand palette, slightly muted vs the display name.
$handleColor = $this->blendHex($textColor, $bgColor, 0.8);
$handleWidth = $this->measureLetterSpacedWidth($handle, $fontMedium, $handleSize, 1);
$handleX = (int) (($this->width - $handleWidth) / 2);
$this->drawTextAt($core, $handle, $fontMedium, $handleSize, $handleColor, $handleX, $handleBaselineY, letterSpacing: 1);
}
$filename = 'ai-images/'.uniqid('slide_', true).'.webp';
Storage::put($filename, (string) $canvas->encode(new WebpEncoder(quality: 85)));
RecordAiUsage::recordTemplate(
workspace: $workspace,
provider: 'internal',
metadata: [
'template' => 'C',
'width' => $this->width,
'height' => $this->height,
],
);
return $filename;
}
public function render(
string $template,
Workspace $workspace,
SocialAccount $socialAccount,
string $title,
@ -130,54 +50,79 @@ public function render(
array $imageKeywords,
int $width = self::DEFAULT_WIDTH,
int $height = self::DEFAULT_HEIGHT,
): ?string {
): ?array {
$this->width = $width;
$this->height = $height;
$colorBucket = $this->colorMapper->fromWorkspace($workspace);
$orientation = $this->unsplashOrientation();
$photo = $this->unsplash->searchPhoto($imageKeywords, $orientation, $colorBucket);
if (! $photo) {
return null;
}
$imageData = @file_get_contents($photo['url']);
if (! $imageData) {
Log::warning('TemplateImageGenerator: failed to download Unsplash photo', ['url' => $photo['url']]);
$orientation = $this->orientationForCanvas();
$rawStyle = $workspace->image_style;
$imageStyle = match (true) {
$rawStyle instanceof ImageStyle => $rawStyle,
is_string($rawStyle) => ImageStyle::tryFrom($rawStyle) ?? ImageStyle::DEFAULT,
default => ImageStyle::DEFAULT,
};
$language = $workspace->content_language;
$imageData = $this->aiImage->generate(
keywords: $imageKeywords,
style: $imageStyle,
orientation: $orientation,
language: $language,
brandColor: $workspace->brand_color,
brandDescription: $workspace->brand_description,
);
if ($imageData === null) {
return null;
}
$manager = new ImageManager(Driver::class);
$canvas = $template === 'A'
? $this->renderTemplateA($manager, $imageData, $title, $body)
: $this->renderTemplateB($manager, $imageData, $title, $body, $workspace);
$canvas = $this->renderFooter($canvas, $socialAccount, $template, $workspace);
$canvas = $this->renderTemplateA($manager, $imageData, $title, $body);
$canvas = $this->renderFooter($canvas, $socialAccount);
$filename = 'ai-images/'.uniqid('slide_', true).'.webp';
Storage::put($filename, (string) $canvas->encode(new WebpEncoder(quality: 85)));
RecordAiUsage::recordTemplate(
RecordAiUsage::recordImage(
workspace: $workspace,
provider: 'internal',
provider: 'openai',
model: AiImageClient::MODEL,
metadata: [
'template' => $template,
'image_style' => $imageStyle->value,
'width' => $this->width,
'height' => $this->height,
],
);
return $filename;
RecordAiUsage::recordTemplate(
workspace: $workspace,
provider: 'internal',
metadata: [
'width' => $this->width,
'height' => $this->height,
],
);
return [
'path' => $filename,
'source_meta' => [
'keywords' => array_values($imageKeywords),
'style' => $imageStyle->value,
'language' => $language,
'model' => AiImageClient::MODEL,
'title' => $title,
'body' => $body,
'width' => $this->width,
'height' => $this->height,
],
];
}
/**
* Pick the closest Unsplash orientation for the active canvas. Avoids
* stretching/cropping a portrait photo into a landscape canvas.
* Pick the closest aspect ratio for the active canvas so the AI image
* generator returns a photo that doesn't need heavy cropping.
*/
private function unsplashOrientation(): string
private function orientationForCanvas(): string
{
$ratio = $this->width / $this->height;
if ($ratio > 1.1) {
@ -309,26 +254,6 @@ private function renderTextLines($core, array $lines, string $fontPath, int $fon
}
}
/**
* Same as renderTextLines but each line is horizontally centered within the
* canvas based on its measured glyph width.
*/
private function renderTextLinesCentered($core, array $lines, string $fontPath, int $fontSize, float $lineHeight, string $hexColor, int $topY): void
{
$color = $this->allocateColor($core, $hexColor);
$lineSpacing = (int) round($fontSize * $lineHeight);
$ascent = (int) round($fontSize * 0.82);
$baselineY = $topY + $ascent;
foreach ($lines as $line) {
$bbox = imagettfbbox($fontSize, 0, $fontPath, $line);
$lineWidth = abs($bbox[2] - $bbox[0]);
$x = (int) round(($this->width - $lineWidth) / 2);
imagettftext($core, $fontSize, 0, $x, $baselineY, $color, $fontPath, $line);
$baselineY += $lineSpacing;
}
}
/**
* Allocate a GD color from a hex string (#rrggbb).
*
@ -343,23 +268,6 @@ private function allocateColor($core, string $hex): int
return $color === false ? imagecolorallocate($core, 255, 255, 255) : $color;
}
/**
* Linearly blend two hex colors. $weight is the share of $foreground in the
* mix (0.0 = pure background, 1.0 = pure foreground).
*/
private function blendHex(string $foreground, string $background, float $weight): string
{
[$fr, $fg, $fb] = $this->hexToRgb($foreground);
[$br, $bg, $bb] = $this->hexToRgb($background);
$w = max(0.0, min(1.0, $weight));
$r = (int) round($fr * $w + $br * (1 - $w));
$g = (int) round($fg * $w + $bg * (1 - $w));
$b = (int) round($fb * $w + $bb * (1 - $w));
return sprintf('#%02x%02x%02x', $r, $g, $b);
}
/**
* @return array{0: int, 1: int, 2: int}
*/
@ -403,103 +311,11 @@ private function applyBottomGradient(ImageInterface $image, float $heightFractio
}
}
/**
* Punch transparent corners into the image's alpha channel so it renders as
* a rounded rectangle when copied onto another canvas.
*/
private function roundCorners(ImageInterface $image, int $radius): void
private function renderFooter(ImageInterface $canvas, SocialAccount $socialAccount): ImageInterface
{
$core = $image->core()->native();
$w = imagesx($core);
$h = imagesy($core);
imagealphablending($core, false);
imagesavealpha($core, true);
$transparent = imagecolorallocatealpha($core, 0, 0, 0, 127);
$corners = [
['x0' => 0, 'y0' => 0, 'cx' => $radius, 'cy' => $radius],
['x0' => $w - $radius, 'y0' => 0, 'cx' => $w - $radius - 1, 'cy' => $radius],
['x0' => 0, 'y0' => $h - $radius, 'cx' => $radius, 'cy' => $h - $radius - 1],
['x0' => $w - $radius, 'y0' => $h - $radius, 'cx' => $w - $radius - 1, 'cy' => $h - $radius - 1],
];
foreach ($corners as $c) {
for ($y = $c['y0']; $y < $c['y0'] + $radius; $y++) {
for ($x = $c['x0']; $x < $c['x0'] + $radius; $x++) {
$dx = $x - $c['cx'];
$dy = $y - $c['cy'];
if ($dx * $dx + $dy * $dy > $radius * $radius) {
imagesetpixel($core, $x, $y, $transparent);
}
}
}
}
imagealphablending($core, true);
}
private function renderTemplateB(ImageManager $manager, string $imageData, string $title, string $body, Workspace $workspace): ImageInterface
{
$bgColor = $workspace->background_color ?? '#F0F4F0';
$brandColor = $workspace->brand_color ?? '#0F4C2A';
$textColor = $workspace->text_color ?? '#0F172A';
// Solid background canvas at active dimensions.
$canvas = $manager->createImage($this->width, $this->height)->fill($bgColor);
$fontBold = $this->fontPath('Inter-Bold.ttf');
$fontMedium = $this->fontPath('Inter-Medium.ttf');
$titleSize = 56;
$bodySize = 28;
$titleLineHeight = 1.25;
$bodyLineHeight = 1.55;
$padding = 60;
$maxWidth = $this->width - 2 * $padding;
$photoWidth = $this->width - 2 * $padding;
// Photo card height scales with the canvas: ~37% of total height. This
// keeps a comfortable text/photo balance across 1:1, 4:5 and 9:16 sizes.
$photoHeight = (int) round($this->height * 0.37);
$titleTopY = (int) round($this->height * 0.09);
$gapTitleToPhoto = 60;
$gapPhotoToBody = 60;
$core = $canvas->core()->native();
$titleLines = ($fontBold && file_exists($fontBold)) ? $this->wrapText($title, $fontBold, $titleSize, $maxWidth) : [];
$bodyLines = ($fontMedium && file_exists($fontMedium)) ? $this->wrapText($body, $fontMedium, $bodySize, $maxWidth) : [];
$titleHeight = $this->measureBlockHeight($titleLines, $titleSize, $titleLineHeight);
$photoY = $titleTopY + $titleHeight + $gapTitleToPhoto;
$bodyTopY = $photoY + $photoHeight + $gapPhotoToBody;
$photoX = (int) (($this->width - $photoWidth) / 2);
if ($titleLines) {
$this->renderTextLines($core, $titleLines, $fontBold, $titleSize, $titleLineHeight, $brandColor, $padding, $titleTopY);
}
// Photo card with rounded corners, horizontally centered.
$photo = $manager->decodeBinary($imageData)->cover($photoWidth, $photoHeight);
$this->roundCorners($photo, 20);
$canvas->insert($photo, $photoX, $photoY);
if ($bodyLines) {
$this->renderTextLines($core, $bodyLines, $fontMedium, $bodySize, $bodyLineHeight, $textColor, $padding, $bodyTopY);
}
return $canvas;
}
private function renderFooter(ImageInterface $canvas, SocialAccount $socialAccount, string $template, Workspace $workspace): ImageInterface
{
// Footer uses Inter Light (300) in a muted color for both @handle and display_name.
// Template A: hardcoded slate (always on dark gradient).
// Template B: blend the brand's text color with its background so the footer is
// always legibly muted regardless of which palette the workspace picked.
$footerColor = $template === 'A'
? '#9ca3af'
: $this->blendHex($workspace->text_color ?? '#0F172A', $workspace->background_color ?? '#F0F4F0', 0.45);
// Footer uses Inter Light (300) in slate-grey — always legible on top
// of the bottom dark gradient applied by Template A.
$footerColor = '#9ca3af';
$username = $socialAccount->username ?? '';
$displayName = $socialAccount->display_name ?? '';
@ -688,18 +504,6 @@ private function drawHorizontalLine($core, int $x1, int $x2, int $y, string $hex
imagecolordeallocate($core, $color);
}
/**
* Localized "Follow me" CTA based on the workspace's content_language.
*/
private function followCta(Workspace $workspace): string
{
return match ($workspace->content_language) {
'pt-BR' => 'ME SIGA',
'es' => 'SÍGUEME',
default => 'FOLLOW ME',
};
}
private function fontPath(string $filename): ?string
{
$path = base_path('resources/fonts/'.$filename);

View file

@ -10,12 +10,6 @@
class PostHogService
{
/**
* Whether PostHog tracking should run at all. Controlled by the explicit
* `POSTHOG_ENABLED` env var so self-hosted installs that inherited an
* example `POSTHOG_API_KEY` from a config snippet do not silently send
* events upstream. The SaaS deployment opts in via `POSTHOG_ENABLED=true`.
*/
public static function isEnabled(): bool
{
return (bool) config('services.posthog.enabled')
@ -23,10 +17,6 @@ public static function isEnabled(): bool
}
/**
* Capture an event for the given distinct id. When `$account` is supplied,
* the workspace/plan group is auto-attached so the event is filterable in
* PostHog by `$groups.account` and the `account_id` / `plan` properties.
*
* @param array<string, mixed> $properties
*/
public function capture(string $distinctId, string $event, array $properties = [], ?Account $account = null): void

View file

@ -1,152 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\Unsplash;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class UnsplashClient
{
/**
* Generic, always-available fallback keywords. We try these last so a slide
* never ends up without a photo.
*/
private const array FALLBACK_KEYWORDS = ['business', 'workspace', 'abstract', 'background'];
/**
* Search a single photo with progressive fallbacks so a slide never returns
* without a photo:
* 1. all keywords + color filter
* 2. all keywords, no color
* 3. only the first keyword, no color
* 4. each generic fallback keyword in turn
*
* @param array<int, string> $keywords
* @return array{id: string, url: string, alt_description: ?string}|null
*/
public function searchPhoto(array $keywords, string $orientation = 'portrait', ?string $colorBucket = null): ?array
{
$key = config('services.unsplash.access_key');
if (! $key) {
Log::warning('Unsplash access key not configured');
return null;
}
$cleanKeywords = array_values(array_filter(array_map('trim', $keywords)));
if (empty($cleanKeywords)) {
return $this->tryFallbacks([], $orientation, $key);
}
$query = implode(' ', $cleanKeywords);
// 1. all keywords + color
$photo = $this->fetchOne($key, $query, $orientation, $colorBucket);
if ($photo) {
return $photo;
}
// 2. all keywords, no color
if ($colorBucket !== null) {
$photo = $this->fetchOne($key, $query, $orientation, null);
if ($photo) {
return $photo;
}
}
// 3. only the first keyword
if (count($cleanKeywords) > 1) {
$photo = $this->fetchOne($key, $cleanKeywords[0], $orientation, null);
if ($photo) {
return $photo;
}
}
// 4. generic fallbacks
return $this->tryFallbacks($cleanKeywords, $orientation, $key);
}
/**
* @return array{id: string, url: string, alt_description: ?string}|null
*/
private function fetchOne(string $key, string $query, string $orientation, ?string $colorBucket): ?array
{
if ($query === '') {
return null;
}
$params = [
'query' => $query,
'orientation' => $orientation,
'per_page' => 1,
];
if ($colorBucket) {
$params['color'] = $colorBucket;
}
$cacheKey = 'unsplash:'.md5(json_encode($params));
$result = Cache::get($cacheKey);
if ($result === null) {
$response = Http::withHeaders(['Authorization' => 'Client-ID '.$key])
->timeout(10)
->get('https://api.unsplash.com/search/photos', $params);
if (! $response->successful()) {
Log::warning('Unsplash search failed', ['status' => $response->status(), 'query' => $query]);
return null;
}
$result = $response->json();
// Only cache successful responses that actually returned results, so
// a transient empty hit doesn't get pinned for an hour.
if (! empty(data_get($result, 'results'))) {
Cache::put($cacheKey, $result, now()->addHour());
}
}
$first = data_get($result, 'results.0');
if (! $first) {
return null;
}
return [
'id' => data_get($first, 'id'),
'url' => data_get($first, 'urls.regular'),
'alt_description' => data_get($first, 'alt_description'),
];
}
/**
* Try each generic fallback keyword in turn.
*
* @param array<int, string> $skip fallback keywords to skip (already tried)
* @return array{id: string, url: string, alt_description: ?string}|null
*/
private function tryFallbacks(array $skip, string $orientation, string $key): ?array
{
foreach (self::FALLBACK_KEYWORDS as $keyword) {
if (in_array($keyword, $skip, true)) {
continue;
}
$photo = $this->fetchOne($key, $keyword, $orientation, null);
if ($photo) {
Log::info('Unsplash fell back to generic keyword', ['keyword' => $keyword]);
return $photo;
}
}
Log::warning('Unsplash exhausted all fallbacks');
return null;
}
}

View file

@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace App\Support;
/**
* Maps a hex colour to a human-readable approximate name. Image generation
* models follow colour names ("warm orange", "deep teal") far more reliably
* than raw hex codes, so we translate the brand colour into the closest
* named bucket before injecting it into the prompt.
*
* Returns null when the hex is malformed.
*/
class HexColorName
{
public static function approximate(string $hex): ?string
{
$hex = ltrim(trim($hex), '#');
if (strlen($hex) === 3) {
$hex = $hex[0].$hex[0].$hex[1].$hex[1].$hex[2].$hex[2];
}
if (strlen($hex) === 8) {
$hex = substr($hex, 0, 6);
}
if (strlen($hex) !== 6 || ! ctype_xdigit($hex)) {
return null;
}
$r = hexdec(substr($hex, 0, 2)) / 255;
$g = hexdec(substr($hex, 2, 2)) / 255;
$b = hexdec(substr($hex, 4, 2)) / 255;
[$h, $s, $l] = self::rgbToHsl($r, $g, $b);
// Low-saturation neutrals.
if ($s < 0.10) {
return match (true) {
$l < 0.10 => 'near-black',
$l < 0.30 => 'dark gray',
$l < 0.70 => 'medium gray',
$l < 0.90 => 'light gray',
default => 'off-white',
};
}
$hue = $h * 360;
$base = match (true) {
$hue < 10 || $hue >= 345 => 'red',
$hue < 25 => 'red-orange',
$hue < 45 => 'warm orange',
$hue < 65 => 'golden yellow',
$hue < 90 => 'yellow-green',
$hue < 150 => 'green',
$hue < 180 => 'teal',
$hue < 210 => 'cyan',
$hue < 240 => 'blue',
$hue < 270 => 'indigo',
$hue < 300 => 'purple',
$hue < 345 => 'magenta',
default => 'red',
};
$modifier = match (true) {
$l < 0.25 => 'deep ',
$l > 0.75 => 'light ',
default => '',
};
return $modifier.$base;
}
/**
* @return array{0: float, 1: float, 2: float} HSL components on a 0..1 scale.
*/
private static function rgbToHsl(float $r, float $g, float $b): array
{
$max = max($r, $g, $b);
$min = min($r, $g, $b);
$l = ($max + $min) / 2;
if ($max === $min) {
return [0.0, 0.0, $l];
}
$d = $max - $min;
$s = $l > 0.5 ? $d / (2 - $max - $min) : $d / ($max + $min);
$h = match (true) {
$max === $r => ($g - $b) / $d + ($g < $b ? 6 : 0),
$max === $g => ($b - $r) / $d + 2,
default => ($r - $g) / $d + 4,
};
$h /= 6;
return [$h, $s, $l];
}
}

2
composer.lock generated
View file

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "3d3e5acee3f0e8b5df1dcfab91a2c6c0",
"content-hash": "4d71722b650e74b41e1943d3ae9c814f",
"packages": [
{
"name": "aws/aws-crt-php",

View file

@ -27,6 +27,9 @@
'default' => 50,
'gpt-image-1.5' => 50,
'gpt-image-1.5-hd' => 100,
// gpt-image-2 is currently called at quality=low (~$0.01/image at 1024².
// If we ever expose medium/high to users, add gpt-image-2-medium / -hd.
'gpt-image-2' => 15,
'flux-pro' => 30,
],

View file

@ -214,7 +214,7 @@
'defaults' => [
'supervisor-1' => [
'connection' => 'redis',
'queue' => ['default', 'posthog'],
'queue' => ['default', 'posthog', 'broadcasts'],
'balance' => 'auto',
'autoScalingStrategy' => 'time',
'maxProcesses' => 1,

View file

@ -24,6 +24,10 @@ public function definition(): array
return [
'user_id' => User::factory(),
'name' => fake()->company(),
'content_language' => 'en',
'brand_tone' => 'professional',
'brand_font' => 'Inter',
'image_style' => 'cinematic',
];
}

View file

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
use App\Enums\Workspace\ImageStyle;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('workspaces', function (Blueprint $table): void {
$table->string('image_style')
->default(ImageStyle::DEFAULT->value)
->after('brand_font');
});
}
public function down(): void
{
Schema::table('workspaces', function (Blueprint $table): void {
$table->dropColumn('image_style');
});
}
};

View file

@ -8,6 +8,7 @@
'type' => 'Type',
'to_confirm' => 'to confirm.',
'copy_to_clipboard' => 'Copy to clipboard',
'delete_keyword' => 'delete',
],
'photo_upload' => [

10
lang/en/notifications.php Normal file
View file

@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
return [
'post_ready' => [
'title' => 'Your post is ready',
'body' => 'The AI just finished. Tap to review and publish.',
],
];

View file

@ -243,8 +243,6 @@
'title' => 'No platform selected',
'description' => 'Select a platform to publish to see the preview.',
],
'drag_drop' => 'Drop files to upload',
'drag_drop_hint' => 'Drag & drop files here, or use the buttons above',
'drop_zone_title' => 'Add media',
'drop_zone_subtitle' => 'Drag & drop files or click to browse',
'add' => 'Add',
@ -501,9 +499,21 @@
'prompt_title' => 'Describe your post',
'prompt_label' => 'What is this post about?',
'prompt_placeholder' => 'e.g. Announce our new carousel feature for Instagram',
'preview_title' => 'Preview',
'preview_loading' => 'Generating your content…',
'preview_error' => 'Something went wrong. Please try again.',
'loading_page_title' => 'Generating your post',
'loading_eta' => 'Estimated time: about :minutes.',
'loading_eta_minute_one' => '1 minute',
'loading_eta_minute_other' => ':count minutes',
'loading_leave_title' => 'You can keep working.',
'loading_leave_body' => 'We will notify you when the post is ready.',
'loading_leave_cta' => 'Go to calendar',
'loading_create_another_cta' => 'Create another post',
'loading_tip_credits' => 'Each AI image uses about 15 credits.',
'loading_tip_edit' => 'You will be able to edit everything once the post is ready.',
'loading_tip_draft' => 'Generated posts land in your drafts.',
'loading_tip_brand' => 'Tweak your brand settings to influence future posts.',
'loading_tip_carousel' => 'Carousels deliver one slide per uploaded image.',
'loading_tip_quality' => 'Image quality is set to balance speed and cost.',
'create' => 'Create post',
'back' => 'Back',
'next' => 'Continue',

View file

@ -145,6 +145,8 @@
'brand' => [
'title' => 'Brand',
'description' => 'Configure your brand identity for AI-generated content.',
'name' => 'Workspace name',
'name_placeholder' => 'My brand',
'website' => 'Website',
'website_placeholder' => 'https://yourbrand.com',
'brand_description' => 'Description',
@ -163,6 +165,16 @@
'background_color' => 'Background color',
'text_color' => 'Text color',
'font' => 'Font',
'image_style' => 'Image style',
'image_style_description' => 'Visual style applied when generating slide and cover images for AI posts.',
'image_style_cinematic' => 'Cinematic',
'image_style_illustration' => 'Illustration',
'image_style_isometric_3d' => 'Isometric',
'image_style_cartoon' => 'Cartoon',
'image_style_typographic' => 'Typographic',
'image_style_infographic' => 'Infographic',
'image_style_minimalist' => 'Minimalist',
'image_style_mockup' => 'Mockup',
'content_language' => 'Content language',
'content_language_description' => 'Language used for AI-generated captions, hashtags, and any text inside generated images or videos.',
],

View file

@ -8,6 +8,7 @@
'type' => 'Escribe',
'to_confirm' => 'para confirmar.',
'copy_to_clipboard' => 'Copiar al portapapeles',
'delete_keyword' => 'eliminar',
],
'photo_upload' => [

10
lang/es/notifications.php Normal file
View file

@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
return [
'post_ready' => [
'title' => 'Tu publicación está lista',
'body' => 'La IA terminó. Toca para revisar y publicar.',
],
];

View file

@ -238,8 +238,6 @@
'title' => 'Ninguna plataforma seleccionada',
'description' => 'Selecciona una plataforma para publicar y ver la vista previa.',
],
'drag_drop' => 'Suelta los archivos para subir',
'drag_drop_hint' => 'Arrastra archivos aquí o usa los botones de arriba',
'drop_zone_title' => 'Añadir media',
'drop_zone_subtitle' => 'Arrastra archivos o haz clic para seleccionar',
'add' => 'Añadir',
@ -502,9 +500,21 @@
'prompt_title' => 'Describe tu post',
'prompt_label' => '¿De qué trata este post?',
'prompt_placeholder' => 'Ej. Anuncia nuestra nueva función de carrusel para Instagram',
'preview_title' => 'Vista previa',
'preview_loading' => 'Generando tu contenido…',
'preview_error' => 'Algo salió mal. Por favor, inténtalo de nuevo.',
'loading_page_title' => 'Generando tu publicación',
'loading_eta' => 'Tiempo estimado: cerca de :minutes.',
'loading_eta_minute_one' => '1 minuto',
'loading_eta_minute_other' => ':count minutos',
'loading_leave_title' => 'Puedes seguir trabajando.',
'loading_leave_body' => 'Te avisamos cuando la publicación esté lista.',
'loading_leave_cta' => 'Ir al calendario',
'loading_create_another_cta' => 'Crear otra publicación',
'loading_tip_credits' => 'Cada imagen IA usa unos 15 créditos.',
'loading_tip_edit' => 'Podrás editar todo cuando la publicación esté lista.',
'loading_tip_draft' => 'Las publicaciones generadas van directo a tus borradores.',
'loading_tip_brand' => 'Ajusta tu marca para influir en las próximas publicaciones.',
'loading_tip_carousel' => 'Los carruseles generan una diapositiva por imagen solicitada.',
'loading_tip_quality' => 'La calidad balancea velocidad y costo.',
'create' => 'Crear post',
'back' => 'Atrás',
'next' => 'Continuar',

View file

@ -145,6 +145,8 @@
'brand' => [
'title' => 'Marca',
'description' => 'Configura la identidad de tu marca para el contenido generado por IA.',
'name' => 'Nombre del workspace',
'name_placeholder' => 'Mi marca',
'website' => 'Sitio web',
'website_placeholder' => 'https://tumarca.com',
'brand_description' => 'Descripción',
@ -163,6 +165,16 @@
'background_color' => 'Color de fondo',
'text_color' => 'Color de texto',
'font' => 'Fuente',
'image_style' => 'Estilo de imágenes',
'image_style_description' => 'Estilo visual aplicado al generar imágenes de diapositivas y portadas para publicaciones con IA.',
'image_style_cinematic' => 'Cinematográfico',
'image_style_illustration' => 'Ilustración',
'image_style_isometric_3d' => 'Isométrico',
'image_style_cartoon' => 'Cartoon',
'image_style_typographic' => 'Tipográfico',
'image_style_infographic' => 'Infográfico',
'image_style_minimalist' => 'Minimalista',
'image_style_mockup' => 'Mockup',
'content_language' => 'Idioma del contenido',
'content_language_description' => 'Idioma usado en los subtítulos, hashtags y cualquier texto dentro de imágenes o videos generados por IA.',
],

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

@ -8,6 +8,7 @@
'type' => 'Digite',
'to_confirm' => 'para confirmar.',
'copy_to_clipboard' => 'Copiar para a área de transferência',
'delete_keyword' => 'deletar',
],
'photo_upload' => [

View file

@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
return [
'post_ready' => [
'title' => 'Seu post está pronto',
'body' => 'A AI terminou. Toque pra revisar e publicar.',
],
];

View file

@ -238,8 +238,6 @@
'title' => 'Nenhuma plataforma selecionada',
'description' => 'Selecione uma plataforma para publicar e ver o preview.',
],
'drag_drop' => 'Solte os arquivos para enviar',
'drag_drop_hint' => 'Arraste arquivos aqui ou use os botões acima',
'drop_zone_title' => 'Adicionar mídia',
'drop_zone_subtitle' => 'Arraste arquivos ou clique para selecionar',
'add' => 'Adicionar',
@ -501,9 +499,21 @@
'prompt_title' => 'Descreva seu post',
'prompt_label' => 'Sobre o que é este post?',
'prompt_placeholder' => 'Ex. Anunciar nossa nova função de carrossel para o Instagram',
'preview_title' => 'Prévia',
'preview_loading' => 'Gerando seu conteúdo…',
'preview_error' => 'Algo deu errado. Por favor, tente novamente.',
'loading_page_title' => 'Gerando seu post',
'loading_eta' => 'Tempo estimado: cerca de :minutes.',
'loading_eta_minute_one' => '1 minuto',
'loading_eta_minute_other' => ':count minutos',
'loading_leave_title' => 'Você pode continuar trabalhando.',
'loading_leave_body' => 'A gente te avisa assim que o post ficar pronto.',
'loading_leave_cta' => 'Ir pro calendário',
'loading_create_another_cta' => 'Criar outro post',
'loading_tip_credits' => 'Cada imagem AI consome cerca de 15 créditos.',
'loading_tip_edit' => 'Você poderá editar tudo quando o post ficar pronto.',
'loading_tip_draft' => 'Posts gerados vão direto pros seus rascunhos.',
'loading_tip_brand' => 'Ajuste sua marca pra influenciar os próximos posts.',
'loading_tip_carousel' => 'Carrosséis geram um slide por imagem solicitada.',
'loading_tip_quality' => 'A qualidade equilibra velocidade e custo.',
'create' => 'Criar post',
'back' => 'Voltar',
'next' => 'Continuar',

View file

@ -145,6 +145,8 @@
'brand' => [
'title' => 'Marca',
'description' => 'Configure a identidade da sua marca para os conteúdos gerados por AI.',
'name' => 'Nome do workspace',
'name_placeholder' => 'Minha marca',
'website' => 'Site',
'website_placeholder' => 'https://suamarca.com',
'brand_description' => 'Descrição',
@ -163,6 +165,16 @@
'background_color' => 'Cor de fundo',
'text_color' => 'Cor do texto',
'font' => 'Fonte',
'image_style' => 'Estilo das imagens',
'image_style_description' => 'Estilo visual aplicado ao gerar imagens de slides e capas para posts com AI.',
'image_style_cinematic' => 'Cinematográfico',
'image_style_illustration' => 'Ilustração',
'image_style_isometric_3d' => 'Isométrico',
'image_style_cartoon' => 'Cartoon',
'image_style_typographic' => 'Tipográfico',
'image_style_infographic' => 'Infográfico',
'image_style_minimalist' => 'Minimalista',
'image_style_mockup' => 'Mockup',
'content_language' => 'Idioma do conteúdo',
'content_language_description' => 'Idioma usado nas legendas, hashtags e em qualquer texto dentro de imagens ou vídeos gerados por AI.',
],

6
package-lock.json generated
View file

@ -8603,9 +8603,9 @@
"license": "MIT"
},
"node_modules/semver": {
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"dev": true,
"license": "ISC",
"bin": {

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View file

@ -0,0 +1,292 @@
<script setup lang="ts">
/* eslint-disable vue/no-mutating-props */
import { useHttp } from '@inertiajs/vue3';
import { IconCheck, IconLoader2, IconSparkles } from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import { computed, ref } from 'vue';
import { toast } from 'vue-sonner';
import FontPicker from '@/components/FontPicker.vue';
import HexColorInput from '@/components/HexColorInput.vue';
import InputError from '@/components/InputError.vue';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { autofill as autofillBrand } from '@/routes/app/workspaces';
interface BrandFields {
name?: string;
brand_website: string;
brand_description: string;
brand_tone: string;
brand_voice_notes: string;
brand_color: string | null;
background_color: string | null;
text_color: string | null;
brand_font: string;
image_style: string;
content_language: string;
logo_url?: string | null;
}
interface AutofillResponse {
name: string | null;
brand_description: string | null;
content_language: string | null;
brand_tone: string | null;
brand_voice_notes: string | null;
brand_color: string | null;
background_color: string | null;
text_color: string | null;
logo_url: string | null;
}
const props = withDefaults(
defineProps<{
fields: BrandFields;
errors: Partial<Record<keyof BrandFields, string>>;
availableFonts: string[];
availableImageStyles: string[];
autofill?: boolean;
showName?: boolean;
}>(),
{
autofill: false,
showName: false,
},
);
const autofillHttp = useHttp<{ url: string }, AutofillResponse>({ url: '' });
const isAutofilling = ref(false);
const logoPreview = ref<string | null>(null);
const toneLabel = computed(() =>
props.fields.brand_tone ? trans(`settings.brand.tone_${props.fields.brand_tone}`) : '',
);
const languageLabel = computed(() => {
const map: Record<string, string> = {
en: 'English',
'pt-BR': 'Português (Brasil)',
es: 'Español',
};
return map[props.fields.content_language] ?? '';
});
const runAutofill = async () => {
const url = props.fields.brand_website?.trim() ?? '';
if (!url) {
toast.error(trans('workspaces.create.autofill_missing_url'));
return;
}
isAutofilling.value = true;
try {
autofillHttp.url = url;
const data = await autofillHttp.post(autofillBrand.url());
if (data?.name && props.showName && !props.fields.name) props.fields.name = data.name;
if (data?.brand_description) props.fields.brand_description = data.brand_description;
if (data?.content_language) props.fields.content_language = data.content_language;
if (data?.brand_tone) props.fields.brand_tone = data.brand_tone;
if (data?.brand_voice_notes) props.fields.brand_voice_notes = data.brand_voice_notes;
if (data?.brand_color) props.fields.brand_color = data.brand_color;
if (data?.background_color) props.fields.background_color = data.background_color;
if (data?.text_color) props.fields.text_color = data.text_color;
if (data?.logo_url) {
logoPreview.value = data.logo_url;
if ('logo_url' in props.fields) {
props.fields.logo_url = data.logo_url;
}
}
toast.success(trans('workspaces.create.autofill_success'));
} catch {
toast.error(trans('workspaces.create.autofill_error'));
} finally {
isAutofilling.value = false;
}
};
</script>
<template>
<div class="flex flex-col space-y-6">
<div class="grid gap-2">
<Label for="brand_website">{{ $t('settings.brand.website') }}</Label>
<div :class="autofill ? 'flex gap-2' : ''">
<Input
id="brand_website"
v-model="fields.brand_website"
type="url"
:placeholder="$t('settings.brand.website_placeholder')"
:class="autofill ? 'flex-1' : ''"
/>
<Button
v-if="autofill"
type="button"
variant="default"
:disabled="isAutofilling || !fields.brand_website"
@click="runAutofill"
>
<IconLoader2 v-if="isAutofilling" class="size-4 animate-spin" />
<IconSparkles v-else class="size-4" />
{{ $t('workspaces.create.autofill') }}
</Button>
</div>
<p v-if="autofill && logoPreview" class="flex items-center gap-2 text-xs text-muted-foreground">
<img :src="logoPreview" alt="" class="h-6 w-6 rounded object-cover" />
{{ $t('workspaces.create.logo_captured') }}
</p>
<InputError :message="errors.brand_website" />
</div>
<div v-if="showName" class="grid gap-2">
<Label for="name">{{ $t('settings.brand.name') }}</Label>
<Input id="name" v-model="fields.name" :placeholder="$t('settings.brand.name_placeholder')" />
<InputError :message="errors.name" />
</div>
<div class="grid gap-2">
<Label for="brand_description">{{ $t('settings.brand.brand_description') }}</Label>
<Textarea
id="brand_description"
v-model="fields.brand_description"
:placeholder="$t('settings.brand.brand_description_placeholder')"
rows="3"
/>
<InputError :message="errors.brand_description" />
</div>
<div class="grid gap-4 sm:grid-cols-2">
<div class="grid gap-2">
<Label for="brand_tone">{{ $t('settings.brand.tone') }}</Label>
<Select v-model="fields.brand_tone">
<SelectTrigger id="brand_tone" class="w-full">
<SelectValue :placeholder="$t('settings.brand.tone')">
{{ toneLabel }}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="professional">{{ $t('settings.brand.tone_professional') }}</SelectItem>
<SelectItem value="casual">{{ $t('settings.brand.tone_casual') }}</SelectItem>
<SelectItem value="friendly">{{ $t('settings.brand.tone_friendly') }}</SelectItem>
<SelectItem value="bold">{{ $t('settings.brand.tone_bold') }}</SelectItem>
<SelectItem value="inspirational">{{ $t('settings.brand.tone_inspirational') }}</SelectItem>
<SelectItem value="humorous">{{ $t('settings.brand.tone_humorous') }}</SelectItem>
<SelectItem value="educational">{{ $t('settings.brand.tone_educational') }}</SelectItem>
</SelectContent>
</Select>
<InputError :message="errors.brand_tone" />
</div>
<div class="grid gap-2">
<Label for="content_language">{{ $t('settings.brand.content_language') }}</Label>
<Select v-model="fields.content_language">
<SelectTrigger id="content_language" class="w-full">
<SelectValue :placeholder="$t('settings.brand.content_language')">
{{ languageLabel }}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="en">English</SelectItem>
<SelectItem value="pt-BR">Português (Brasil)</SelectItem>
<SelectItem value="es">Español</SelectItem>
</SelectContent>
</Select>
<InputError :message="errors.content_language" />
</div>
</div>
<p class="-mt-4 text-xs font-medium text-foreground/60">
{{ $t('settings.brand.content_language_description') }}
</p>
<div class="grid gap-2">
<Label for="brand_voice_notes">{{ $t('settings.brand.voice_notes') }}</Label>
<Textarea
id="brand_voice_notes"
v-model="fields.brand_voice_notes"
:placeholder="$t('settings.brand.voice_notes_placeholder')"
rows="3"
/>
<InputError :message="errors.brand_voice_notes" />
</div>
<div class="grid gap-4 sm:grid-cols-3">
<div class="grid gap-2">
<Label for="brand_color">{{ $t('settings.brand.brand_color') }}</Label>
<HexColorInput v-model="fields.brand_color" />
<InputError :message="errors.brand_color" />
</div>
<div class="grid gap-2">
<Label for="background_color">{{ $t('settings.brand.background_color') }}</Label>
<HexColorInput v-model="fields.background_color" />
<InputError :message="errors.background_color" />
</div>
<div class="grid gap-2">
<Label for="text_color">{{ $t('settings.brand.text_color') }}</Label>
<HexColorInput v-model="fields.text_color" />
<InputError :message="errors.text_color" />
</div>
</div>
<div class="grid gap-2">
<Label for="brand_font">{{ $t('settings.brand.font') }}</Label>
<FontPicker v-model="fields.brand_font" :fonts="availableFonts" />
<InputError :message="errors.brand_font" />
</div>
<div class="grid gap-2">
<Label>{{ $t('settings.brand.image_style') }}</Label>
<p class="text-xs font-medium text-foreground/60">
{{ $t('settings.brand.image_style_description') }}
</p>
<div class="grid grid-cols-2 gap-4 sm:grid-cols-4">
<button
v-for="style in availableImageStyles"
:key="style"
type="button"
:aria-pressed="fields.image_style === style"
:class="[
'group relative flex flex-col overflow-hidden rounded-xl border-2 border-foreground bg-card text-left shadow-sm transition-all focus:outline-none focus-visible:ring-2 focus-visible:ring-ring',
fields.image_style === style
? '-translate-y-0.5 shadow-md'
: 'hover:-translate-y-0.5 hover:shadow-md',
]"
@click="fields.image_style = style"
>
<div class="relative aspect-square w-full overflow-hidden border-b-2 border-foreground bg-muted">
<img
:src="`/images/branding/image-styles/${style}.webp`"
:alt="$t(`settings.brand.image_style_${style}`)"
class="size-full object-cover"
loading="lazy"
/>
<div
v-if="fields.image_style === style"
class="absolute right-2 top-2 flex size-7 items-center justify-center rounded-full border-2 border-foreground bg-primary text-primary-foreground shadow"
>
<IconCheck class="size-4" stroke-width="3" />
</div>
</div>
<span
:class="[
'block truncate px-3 py-2 text-center text-sm font-semibold',
fields.image_style === style ? 'bg-foreground text-background' : 'bg-card text-foreground',
]"
>
{{ $t(`settings.brand.image_style_${style}`) }}
</span>
</button>
</div>
<InputError :message="errors.image_style" />
</div>
</div>
</template>

View file

@ -4,7 +4,6 @@ import { IconAlertTriangle, IconCopy } from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import { computed, ref } from 'vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Dialog,

View file

@ -1,52 +1,51 @@
<script setup lang="ts">
import { IconChevronLeft, IconChevronRight } from '@tabler/icons-vue';
import { computed, onUnmounted, watch } from 'vue';
import { computed, onUnmounted, ref, watch } from 'vue';
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
interface Props {
/** Single-image mode (backward compatible). */
src?: string | null;
/** Multi-image mode — pass the full list and bind v-model:index to control which one is shown. */
images?: string[];
interface MediaItem {
url: string;
type: 'image' | 'video';
}
const props = withDefaults(defineProps<Props>(), {
src: null,
images: () => [],
});
const items = ref<MediaItem[]>([]);
const index = ref<number | null>(null);
const index = defineModel<number | null>('index', { default: null });
const emit = defineEmits<{
close: [];
}>();
// Multi-image takes precedence; falls back to single src when no list provided.
const allImages = computed<string[]>(() =>
props.images.length > 0 ? props.images : (props.src ? [props.src] : []),
);
// In multi-image mode the dialog is open when index is a number. In single src
// mode (legacy) it's open whenever src is set.
const isOpen = computed({
get: () => allImages.value.length > 0 && (props.images.length === 0 || index.value !== null),
get: () => index.value !== null && items.value.length > 0,
set: (val) => {
if (!val) emit('close');
if (!val) close();
},
});
const safeIndex = computed(() =>
Math.max(0, Math.min(index.value ?? 0, allImages.value.length - 1)),
Math.max(0, Math.min(index.value ?? 0, items.value.length - 1)),
);
const currentImage = computed(() => allImages.value[safeIndex.value] ?? null);
const currentItem = computed(() => items.value[safeIndex.value] ?? null);
const hasPrev = computed(() => safeIndex.value > 0);
const hasNext = computed(() => safeIndex.value < allImages.value.length - 1);
const showNav = computed(() => allImages.value.length > 1);
const hasNext = computed(() => safeIndex.value < items.value.length - 1);
const showNav = computed(() => items.value.length > 1);
const open = (url: string, type: 'image' | 'video' = 'image') => {
items.value = [{ url, type }];
index.value = 0;
};
const openCollection = (collection: MediaItem[], startIndex = 0) => {
if (collection.length === 0) return;
items.value = [...collection];
index.value = Math.max(0, Math.min(startIndex, collection.length - 1));
};
const close = () => {
index.value = null;
};
const goPrev = () => {
if (hasPrev.value) index.value = safeIndex.value - 1;
};
const goNext = () => {
if (hasNext.value) index.value = safeIndex.value + 1;
};
@ -75,6 +74,8 @@ watch(
);
onUnmounted(() => window.removeEventListener('keydown', onKeydown));
defineExpose({ open, openCollection, close });
</script>
<template>
@ -83,20 +84,32 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown));
class="max-w-5xl gap-0 border-0 bg-transparent p-0 shadow-none outline-none focus:outline-none focus-visible:outline-none sm:max-w-5xl"
:show-close-button="false"
>
<DialogTitle class="sr-only">Image preview</DialogTitle>
<div class="relative flex justify-center">
<DialogTitle class="sr-only">Media preview</DialogTitle>
<div class="relative flex justify-center" @click.self="close">
<img
v-if="currentImage"
:src="currentImage"
v-if="currentItem && currentItem.type === 'image'"
:src="currentItem.url"
alt="Preview"
class="max-h-[85vh] max-w-full cursor-pointer rounded-2xl object-contain"
@click="emit('close')"
@click="close"
/>
<video
v-else-if="currentItem && currentItem.type === 'video'"
:key="currentItem.url"
:src="currentItem.url"
class="max-h-[85vh] max-w-full rounded-2xl bg-black"
controls
autoplay
preload="metadata"
playsinline
@click.stop
/>
<button
v-if="showNav && hasPrev"
type="button"
aria-label="Previous image"
aria-label="Previous"
class="absolute left-2 top-1/2 -translate-y-1/2 rounded-full bg-black/50 p-2 text-white transition hover:bg-black/70"
@click.stop="goPrev"
>
@ -106,7 +119,7 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown));
<button
v-if="showNav && hasNext"
type="button"
aria-label="Next image"
aria-label="Next"
class="absolute right-2 top-1/2 -translate-y-1/2 rounded-full bg-black/50 p-2 text-white transition hover:bg-black/70"
@click.stop="goNext"
>
@ -117,7 +130,7 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown));
v-if="showNav"
class="absolute bottom-3 left-1/2 -translate-x-1/2 rounded-full bg-black/60 px-3 py-1 text-xs text-white tabular-nums"
>
{{ safeIndex + 1 }} / {{ allImages.length }}
{{ safeIndex + 1 }} / {{ items.length }}
</div>
</div>
</DialogContent>

View file

@ -44,7 +44,7 @@ const channelName = computed(() =>
);
if (channelName.value) {
useEcho(channelName.value, '.NotificationCreated', (e: { notification: Notification }) => {
useEcho(channelName.value, '.notification.created', (e: { notification: Notification }) => {
const exists = notifications.value.some((n) => n.id === e.notification.id);
if (exists) return;

View file

@ -11,7 +11,7 @@ withDefaults(defineProps<Props>(), {
<template>
<div class="relative mx-auto select-none origin-top">
<!-- Phone Frame - iPhone 14 Pro style -->
<div class="relative bg-[#1a1a1a] rounded-[44px] p-[10px] shadow-2xl ring-1 ring-white/10">
<div class="relative bg-[#1a1a1a] rounded-[44px] p-[10px] ring-1 ring-white/10">
<!-- Inner frame -->
<div class="relative bg-[#0a0a0a] rounded-[34px] overflow-hidden">
<!-- Screen Content - iPhone 14 Pro proportions (393x852 scaled down) -->

View file

@ -71,6 +71,8 @@ interface PickedMedia {
original_filename?: string;
size?: number;
meta?: { width?: number; height?: number; duration?: number };
source?: 'ai' | 'unsplash' | 'giphy';
source_meta?: Record<string, unknown>;
}
const props = defineProps<{
@ -81,16 +83,37 @@ const selected = defineModel<PickedMedia[]>('selected', { default: () => [] });
const isPicker = computed(() => props.mode === 'picker');
const previewImage = ref<string | null>(null);
const lightbox = ref<InstanceType<typeof ImagePreviewDialog> | null>(null);
const handleAssetClick = (asset: { id: string; url: string; type?: string }) => {
const handleAssetClick = (asset: AssetMedia) => {
if (isPicker.value) {
toggleSelect(asset as AssetMedia);
toggleSelect(asset);
return;
}
if (asset.type !== 'video') {
previewImage.value = asset.url;
}
const items = uploads.value.map((a) => ({
url: a.url,
type: a.type === 'video' ? ('video' as const) : ('image' as const),
}));
const idx = uploads.value.findIndex((a) => a.id === asset.id);
lightbox.value?.openCollection(items, idx);
};
const previewUnsplashPhoto = (photo: UnsplashPhoto) => {
const items = displayedPhotos.value.map((p) => ({
url: p.url_regular,
type: 'image' as const,
}));
const idx = displayedPhotos.value.findIndex((p) => p.id === photo.id);
lightbox.value?.openCollection(items, idx);
};
const previewGiphyGif = (gif: GiphyGif) => {
const items = displayedGifs.value.map((g) => ({
url: g.url_original,
type: 'image' as const,
}));
const idx = displayedGifs.value.findIndex((g) => g.id === gif.id);
lightbox.value?.openCollection(items, idx);
};
const selectedIds = computed(() => new Set(selected.value.map((m) => m.id)));
@ -225,7 +248,10 @@ const uploadFiles = async (files: File[]) => {
const deleteModal = ref<InstanceType<typeof ConfirmDeleteModal> | null>(null);
const handleDelete = (assetId: string) => {
deleteModal.value?.open({ url: assetsDestroy.url(assetId) });
deleteModal.value?.open({
url: assetsDestroy.url(assetId),
confirmText: trans('common.confirm_modal.delete_keyword'),
});
};
const createPostFromAsset = (asset: AssetMedia) => {
@ -357,7 +383,7 @@ const saveAndPickUnsplash = async (photo: UnsplashPhoto) => {
if (!media) return;
if (isPicker.value) {
toggleSelect(media);
toggleSelect(media, { source: 'unsplash', source_meta: { photo_id: photo.id } });
} else {
toast.success(trans('assets.saved'));
await loadUploadsFirstPage();
@ -376,7 +402,15 @@ const createPostFromUnsplash = async (photo: UnsplashPhoto) => {
return;
}
router.post(storePost.url(), {
media: [{ id: media.id, path: media.path, url: media.url, type: media.type, mime_type: media.mime_type }],
media: [{
id: media.id,
path: media.path,
url: media.url,
type: media.type,
mime_type: media.mime_type,
source: 'unsplash',
source_meta: { photo_id: photo.id },
}],
});
};
@ -485,7 +519,7 @@ const saveAndPickGiphy = async (gif: GiphyGif) => {
if (!media) return;
if (isPicker.value) {
toggleSelect(media);
toggleSelect(media, { source: 'giphy', source_meta: { gif_id: gif.id } });
} else {
toast.success(trans('assets.saved'));
await loadUploadsFirstPage();
@ -503,7 +537,15 @@ const createPostFromGiphy = async (gif: GiphyGif) => {
return;
}
router.post(storePost.url(), {
media: [{ id: media.id, path: media.path, url: media.url, type: media.type, mime_type: media.mime_type }],
media: [{
id: media.id,
path: media.path,
url: media.url,
type: media.type,
mime_type: media.mime_type,
source: 'giphy',
source_meta: { gif_id: gif.id },
}],
});
};
@ -701,7 +743,7 @@ onUnmounted(() => {
v-for="photo in displayedPhotos"
:key="photo.id"
class="group relative cursor-pointer overflow-hidden rounded-xl border-2 border-foreground bg-muted shadow-2xs transition-all hover:-translate-y-0.5 hover:shadow-md"
@click="previewImage = photo.url_regular"
@click="previewUnsplashPhoto(photo)"
>
<div class="aspect-[4/3]">
<img
@ -803,7 +845,7 @@ onUnmounted(() => {
v-for="gif in displayedGifs"
:key="gif.id"
class="group relative cursor-pointer overflow-hidden rounded-xl border-2 border-foreground bg-muted shadow-2xs transition-all hover:-translate-y-0.5 hover:shadow-md"
@click="previewImage = gif.url_original"
@click="previewGiphyGif(gif)"
>
<div class="aspect-[4/3]">
<img :src="gif.url_preview" :alt="gif.title || 'GIF'" class="size-full object-cover" loading="lazy" />
@ -882,6 +924,6 @@ onUnmounted(() => {
:cancel="trans('assets.delete.cancel')"
/>
<ImagePreviewDialog :src="previewImage" @close="previewImage = null" />
<ImagePreviewDialog ref="lightbox" />
</div>
</template>

View file

@ -6,7 +6,7 @@ import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { useAiStream } from '@/composables/useAiStream';
import { useAiStream } from '@/composables/echo/useAiStream';
import { generate as generatePostAi } from '@/routes/app/posts/ai';
const props = defineProps<{

View file

@ -1,22 +1,21 @@
<script setup lang="ts">
import { router, useHttp } from '@inertiajs/vue3';
import { echo } from '@laravel/echo-vue';
import {
IconArrowLeft,
IconCheck,
IconLoader2,
IconRefresh,
} from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import { computed, onUnmounted, ref, watch } from 'vue';
import { computed, ref, watch } from 'vue';
import { toast } from 'vue-sonner';
import { finalize as finalizeRoute, start as startRoute } from '@/actions/App/Http/Controllers/App/PostAiCreateController';
import { start as startRoute } from '@/actions/App/Http/Controllers/App/PostAiCreateController';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { getPlatformLogo } from '@/composables/usePlatformLogo';
import { ContentType, type ContentTypeValue } from '@/enums/content-type';
import { loading as loadingRoute } from '@/routes/app/posts/ai';
interface SocialAccount {
id: string;
@ -36,17 +35,13 @@ const props = withDefaults(defineProps<Props>(), {
date: null,
});
type WizardStep = 'configure' | 'preview';
const emit = defineEmits<{
/** Parent mirrors this in the PageHeader for context. */
'update:stepHeader': [{ title: string; description: string }];
/** Back button on the configure step asks parent to leave the AI flow. */
/** Back button asks parent to leave the AI flow. */
cancel: [];
}>();
const step = ref<WizardStep>('configure');
// Selections
const selectedFormat = ref<ContentTypeValue | null>(null);
const selectedAccountId = ref<string | null>(null);
@ -54,17 +49,7 @@ const includeImages = ref(true);
const imageCount = ref(2);
const promptText = ref('');
// Preview state
const submitting = ref(false);
const finalizing = ref(false);
const previewStatus = ref<'loading' | 'done' | 'error'>('loading');
const previewContent = ref('');
const previewImageTitle = ref('');
const previewImageBody = ref('');
const previewError = ref('');
const previewCreationId = ref<string | null>(null);
let echoChannel: any = null;
let subscribedChannelName: string | null = null;
const httpStart = useHttp<{
format: string | null;
@ -74,12 +59,6 @@ const httpStart = useHttp<{
date: string | null;
}>({ format: null, social_account_id: null, image_count: 0, prompt: '', date: null });
const httpFinalize = useHttp<{ content: string; image_title: string; image_body: string }>({
content: '',
image_title: '',
image_body: '',
});
const AI_FORMATS: Array<{ value: ContentTypeValue; platforms: string[] }> = [
{ value: ContentType.InstagramFeed, platforms: ['instagram', 'instagram-facebook'] },
{ value: ContentType.InstagramCarousel, platforms: ['instagram', 'instagram-facebook'] },
@ -137,11 +116,6 @@ const supportsOptionalImages = computed(() =>
const maxOptionalImages = computed(() =>
selectedFormat.value === ContentType.InstagramFeed ? 1 : 4,
);
// Mirrors ContentType::supportsCaption() in PHP.
const supportsCaption = computed(() =>
selectedFormat.value !== ContentType.InstagramStory &&
selectedFormat.value !== ContentType.FacebookStory,
);
const showsAccountPicker = computed(() => accountsForFormat.value.length > 1);
const submittedImageCount = computed(() => {
@ -183,76 +157,17 @@ const selectFormat = (format: ContentTypeValue) => {
}
};
// Step header text — the parent reflects this in the PageHeader.
const stepHeaderFor = (s: WizardStep) => {
switch (s) {
case 'configure':
return {
title: trans('posts.create.ai_title'),
description: trans('posts.create.ai_configure_description'),
};
case 'preview':
return {
title: trans('posts.create.steps.preview_title'),
description: '',
};
}
};
emit('update:stepHeader', {
title: trans('posts.create.ai_title'),
description: trans('posts.create.ai_configure_description'),
});
const goToStep = (s: WizardStep) => {
step.value = s;
emit('update:stepHeader', stepHeaderFor(s));
};
emit('update:stepHeader', stepHeaderFor(step.value));
const goBack = () => {
if (step.value === 'configure') {
emit('cancel');
} else if (step.value === 'preview') {
goToStep('configure');
}
};
// Echo subscription for AI streaming
const unsubscribeEcho = () => {
if (echoChannel && subscribedChannelName) {
echo().leave(`private-${subscribedChannelName}`);
echoChannel = null;
subscribedChannelName = null;
}
};
const subscribeToCreation = (userId: string, creationId: string) => {
unsubscribeEcho();
previewCreationId.value = creationId;
const channelName = `users.${userId}.ai-creation.${creationId}`;
subscribedChannelName = channelName;
echoChannel = echo().private(channelName).listen('.PostCreationReady', (e: any) => {
if (e.error) {
previewStatus.value = 'error';
previewError.value = e.error;
} else {
previewContent.value = e.content ?? '';
previewImageTitle.value = e.image_title ?? '';
previewImageBody.value = e.image_body ?? '';
previewStatus.value = 'done';
}
unsubscribeEcho();
});
};
const goBack = () => emit('cancel');
const startGeneration = async () => {
if (!canSubmit.value || submitting.value) return;
submitting.value = true;
previewStatus.value = 'loading';
previewContent.value = '';
previewImageTitle.value = '';
previewImageBody.value = '';
previewError.value = '';
goToStep('preview');
httpStart.format = selectedFormat.value;
httpStart.social_account_id = selectedAccountId.value;
@ -262,38 +177,23 @@ const startGeneration = async () => {
try {
const data = await httpStart.post(startRoute.url()) as { creation_id: string; channel: string };
const userId = data.channel.split('.')[1] ?? '';
subscribeToCreation(userId, data.creation_id);
router.visit(loadingRoute(
{ creationId: data.creation_id },
{
query: {
images: String(submittedImageCount.value),
format: selectedFormat.value ?? '',
prompt: promptText.value.trim(),
},
},
).url);
} catch (err: any) {
previewStatus.value = 'error';
previewError.value = err?.response?.data?.message ?? trans('posts.create.steps.preview_error');
} finally {
toast.error(err?.response?.data?.message ?? trans('posts.create.steps.preview_error'));
submitting.value = false;
}
};
const retryGeneration = () => startGeneration();
const createPost = async () => {
if (!previewCreationId.value || finalizing.value) return;
finalizing.value = true;
httpFinalize.content = previewContent.value;
httpFinalize.image_title = previewImageTitle.value;
httpFinalize.image_body = previewImageBody.value;
try {
const data = await httpFinalize.post(finalizeRoute.url(previewCreationId.value)) as { redirect_url: string };
router.visit(data.redirect_url);
} catch {
previewStatus.value = 'error';
previewError.value = trans('posts.create.steps.preview_error');
} finally {
finalizing.value = false;
}
};
onUnmounted(() => unsubscribeEcho());
</script>
<template>
@ -309,9 +209,6 @@ onUnmounted(() => unsubscribeEcho());
</span>
{{ $t('posts.create.steps.back') }}
</button>
<!-- ====== Step 1: Configure (everything in one screen) ====== -->
<template v-if="step === 'configure'">
<!-- Format -->
<div class="space-y-2">
<Label class="text-sm font-bold">{{ $t('posts.create.steps.format_title') }}</Label>
@ -422,65 +319,9 @@ onUnmounted(() => unsubscribeEcho());
<!-- Generate -->
<div v-if="selectedFormat" class="flex justify-end pt-1">
<Button :disabled="!canSubmit" @click="startGeneration">
<Button :disabled="!canSubmit || submitting" @click="startGeneration">
{{ $t('posts.ai.generate.start') }}
</Button>
</div>
</template>
<!-- ====== Step 2: Preview ====== -->
<template v-else-if="step === 'preview'">
<div v-if="previewStatus === 'loading'" class="flex flex-col items-center gap-4 rounded-2xl border-2 border-foreground bg-card py-16 text-center shadow-2xs">
<div class="inline-flex size-12 -rotate-2 items-center justify-center rounded-2xl border-2 border-foreground bg-violet-200 shadow-2xs">
<IconLoader2 class="size-6 animate-spin text-foreground" stroke-width="2" />
</div>
<p class="text-sm font-semibold text-foreground/70">{{ $t('posts.create.steps.preview_loading') }}</p>
</div>
<div v-else-if="previewStatus === 'error'" class="space-y-4">
<div class="rounded-xl border-2 border-foreground bg-rose-50 p-4 shadow-2xs">
<p class="text-sm font-semibold text-rose-700">{{ previewError || $t('posts.create.steps.preview_error') }}</p>
</div>
<div class="flex justify-end">
<Button variant="outline" @click="retryGeneration">
<IconRefresh class="size-4" />
{{ $t('posts.create.steps.retry') }}
</Button>
</div>
</div>
<div v-else-if="previewStatus === 'done'" class="space-y-4">
<!-- Caption-less formats (Stories): edit title + body separately. -->
<div v-if="!supportsCaption" class="space-y-3 rounded-2xl border-2 border-foreground bg-card p-5 shadow-2xs">
<div class="space-y-1.5">
<Label class="text-[11px] font-black uppercase tracking-widest text-foreground/60">{{ $t('posts.create.preview.image_title') }}</Label>
<Input v-model="previewImageTitle" />
</div>
<div class="space-y-1.5">
<Label class="text-[11px] font-black uppercase tracking-widest text-foreground/60">{{ $t('posts.create.preview.image_body') }}</Label>
<Textarea v-model="previewImageBody" class="min-h-[120px] resize-none" />
</div>
</div>
<!-- Default: edit caption text. -->
<Textarea
v-else
v-model="previewContent"
class="min-h-[200px] resize-none p-5 text-sm leading-relaxed"
/>
<div class="flex justify-end gap-2">
<Button variant="outline" @click="retryGeneration">
<IconRefresh class="size-4" />
{{ $t('posts.create.steps.retry') }}
</Button>
<Button :disabled="finalizing" @click="createPost">
<IconLoader2 v-if="finalizing" class="size-4 animate-spin" />
{{ $t('posts.create.steps.create') }}
</Button>
</div>
</div>
</template>
</div>
</template>

View file

@ -733,14 +733,14 @@ watch(() => props.postId, () => {
v-model="newBody"
:member-names="memberNames"
:placeholder="replyingTo ? $t('comments.reply_placeholder') : $t('comments.placeholder')"
class="min-h-[36px] max-h-[120px] flex-1 resize-none text-sm"
class="min-h-10 max-h-[120px] flex-1 resize-none text-sm"
:rows="1"
@keydown="handleKeydown"
@mention="registerMention"
/>
<Button
size="icon"
class="size-9 shrink-0"
class="shrink-0"
:disabled="!newBody.trim() || sending"
@click="sendComment"
>

View file

@ -1,11 +1,9 @@
<script setup lang="ts">
import {
IconAlertTriangle,
IconCloudUpload,
IconGripVertical,
IconHash,
IconLibraryPhoto,
IconLoader2,
IconMoodSmile,
IconSparkles,
IconTrash,
@ -19,12 +17,10 @@ import ImagePreviewDialog from '@/components/ImagePreviewDialog.vue';
import EmojiPicker from '@/components/posts/EmojiPicker.vue';
import MediaPickerDialog from '@/components/posts/MediaPickerDialog.vue';
import SignaturesModal from '@/components/posts/SignaturesModal.vue';
import { Button } from '@/components/ui/button';
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { formatBytes, readFileMetadata } from '@/composables/useMedia';
import { formatBytes } from '@/composables/useMedia';
import { getPlatformLabel, getPlatformLogo } from '@/composables/usePlatformLogo';
import { store as storeAsset } from '@/routes/app/assets';
interface MediaItem {
id: string;
@ -67,8 +63,6 @@ const emit = defineEmits<{
(e: 'open-ai-review'): void;
}>();
const isDragging = ref(false);
const uploading = ref(false);
const emojiOpen = ref(false);
const mediaPickerDialog = ref<InstanceType<typeof MediaPickerDialog> | null>(null);
const signaturesModal = ref<InstanceType<typeof SignaturesModal> | null>(null);
@ -76,22 +70,20 @@ const signaturesModal = ref<InstanceType<typeof SignaturesModal> | null>(null);
const dragMediaIndex = ref<number | null>(null);
const dragOverIndex = ref<number | null>(null);
const mediaThumbRefs = ref<HTMLElement[]>([]);
const previewIndex = ref<number | null>(null);
// Image-only URLs (videos are skipped) in the same order as `media`. The
// preview index is computed against THIS list to keep arrow navigation tight.
const previewImages = computed(() =>
media.value.filter((m) => !isVideo(m)).map((m) => m.url),
);
const lightbox = ref<InstanceType<typeof ImagePreviewDialog> | null>(null);
const openPreview = (item: MediaItem) => {
if (isVideo(item)) return;
const idx = previewImages.value.indexOf(item.url);
previewIndex.value = idx >= 0 ? idx : 0;
const idx = media.value.findIndex((m) => m.id === item.id);
if (idx < 0) return;
lightbox.value?.openCollection(
media.value.map((m) => ({
url: m.url,
type: isVideo(m) ? 'video' as const : 'image' as const,
})),
idx,
);
};
const csrfToken = document.querySelector<HTMLMetaElement>('meta[name="csrf-token"]')?.content ?? '';
const isVideo = (item: MediaItem): boolean =>
item.type === 'video' || Boolean(item.mime_type?.startsWith('video/'));
@ -133,60 +125,6 @@ const overflowParts = computed(() => {
};
});
const handleDrop = (event: DragEvent) => {
isDragging.value = false;
if (event.dataTransfer?.files && event.dataTransfer.files.length > 0) {
uploadFiles(Array.from(event.dataTransfer.files));
}
};
const uploadFiles = async (files: File[]) => {
uploading.value = true;
for (const file of files) {
const clientMeta = await readFileMetadata(file);
const formData = new FormData();
formData.append('media', file);
if (clientMeta.width) formData.append('meta[width]', String(clientMeta.width));
if (clientMeta.height) formData.append('meta[height]', String(clientMeta.height));
if (clientMeta.duration) formData.append('meta[duration]', String(clientMeta.duration));
try {
const response = await fetch(storeAsset.url(), {
method: 'POST',
headers: {
Accept: 'application/json',
'X-CSRF-TOKEN': csrfToken,
'X-Requested-With': 'XMLHttpRequest',
},
body: formData,
});
if (!response.ok) continue;
const data = await response.json();
media.value = [
...media.value,
{
id: data.id,
path: data.path,
url: data.url,
type: data.type,
mime_type: data.mime_type,
original_filename: data.original_filename,
size: data.size,
meta: data.meta,
},
];
} catch {
// ignore
}
}
uploading.value = false;
};
const removeMedia = (mediaId: string) => {
media.value = media.value.filter((m) => m.id !== mediaId);
};
@ -276,12 +214,7 @@ const issueLabel = (reason: string): string => trans(`posts.form.warnings.${reas
<template>
<div class="mx-auto max-w-2xl px-6 py-10">
<div
class="relative"
@dragover.prevent="isDragging = true"
@dragleave.prevent="isDragging = false"
@drop.prevent="handleDrop"
>
<div class="relative">
<!-- Media grid (top) — always shown so "Add" tile is discoverable -->
<div class="mb-6">
<div class="grid grid-cols-4 gap-2">
@ -452,9 +385,6 @@ const issueLabel = (reason: string): string => trans(`posts.form.warnings.${reas
</Tooltip>
</TooltipProvider>
<span v-if="uploading" class="flex items-center gap-1.5 text-xs font-medium text-foreground/60">
<IconLoader2 class="size-3.5 animate-spin" />
</span>
</div>
<!-- Per-platform counters (below menu, above textarea) -->
@ -499,30 +429,10 @@ const issueLabel = (reason: string): string => trans(`posts.form.warnings.${reas
/>
</div>
<!-- Drag-drop overlay (full-bleed over the editor area) -->
<div
v-if="isDragging"
class="pointer-events-none absolute -inset-6 z-10 flex flex-col items-center justify-center gap-3 rounded-2xl border-2 border-dashed border-foreground bg-violet-100/80 backdrop-blur-sm"
>
<div class="inline-flex size-12 -rotate-3 items-center justify-center rounded-2xl border-2 border-foreground bg-violet-200 shadow-2xs">
<IconCloudUpload class="size-6 text-foreground" stroke-width="2" />
</div>
<p
class="text-xl font-semibold text-foreground"
style="font-family: var(--font-display)"
>
{{ $t('posts.edit.drag_drop') }}
</p>
</div>
</div>
<SignaturesModal ref="signaturesModal" :signatures="signatures" @select="appendSignature" />
<MediaPickerDialog ref="mediaPickerDialog" @select="addMediaFromGallery" />
<ImagePreviewDialog
:images="previewImages"
:index="previewIndex"
@update:index="previewIndex = $event"
@close="previewIndex = null"
/>
<ImagePreviewDialog ref="lightbox" />
</div>
</template>

View file

@ -1,4 +1,5 @@
<script setup lang="ts">
import VideoPreview from "@/components/posts/previews/VideoPreview.vue";
import { isVideoMedia, type MediaItem } from '@/composables/useMedia';
interface SocialAccount {
@ -88,8 +89,7 @@ defineProps<Props>();
}">
<img v-if="!isVideoMedia(item)" :src="item.url" :alt="item.original_filename"
class="w-full h-full object-cover" />
<video v-else :src="item.url" class="w-full h-full object-cover bg-black" muted loop
playsinline />
<VideoPreview v-else :src="item.url" video-class="w-full h-full object-cover bg-black" />
<div v-if="media.length > 4 && index === 3"
class="absolute inset-0 bg-black/60 flex items-center justify-center">
<span class="text-white text-xl font-semibold">+{{ media.length - 4 }}</span>

View file

@ -1,4 +1,5 @@
<script setup lang="ts">
import VideoPreview from "@/components/posts/previews/VideoPreview.vue";
import { isVideoMedia, type MediaItem } from '@/composables/useMedia';
interface SocialAccount {
@ -77,8 +78,7 @@ defineProps<Props>();
}">
<img v-if="!isVideoMedia(item)" :src="item.url" :alt="item.original_filename"
class="w-full h-full object-cover" />
<video v-else :src="item.url" class="w-full h-full object-cover bg-black" muted loop
playsinline />
<VideoPreview v-else :src="item.url" video-class="w-full h-full object-cover bg-black" />
<div v-if="media.length > 4 && index === 3"
class="absolute inset-0 bg-black/70 flex items-center justify-center">
<span class="text-white text-3xl font-semibold">+{{ media.length - 4 }}</span>

View file

@ -1,4 +1,5 @@
<script setup lang="ts">
import VideoPreview from "@/components/posts/previews/VideoPreview.vue";
import { isVideoMedia, type MediaItem } from '@/composables/useMedia';
interface SocialAccount {
@ -60,8 +61,7 @@ defineProps<Props>();
}">
<img v-if="!isVideoMedia(item)" :src="item.url" :alt="item.original_filename"
class="w-full h-full object-cover" />
<video v-else :src="item.url" class="w-full h-full object-cover bg-black" muted loop
playsinline />
<VideoPreview v-else :src="item.url" video-class="w-full h-full object-cover bg-black" />
<!-- Hide button -->
<button
class="absolute top-2 right-2 bg-black/60 text-white text-[12px] px-2 py-0.5 rounded">

View file

@ -2,6 +2,7 @@
import { IconPhoto, IconStack2 } from '@tabler/icons-vue';
import { computed } from 'vue';
import VideoPreview from "@/components/posts/previews/VideoPreview.vue";
import { isVideoMedia, type MediaItem } from '@/composables/useMedia';
interface SocialAccount {
@ -51,13 +52,10 @@ const isCarousel = computed(() => props.contentType === 'pinterest_carousel');
:alt="media[0].original_filename"
class="w-full aspect-[2/3] object-cover"
/>
<video
<VideoPreview
v-else
:src="media[0].url"
class="w-full aspect-[2/3] object-cover bg-black"
muted
loop
playsinline
video-class="w-full aspect-[2/3] object-cover bg-black"
/>
<!-- Video indicator -->
<div v-if="isVideoMedia(media[0])" class="absolute bottom-2 left-2 bg-black/60 text-white text-[10px] px-2 py-0.5 rounded-full flex items-center gap-1">

View file

@ -2,6 +2,7 @@
import { IconChevronLeft, IconChevronRight, IconPhoto } from '@tabler/icons-vue';
import { computed, ref, watch, type Component } from 'vue';
import VideoPreview from '@/components/posts/previews/VideoPreview.vue';
import { isVideoMedia, type MediaItem } from '@/composables/useMedia';
interface Props {
@ -54,13 +55,10 @@ const goToSlide = (index: number) => {
<template>
<template v-if="media.length > 0">
<template v-for="(item, index) in media" :key="item.id">
<video
<VideoPreview
v-if="isVideoMedia(item) && index === currentIndex"
:src="item.url"
:class="mediaClass"
muted
loop
playsinline
:video-class="mediaClass"
/>
<img
v-else-if="index === currentIndex"

View file

@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed } from 'vue';
import VideoPreview from "@/components/posts/previews/VideoPreview.vue";
import { isVideoMedia, type MediaItem } from '@/composables/useMedia';
interface SocialAccount {
@ -97,8 +98,7 @@ const username = computed(() => props.socialAccount.username || props.socialAcco
}">
<img v-if="!isVideoMedia(item)" :src="item.url" :alt="item.original_filename"
class="w-full h-full object-cover" />
<video v-else :src="item.url" class="w-full h-full object-cover bg-black" muted loop
playsinline />
<VideoPreview v-else :src="item.url" video-class="w-full h-full object-cover bg-black" />
<div v-if="media.length > 4 && index === 3"
class="absolute inset-0 bg-black/60 flex items-center justify-center">
<span class="text-white text-xl font-semibold">+{{ media.length - 4 }}</span>

View file

@ -2,6 +2,7 @@
import { IconPlus } from '@tabler/icons-vue';
import { computed } from 'vue';
import VideoPreview from "@/components/posts/previews/VideoPreview.vue";
import { isVideoMedia, type MediaItem } from '@/composables/useMedia';
interface SocialAccount {
@ -40,7 +41,7 @@ const username = computed(() => props.socialAccount.username || props.socialAcco
<div class="absolute inset-0">
<!-- Video content -->
<div v-if="media.length > 0 && isVideoMedia(media[0])" class="w-full h-full">
<video :src="media[0].url" class="w-full h-full object-cover" muted loop playsinline />
<VideoPreview :src="media[0].url" />
</div>
<div v-else-if="media.length > 0" class="w-full h-full">
<img :src="media[0].url" :alt="media[0].original_filename" class="w-full h-full object-cover" />

View file

@ -0,0 +1,54 @@
<script setup lang="ts">
import { IconPlayerPlayFilled } from '@tabler/icons-vue';
import { ref } from 'vue';
const props = withDefaults(
defineProps<{
src: string;
videoClass?: string;
}>(),
{
videoClass: 'w-full h-full object-cover',
},
);
const videoRef = ref<HTMLVideoElement | null>(null);
const isPlaying = ref(false);
const toggle = () => {
const el = videoRef.value;
if (!el) return;
if (el.paused) {
void el.play();
} else {
el.pause();
}
};
</script>
<template>
<div class="relative h-full w-full" @click="toggle">
<video
ref="videoRef"
:src="props.src"
:class="props.videoClass"
playsinline
preload="metadata"
@play="isPlaying = true"
@pause="isPlaying = false"
@ended="isPlaying = false"
/>
<button
v-show="!isPlaying"
type="button"
class="absolute inset-0 flex cursor-pointer items-center justify-center bg-black/10 transition-colors hover:bg-black/20"
aria-label="Play"
>
<span
class="flex size-14 items-center justify-center rounded-full bg-black/55 ring-1 ring-white/30 backdrop-blur-sm transition-transform hover:scale-110"
>
<IconPlayerPlayFilled class="size-7 text-white drop-shadow" />
</span>
</button>
</div>
</template>

View file

@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed } from 'vue';
import VideoPreview from "@/components/posts/previews/VideoPreview.vue";
import { isVideoMedia, type MediaItem } from '@/composables/useMedia';
interface SocialAccount {
@ -95,8 +96,7 @@ const username = computed(() => props.socialAccount.username || 'username');
}">
<img v-if="!isVideoMedia(item)" :src="item.url" :alt="item.original_filename"
class="w-full h-full object-cover" />
<video v-else :src="item.url" class="w-full h-full object-cover bg-black" muted loop
playsinline />
<VideoPreview v-else :src="item.url" video-class="w-full h-full object-cover bg-black" />
<!-- Video duration badge -->
<div v-if="isVideoMedia(item)"
class="absolute bottom-2 left-2 bg-black/70 text-white text-[13px] px-1.5 py-0.5 rounded">

View file

@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed } from 'vue';
import VideoPreview from "@/components/posts/previews/VideoPreview.vue";
import { isVideoMedia, type MediaItem } from '@/composables/useMedia';
interface SocialAccount {
@ -39,7 +40,7 @@ const username = computed(() => props.socialAccount.username || props.socialAcco
<div class="absolute inset-0">
<!-- Video content -->
<div v-if="media.length > 0 && isVideoMedia(media[0])" class="w-full h-full">
<video :src="media[0].url" class="w-full h-full object-cover" muted loop playsinline />
<VideoPreview :src="media[0].url" />
</div>
<div v-else-if="media.length > 0" class="w-full h-full">
<img :src="media[0].url" :alt="media[0].original_filename" class="w-full h-full object-cover" />

View file

@ -1,24 +1,10 @@
<script setup lang="ts">
import { Form } from '@inertiajs/vue3';
import { trans } from 'laravel-vue-i18n';
import { computed, ref } from 'vue';
import { useForm } from '@inertiajs/vue3';
import WorkspaceController from '@/actions/App/Http/Controllers/App/WorkspaceController';
import FontPicker from '@/components/FontPicker.vue';
import BrandForm from '@/components/BrandForm.vue';
import HeadingSmall from '@/components/HeadingSmall.vue';
import HexColorInput from '@/components/HexColorInput.vue';
import InputError from '@/components/InputError.vue';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
interface Workspace {
id: string;
@ -31,156 +17,49 @@ interface Workspace {
background_color: string | null;
text_color: string | null;
brand_font: string;
image_style: string;
content_language: string;
}
const props = defineProps<{
workspace: Workspace;
availableFonts: string[];
availableImageStyles: string[];
}>();
const brandTone = ref(props.workspace.brand_tone ?? 'professional');
const contentLanguage = ref(props.workspace.content_language ?? 'en');
const brandColor = ref<string | null>(props.workspace.brand_color);
const backgroundColor = ref<string | null>(props.workspace.background_color);
const textColor = ref<string | null>(props.workspace.text_color);
const brandFont = ref<string>(props.workspace.brand_font ?? 'Inter');
const toneLabel = computed(() =>
brandTone.value ? trans(`settings.brand.tone_${brandTone.value}`) : '',
);
const languageLabel = computed(() => {
const map: Record<string, string> = {
en: 'English',
'pt-BR': 'Português (Brasil)',
es: 'Español',
};
return map[contentLanguage.value] ?? '';
const form = useForm({
name: props.workspace.name,
brand_website: props.workspace.brand_website ?? '',
brand_description: props.workspace.brand_description ?? '',
brand_tone: props.workspace.brand_tone ?? 'professional',
brand_voice_notes: props.workspace.brand_voice_notes ?? '',
brand_color: props.workspace.brand_color,
background_color: props.workspace.background_color,
text_color: props.workspace.text_color,
brand_font: props.workspace.brand_font ?? 'Inter',
image_style: props.workspace.image_style ?? 'cinematic',
content_language: props.workspace.content_language ?? 'en',
});
const submit = () => {
form.put(WorkspaceController.updateSettings.url());
};
</script>
<template>
<div class="flex flex-col space-y-6">
<form class="flex flex-col space-y-6" @submit.prevent="submit">
<HeadingSmall
:title="$t('settings.brand.title')"
:description="$t('settings.brand.description')"
/>
<Form
v-bind="WorkspaceController.updateSettings.form()"
v-slot="{ errors, processing }"
class="space-y-6"
>
<input type="hidden" name="name" :value="workspace.name" />
<BrandForm
:fields="form"
:errors="form.errors"
:available-fonts="availableFonts"
:available-image-styles="availableImageStyles"
/>
<div class="grid gap-2">
<Label for="brand_website">{{ $t('settings.brand.website') }}</Label>
<Input
id="brand_website"
name="brand_website"
type="url"
:default-value="workspace.brand_website ?? ''"
:placeholder="$t('settings.brand.website_placeholder')"
/>
<InputError :message="errors.brand_website" />
</div>
<div class="grid gap-2">
<Label for="brand_description">{{ $t('settings.brand.brand_description') }}</Label>
<Textarea
id="brand_description"
name="brand_description"
:default-value="workspace.brand_description ?? ''"
:placeholder="$t('settings.brand.brand_description_placeholder')"
rows="3"
/>
<InputError :message="errors.brand_description" />
</div>
<div class="grid gap-4 sm:grid-cols-2">
<div class="grid gap-2">
<Label for="brand_tone">{{ $t('settings.brand.tone') }}</Label>
<Select v-model="brandTone" name="brand_tone">
<SelectTrigger id="brand_tone" class="w-full">
<SelectValue :placeholder="$t('settings.brand.tone')">
{{ toneLabel }}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="professional">{{ $t('settings.brand.tone_professional') }}</SelectItem>
<SelectItem value="casual">{{ $t('settings.brand.tone_casual') }}</SelectItem>
<SelectItem value="friendly">{{ $t('settings.brand.tone_friendly') }}</SelectItem>
<SelectItem value="bold">{{ $t('settings.brand.tone_bold') }}</SelectItem>
<SelectItem value="inspirational">{{ $t('settings.brand.tone_inspirational') }}</SelectItem>
<SelectItem value="humorous">{{ $t('settings.brand.tone_humorous') }}</SelectItem>
<SelectItem value="educational">{{ $t('settings.brand.tone_educational') }}</SelectItem>
</SelectContent>
</Select>
<input type="hidden" name="brand_tone" :value="brandTone" />
<InputError :message="errors.brand_tone" />
</div>
<div class="grid gap-2">
<Label for="content_language">{{ $t('settings.brand.content_language') }}</Label>
<Select v-model="contentLanguage" name="content_language">
<SelectTrigger id="content_language" class="w-full">
<SelectValue :placeholder="$t('settings.brand.content_language')">
{{ languageLabel }}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="en">English</SelectItem>
<SelectItem value="pt-BR">Português (Brasil)</SelectItem>
<SelectItem value="es">Español</SelectItem>
</SelectContent>
</Select>
<input type="hidden" name="content_language" :value="contentLanguage" />
<InputError :message="errors.content_language" />
</div>
</div>
<p class="-mt-4 text-xs font-medium text-foreground/60">
{{ $t('settings.brand.content_language_description') }}
</p>
<div class="grid gap-4 sm:grid-cols-3">
<div class="grid gap-2">
<Label for="brand_color">{{ $t('settings.brand.brand_color') }}</Label>
<HexColorInput v-model="brandColor" name="brand_color" />
<InputError :message="errors.brand_color" />
</div>
<div class="grid gap-2">
<Label for="background_color">{{ $t('settings.brand.background_color') }}</Label>
<HexColorInput v-model="backgroundColor" name="background_color" />
<InputError :message="errors.background_color" />
</div>
<div class="grid gap-2">
<Label for="text_color">{{ $t('settings.brand.text_color') }}</Label>
<HexColorInput v-model="textColor" name="text_color" />
<InputError :message="errors.text_color" />
</div>
</div>
<div class="grid gap-2">
<Label for="brand_font">{{ $t('settings.brand.font') }}</Label>
<FontPicker v-model="brandFont" name="brand_font" :fonts="availableFonts" />
<InputError :message="errors.brand_font" />
</div>
<div class="grid gap-2">
<Label for="brand_voice_notes">{{ $t('settings.brand.voice_notes') }}</Label>
<Textarea
id="brand_voice_notes"
name="brand_voice_notes"
:default-value="workspace.brand_voice_notes ?? ''"
:placeholder="$t('settings.brand.voice_notes_placeholder')"
rows="3"
/>
<InputError :message="errors.brand_voice_notes" />
</div>
<Button :disabled="processing">{{ $t('settings.workspace.save') }}</Button>
</Form>
</div>
<Button :disabled="form.processing">{{ $t('settings.workspace.save') }}</Button>
</form>
</template>

View file

@ -0,0 +1,9 @@
import { useEcho } from '@laravel/echo-vue';
export const usePostEcho = <T = unknown>(
postId: string,
event: string | string[],
callback: (payload: T) => void,
) => {
return useEcho<T>(`post.${postId}`, event, callback);
};

View file

@ -0,0 +1,18 @@
import { usePage } from '@inertiajs/vue3';
import { useEcho } from '@laravel/echo-vue';
import type { Auth } from '@/types';
export const useWorkspaceEcho = <T = unknown>(
event: string | string[],
callback: (payload: T) => void,
) => {
const page = usePage();
const workspaceId = (page.props.auth as Auth | undefined)?.currentWorkspace?.id;
if (!workspaceId) {
return;
}
return useEcho<T>(`workspace.${workspaceId}`, event, callback);
};

View file

@ -156,51 +156,6 @@ export const getMediaItemIssue = (item: MediaItem, contentType: string): string
return null;
};
/**
* Read metadata from a File in the browser before uploading.
* Returns width/height for images, width/height/duration for videos.
*/
export const readFileMetadata = async (file: File): Promise<{ width?: number; height?: number; duration?: number }> => {
if (file.type.startsWith('image/')) {
return new Promise((resolve) => {
const img = new Image();
const url = URL.createObjectURL(file);
img.onload = () => {
URL.revokeObjectURL(url);
resolve({ width: img.naturalWidth, height: img.naturalHeight });
};
img.onerror = () => {
URL.revokeObjectURL(url);
resolve({});
};
img.src = url;
});
}
if (file.type.startsWith('video/')) {
return new Promise((resolve) => {
const video = document.createElement('video');
const url = URL.createObjectURL(file);
video.preload = 'metadata';
video.onloadedmetadata = () => {
URL.revokeObjectURL(url);
resolve({
width: video.videoWidth,
height: video.videoHeight,
duration: video.duration,
});
};
video.onerror = () => {
URL.revokeObjectURL(url);
resolve({});
};
video.src = url;
});
}
return {};
};
export const isVideoMedia = (item: MediaItem | null | undefined): boolean => {
if (! item) return false;
return item.type === 'video' || Boolean(item.mime_type?.startsWith('video/'));

Some files were not shown because too many files have changed in this diff Show more