Merge pull request #22 from trypostit/feature/posts-realtime-status
feat: AI image generation overhaul + post creation flow + realtime broadcast
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
16
app/Broadcasting/WorkspaceChannel.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,4 +8,5 @@ enum UsageType: string
|
|||
{
|
||||
case Template = 'template';
|
||||
case Text = 'text';
|
||||
case Image = 'image';
|
||||
}
|
||||
|
|
|
|||
19
app/Enums/Media/Source.php
Normal 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';
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
32
app/Enums/Workspace/ImageStyle.php
Normal 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
46
app/Events/PostCreated.php
Normal 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';
|
||||
}
|
||||
}
|
||||
47
app/Events/PostDeleted.php
Normal 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';
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
95
app/Services/Ai/AiImageClient.php
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
253
app/Services/Brand/CssColorFrequencyExtractor.php
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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']);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
102
app/Support/HexColorName.php
Normal 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
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
],
|
||||
|
||||
|
|
|
|||
|
|
@ -214,7 +214,7 @@
|
|||
'defaults' => [
|
||||
'supervisor-1' => [
|
||||
'connection' => 'redis',
|
||||
'queue' => ['default', 'posthog'],
|
||||
'queue' => ['default', 'posthog', 'broadcasts'],
|
||||
'balance' => 'auto',
|
||||
'autoScalingStrategy' => 'time',
|
||||
'maxProcesses' => 1,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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
|
|
@ -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.',
|
||||
],
|
||||
];
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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.',
|
||||
],
|
||||
];
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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' => [
|
||||
|
|
|
|||
10
lang/pt-BR/notifications.php
Normal 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.',
|
||||
],
|
||||
];
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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": {
|
||||
|
|
|
|||
BIN
public/images/branding/image-styles/cartoon.webp
Normal file
|
After Width: | Height: | Size: 59 KiB |
BIN
public/images/branding/image-styles/cinematic.webp
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
public/images/branding/image-styles/illustration.webp
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
public/images/branding/image-styles/infographic.webp
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
public/images/branding/image-styles/isometric_3d.webp
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
public/images/branding/image-styles/minimalist.webp
Normal file
|
After Width: | Height: | Size: 8.7 KiB |
BIN
public/images/branding/image-styles/mockup.webp
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
public/images/branding/image-styles/typographic.webp
Normal file
|
After Width: | Height: | Size: 13 KiB |
292
resources/js/components/BrandForm.vue
Normal 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>
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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) -->
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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<{
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
54
resources/js/components/posts/previews/VideoPreview.vue
Normal 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>
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
9
resources/js/composables/echo/usePostEcho.ts
Normal 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);
|
||||
};
|
||||
18
resources/js/composables/echo/useWorkspaceEcho.ts
Normal 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);
|
||||
};
|
||||
|
|
@ -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/'));
|
||||
|
|
|
|||