feat: replace legacy AI assistant with modular post content generation, review, and template management system
This commit is contained in:
parent
531bb23315
commit
1e1519876d
130 changed files with 5958 additions and 3039 deletions
|
|
@ -48,11 +48,14 @@ public function schema(JsonSchema $schema): array
|
|||
->description('2-3 sentences of concrete writing guidelines inferred from the site style (e.g. "Use technical but approachable language", "Avoid marketing buzzwords"). Written in the detected content language.')
|
||||
->required(),
|
||||
'brand_color' => $schema->string()
|
||||
->description('The primary brand color as a hex string starting with # (e.g. "#0ea5e9"). Pick the most prominent accent color used in CTAs, links, or logos. Return empty string if not confidently identifiable.'),
|
||||
->description('The primary brand color as a hex string starting with # (e.g. "#0ea5e9"). Pick the most prominent accent color used in CTAs, links, or logos. Return empty string if not confidently identifiable.')
|
||||
->required(),
|
||||
'background_color' => $schema->string()
|
||||
->description('The dominant page background color as a hex string starting with # (e.g. "#ffffff" or "#0b0f19"). Return empty string if not confidently identifiable.'),
|
||||
->description('The dominant page background color as a hex string starting with # (e.g. "#ffffff" or "#0b0f19"). Return empty string if not confidently identifiable.')
|
||||
->required(),
|
||||
'text_color' => $schema->string()
|
||||
->description('The dominant body text color as a hex string starting with # (e.g. "#0f172a"). Return empty string if not confidently identifiable.'),
|
||||
->description('The dominant body text color as a hex string starting with # (e.g. "#0f172a"). Return empty string if not confidently identifiable.')
|
||||
->required(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,31 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Ai\Agents;
|
||||
|
||||
use Laravel\Ai\Attributes\UseCheapestModel;
|
||||
use Laravel\Ai\Contracts\Agent;
|
||||
use Laravel\Ai\Enums\Lab;
|
||||
use Laravel\Ai\Promptable;
|
||||
|
||||
#[UseCheapestModel]
|
||||
class Humanizer implements Agent
|
||||
{
|
||||
use Promptable;
|
||||
|
||||
public function __construct(public string $instructions) {}
|
||||
|
||||
public function instructions(): string
|
||||
{
|
||||
return $this->instructions;
|
||||
}
|
||||
|
||||
public function provider(): Lab
|
||||
{
|
||||
return match (config('ai.default')) {
|
||||
'openai' => Lab::OpenAI,
|
||||
default => Lab::Gemini,
|
||||
};
|
||||
}
|
||||
}
|
||||
91
app/Ai/Agents/PostContentGenerator.php
Normal file
91
app/Ai/Agents/PostContentGenerator.php
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Ai\Agents;
|
||||
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Ai\TemplateContextResolver;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Laravel\Ai\Attributes\Temperature;
|
||||
use Laravel\Ai\Contracts\Agent;
|
||||
use Laravel\Ai\Contracts\HasStructuredOutput;
|
||||
use Laravel\Ai\Enums\Lab;
|
||||
use Laravel\Ai\Promptable;
|
||||
|
||||
#[Temperature(0.7)]
|
||||
class PostContentGenerator implements Agent, HasStructuredOutput
|
||||
{
|
||||
use Promptable;
|
||||
|
||||
public function __construct(
|
||||
public Workspace $workspace,
|
||||
public ?string $currentContent = null,
|
||||
public string $format = 'single',
|
||||
public int $slideCount = 1,
|
||||
public ?string $platformContext = null,
|
||||
) {}
|
||||
|
||||
public function instructions(): string
|
||||
{
|
||||
$examples = [];
|
||||
|
||||
if ($this->platformContext !== null) {
|
||||
$resolver = app(TemplateContextResolver::class);
|
||||
$examples = $resolver->relevantFor($this->platformContext, 2)
|
||||
->map(fn ($t) => [
|
||||
'name' => $t->name,
|
||||
'description' => $t->description,
|
||||
'content' => $t->content,
|
||||
'slides' => $t->slides,
|
||||
])
|
||||
->all();
|
||||
}
|
||||
|
||||
return view('prompts.post_content.generator', [
|
||||
'brand_name' => $this->workspace->name ?? '',
|
||||
'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',
|
||||
'current_content' => $this->currentContent,
|
||||
'format' => $this->format,
|
||||
'slide_count' => $this->slideCount,
|
||||
'examples' => $examples,
|
||||
])->render();
|
||||
}
|
||||
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
if ($this->format === 'carousel') {
|
||||
return [
|
||||
'caption' => $schema->string()->description('The Instagram caption for the carousel post.')->required(),
|
||||
'slides' => $schema->array()
|
||||
->items($schema->object(fn ($s) => [
|
||||
'title' => $s->string()->description('Headline of the slide. Short, impactful.')->required(),
|
||||
'body' => $s->string()->description('Supporting text below the headline. 1-3 sentences.')->required(),
|
||||
'image_keywords' => $s->array()->items($schema->string())->description('2-4 search keywords for Unsplash.')->required(),
|
||||
]))
|
||||
->min($this->slideCount)
|
||||
->max($this->slideCount)
|
||||
->description("Exactly {$this->slideCount} slides for the carousel, in order.")
|
||||
->required(),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'content' => $schema->string()->description('The full post caption text that will be published on the platform.')->required(),
|
||||
'image_title' => $schema->string()->description('Short headline (5-12 words) overlaid on the image. The hook — should make a scroller stop. Distinct from content.')->required(),
|
||||
'image_body' => $schema->string()->description('1-2 short sentences (max 25 words) overlaid below the image_title. Expands the hook just enough to compel reading the caption.')->required(),
|
||||
'image_keywords' => $schema->array()->items($schema->string())->description('2-4 search keywords for Unsplash for the single image.')->required(),
|
||||
];
|
||||
}
|
||||
|
||||
public function provider(): Lab
|
||||
{
|
||||
return match (config('ai.default')) {
|
||||
'openai' => Lab::OpenAI,
|
||||
default => Lab::Gemini,
|
||||
};
|
||||
}
|
||||
}
|
||||
71
app/Ai/Agents/PostContentHumanizer.php
Normal file
71
app/Ai/Agents/PostContentHumanizer.php
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Ai\Agents;
|
||||
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Laravel\Ai\Attributes\Temperature;
|
||||
use Laravel\Ai\Contracts\Agent;
|
||||
use Laravel\Ai\Contracts\HasStructuredOutput;
|
||||
use Laravel\Ai\Enums\Lab;
|
||||
use Laravel\Ai\Promptable;
|
||||
|
||||
/**
|
||||
* Second-pass agent that rewrites AI-generated post text to remove AI-tells.
|
||||
* Operates on the same structured shape as PostContentGenerator (single or
|
||||
* carousel) but only touches human-readable text fields — image_keywords pass
|
||||
* through untouched (those need to stay in English for Unsplash regardless).
|
||||
*/
|
||||
#[Temperature(0.4)]
|
||||
class PostContentHumanizer implements Agent, HasStructuredOutput
|
||||
{
|
||||
use Promptable;
|
||||
|
||||
public function __construct(
|
||||
public Workspace $workspace,
|
||||
public string $format = 'single',
|
||||
) {}
|
||||
|
||||
public function instructions(): string
|
||||
{
|
||||
return view('prompts.post_content.humanizer', [
|
||||
'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',
|
||||
'format' => $this->format,
|
||||
])->render();
|
||||
}
|
||||
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
if ($this->format === 'carousel') {
|
||||
return [
|
||||
'caption' => $schema->string()->description('The humanized Instagram caption.')->required(),
|
||||
'slides' => $schema->array()
|
||||
->items($schema->object(fn ($s) => [
|
||||
'title' => $s->string()->description('Humanized slide headline.')->required(),
|
||||
'body' => $s->string()->description('Humanized slide body.')->required(),
|
||||
]))
|
||||
->description('The same number of slides as the input, in the same order, with humanized text.')
|
||||
->required(),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'content' => $schema->string()->description('The humanized post caption.')->required(),
|
||||
'image_title' => $schema->string()->description('The humanized image overlay title.')->required(),
|
||||
'image_body' => $schema->string()->description('The humanized image overlay body.')->required(),
|
||||
];
|
||||
}
|
||||
|
||||
public function provider(): Lab
|
||||
{
|
||||
return match (config('ai.default')) {
|
||||
'openai' => Lab::OpenAI,
|
||||
default => Lab::Gemini,
|
||||
};
|
||||
}
|
||||
}
|
||||
55
app/Ai/Agents/PostContentReviewer.php
Normal file
55
app/Ai/Agents/PostContentReviewer.php
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Ai\Agents;
|
||||
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Laravel\Ai\Attributes\Temperature;
|
||||
use Laravel\Ai\Contracts\Agent;
|
||||
use Laravel\Ai\Contracts\HasStructuredOutput;
|
||||
use Laravel\Ai\Enums\Lab;
|
||||
use Laravel\Ai\Promptable;
|
||||
|
||||
#[Temperature(0.2)]
|
||||
class PostContentReviewer implements Agent, HasStructuredOutput
|
||||
{
|
||||
use Promptable;
|
||||
|
||||
public function __construct(
|
||||
public Workspace $workspace,
|
||||
) {}
|
||||
|
||||
public function instructions(): string
|
||||
{
|
||||
return view('prompts.post_content.reviewer', [
|
||||
'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',
|
||||
])->render();
|
||||
}
|
||||
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
return [
|
||||
'suggestions' => $schema->array()
|
||||
->items($schema->object(fn ($s) => [
|
||||
'original' => $s->string()->description('The exact substring of the input that needs correction.')->required(),
|
||||
'suggestion' => $s->string()->description('The corrected version.')->required(),
|
||||
'reason' => $s->string()->description('1-line explanation in the output language.')->required(),
|
||||
]))
|
||||
->description('Up to 8 grammar/spelling/clarity suggestions. Empty array if the text is fine.')
|
||||
->required(),
|
||||
];
|
||||
}
|
||||
|
||||
public function provider(): Lab
|
||||
{
|
||||
return match (config('ai.default')) {
|
||||
'openai' => Lab::OpenAI,
|
||||
default => Lab::Gemini,
|
||||
};
|
||||
}
|
||||
}
|
||||
52
app/Ai/Agents/PostContentStreamer.php
Normal file
52
app/Ai/Agents/PostContentStreamer.php
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Ai\Agents;
|
||||
|
||||
use App\Models\Workspace;
|
||||
use Laravel\Ai\Attributes\Temperature;
|
||||
use Laravel\Ai\Contracts\Agent;
|
||||
use Laravel\Ai\Enums\Lab;
|
||||
use Laravel\Ai\Promptable;
|
||||
|
||||
/**
|
||||
* Streaming-compatible agent for inline post content generation in the editor.
|
||||
*
|
||||
* This is a separate agent from PostContentGenerator because the Laravel AI SDK
|
||||
* does not support streaming with HasStructuredOutput. Use this agent for
|
||||
* broadcast()/stream() calls; use PostContentGenerator for prompt() with
|
||||
* structured output in the AI wizard pipeline.
|
||||
*/
|
||||
#[Temperature(0.7)]
|
||||
class PostContentStreamer implements Agent
|
||||
{
|
||||
use Promptable;
|
||||
|
||||
public function __construct(
|
||||
public Workspace $workspace,
|
||||
public ?string $currentContent = null,
|
||||
) {}
|
||||
|
||||
public function instructions(): string
|
||||
{
|
||||
return view('prompts.post_content.generator', [
|
||||
'brand_name' => $this->workspace->name ?? '',
|
||||
'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',
|
||||
'current_content' => $this->currentContent,
|
||||
'format' => 'single',
|
||||
'slide_count' => 1,
|
||||
])->render();
|
||||
}
|
||||
|
||||
public function provider(): Lab
|
||||
{
|
||||
return match (config('ai.default')) {
|
||||
'openai' => Lab::OpenAI,
|
||||
default => Lab::Gemini,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,184 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Ai\Agents;
|
||||
|
||||
use App\Ai\Middleware\DebugGeminiRequest;
|
||||
use App\Ai\PlatformRules\Contract;
|
||||
use App\Ai\PlatformRules\Registry as PlatformRulesRegistry;
|
||||
use App\Ai\Tools\GenerateImage;
|
||||
use App\Ai\Tools\GenerateVideo;
|
||||
use App\Enums\SocialAccount\Platform;
|
||||
use App\Models\AiMessage;
|
||||
use App\Models\Post;
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Ai\Attributes\MaxSteps;
|
||||
use Laravel\Ai\Attributes\Temperature;
|
||||
use Laravel\Ai\Contracts\Agent;
|
||||
use Laravel\Ai\Contracts\Conversational;
|
||||
use Laravel\Ai\Contracts\HasMiddleware;
|
||||
use Laravel\Ai\Contracts\HasStructuredOutput;
|
||||
use Laravel\Ai\Contracts\HasTools;
|
||||
use Laravel\Ai\Contracts\Tool;
|
||||
use Laravel\Ai\Enums\Lab;
|
||||
use Laravel\Ai\Messages\Message;
|
||||
use Laravel\Ai\Promptable;
|
||||
|
||||
#[Temperature(0.3)]
|
||||
#[MaxSteps(3)]
|
||||
class SocialMediaAssistant implements Agent, Conversational, HasMiddleware, HasStructuredOutput, HasTools
|
||||
{
|
||||
use Promptable;
|
||||
|
||||
public function __construct(
|
||||
public Workspace $workspace,
|
||||
public ?Post $post = null,
|
||||
public ?string $userId = null,
|
||||
) {}
|
||||
|
||||
public function instructions(): string
|
||||
{
|
||||
return view('prompts.assistant.system', [
|
||||
'brand_name' => $this->workspace->name ?? '',
|
||||
'brand_description' => $this->workspace->brand_description ?? '',
|
||||
'brand_website' => $this->workspace->brand_website ?? '',
|
||||
'tone' => $this->workspace->brand_tone ?? 'professional',
|
||||
'voice_notes' => $this->workspace->brand_voice_notes ?? '',
|
||||
'content_language' => $this->workspace->content_language ?? 'en',
|
||||
'platform_rules' => $this->activePlatformRules(),
|
||||
'connected_platforms' => $this->connectedPlatformLabels(),
|
||||
])->render();
|
||||
}
|
||||
|
||||
public function contentLanguage(): string
|
||||
{
|
||||
return $this->workspace->content_language ?? 'en';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, Contract>
|
||||
*/
|
||||
private function activePlatformRules(): array
|
||||
{
|
||||
if (! $this->post) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$platforms = $this->post->postPlatforms
|
||||
->pluck('platform')
|
||||
->filter()
|
||||
->map(fn ($p) => $p instanceof Platform ? $p : Platform::tryFrom((string) $p))
|
||||
->filter()
|
||||
->unique(fn (Platform $p) => $p->value)
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return PlatformRulesRegistry::forMany($platforms);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{slug: string, label: string}>
|
||||
*/
|
||||
private function connectedPlatformLabels(): array
|
||||
{
|
||||
return $this->workspace->socialAccounts()
|
||||
->active()
|
||||
->get()
|
||||
->map(fn ($account) => [
|
||||
'slug' => $account->platform->value,
|
||||
'label' => $account->platform->label(),
|
||||
])
|
||||
->unique('slug')
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<Message>
|
||||
*/
|
||||
public function messages(): iterable
|
||||
{
|
||||
if (! $this->post) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return AiMessage::query()
|
||||
->where('post_id', $this->post->id)
|
||||
->whereIn('role', ['user', 'assistant'])
|
||||
->oldest()
|
||||
->orderBy('id')
|
||||
->limit(20)
|
||||
->get()
|
||||
->map(fn (AiMessage $m) => new Message($m->role, $this->enrichContent($m)))
|
||||
->all();
|
||||
}
|
||||
|
||||
public function provider(): Lab
|
||||
{
|
||||
return match (config('ai.default')) {
|
||||
'openai' => Lab::OpenAI,
|
||||
default => Lab::Gemini,
|
||||
};
|
||||
}
|
||||
|
||||
public function middleware(): array
|
||||
{
|
||||
return [
|
||||
new DebugGeminiRequest,
|
||||
];
|
||||
}
|
||||
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
return [
|
||||
'message' => $schema->string()
|
||||
->description('Your response. 1-2 sentences max. No emojis. No brand pitching.')
|
||||
->required(),
|
||||
'quick_actions' => $schema->array()
|
||||
->items($schema->object(fn ($s) => [
|
||||
'label' => $s->string()->description('Button text, no emojis, max 20 chars.')->required(),
|
||||
'value' => $s->string()->description('Same as label.')->required(),
|
||||
]))
|
||||
->description('Buttons for FINITE choices only (format, platform, confirm). Empty array for open-ended questions. Max 4 items.')
|
||||
->required(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<Tool>
|
||||
*/
|
||||
public function tools(): iterable
|
||||
{
|
||||
return [
|
||||
new GenerateImage(
|
||||
workspace: $this->workspace,
|
||||
post: $this->post,
|
||||
userId: $this->userId,
|
||||
),
|
||||
new GenerateVideo(
|
||||
workspace: $this->workspace,
|
||||
post: $this->post,
|
||||
userId: $this->userId,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
private function enrichContent(AiMessage $m): string
|
||||
{
|
||||
$content = $m->content;
|
||||
|
||||
if ($m->role === 'assistant' && ! empty($m->attachments)) {
|
||||
$counts = collect($m->attachments)
|
||||
->groupBy('type')
|
||||
->map(fn ($group, $type) => count($group).' '.Str::plural((string) $type, count($group)))
|
||||
->implode(', ');
|
||||
|
||||
$content .= "\n\n[This assistant message attached: {$counts}]";
|
||||
}
|
||||
|
||||
return $content;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Ai\Tools;
|
||||
|
||||
/**
|
||||
* Request-scoped side-channel for tools to surface structured attachments.
|
||||
*
|
||||
* Laravel AI SDK tools return Stringable|string to the LLM, so they can't
|
||||
* pass structured data back to the controller directly. This collector is
|
||||
* registered as a scoped singleton — the controller clears it before the
|
||||
* agent prompt, tools push attachments into it during execution, and the
|
||||
* controller reads $collector->all() after the agent finishes to persist
|
||||
* them onto the resulting AiMessage.
|
||||
*/
|
||||
class AttachmentCollector
|
||||
{
|
||||
/**
|
||||
* @var array<int, array<string, mixed>>
|
||||
*/
|
||||
private array $attachments = [];
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $attachment
|
||||
*/
|
||||
public function push(array $attachment): void
|
||||
{
|
||||
$this->attachments[] = $attachment;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function all(): array
|
||||
{
|
||||
return $this->attachments;
|
||||
}
|
||||
|
||||
public function clear(): void
|
||||
{
|
||||
$this->attachments = [];
|
||||
}
|
||||
}
|
||||
|
|
@ -1,103 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Ai\Tools;
|
||||
|
||||
use App\Enums\Ai\UsageType;
|
||||
use App\Features\AiVideosLimit;
|
||||
use App\Models\AiUsageLog;
|
||||
use App\Models\Post;
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Ai\Audio;
|
||||
use Laravel\Ai\Contracts\Tool;
|
||||
use Laravel\Ai\Tools\Request;
|
||||
use Laravel\Pennant\Feature;
|
||||
use Stringable;
|
||||
|
||||
class GenerateAudio implements Tool
|
||||
{
|
||||
public function __construct(
|
||||
public Workspace $workspace,
|
||||
public ?Post $post = null,
|
||||
public ?string $userId = null,
|
||||
public ?AttachmentCollector $collector = null,
|
||||
) {
|
||||
$this->collector ??= app(AttachmentCollector::class);
|
||||
}
|
||||
|
||||
public function description(): Stringable|string
|
||||
{
|
||||
return <<<'TXT'
|
||||
Generate an AI voiceover or narration audio from text.
|
||||
|
||||
Call this when the user asks for a voiceover, narration, TTS, podcast clip,
|
||||
or any voice content. The output language will match the language of the
|
||||
input text — so pass the text in the target language.
|
||||
|
||||
Audio is generated via ElevenLabs with the configured default voice.
|
||||
TXT;
|
||||
}
|
||||
|
||||
public function handle(Request $request): Stringable|string
|
||||
{
|
||||
// Audio generation counts against the monthly video quota by design.
|
||||
$limit = (int) Feature::for($this->workspace->account)->value(AiVideosLimit::class);
|
||||
$used = AiUsageLog::monthlyCount($this->workspace->account_id, UsageType::Video)
|
||||
+ AiUsageLog::monthlyCount($this->workspace->account_id, UsageType::Audio);
|
||||
|
||||
if ($used >= $limit) {
|
||||
return "Audio and video share a monthly quota that is exhausted ({$used} of {$limit} used). Ask the user to upgrade their plan or wait until next month.";
|
||||
}
|
||||
|
||||
$text = (string) data_get($request, 'text', '');
|
||||
$voice = config('services.elevenlabs.default_voice', 'EXAVITQu4vr4xnSDxMaL');
|
||||
|
||||
$response = Audio::of($text)->voice($voice)->generate();
|
||||
|
||||
$storedPath = $response->store('medias', 'public');
|
||||
|
||||
$media = $this->workspace->media()->create([
|
||||
'group_id' => Str::uuid()->toString(),
|
||||
'collection' => 'assets',
|
||||
'type' => 'video',
|
||||
'path' => $storedPath,
|
||||
'original_filename' => 'ai-generated.mp3',
|
||||
'mime_type' => 'audio/mpeg',
|
||||
'size' => Storage::disk('public')->size($storedPath),
|
||||
'order' => 0,
|
||||
'meta' => ['ai_generated' => true, 'text' => Str::limit($text, 200)],
|
||||
]);
|
||||
|
||||
AiUsageLog::create([
|
||||
'account_id' => $this->workspace->account_id,
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'user_id' => $this->userId,
|
||||
'post_id' => $this->post?->id,
|
||||
'type' => UsageType::Audio,
|
||||
'provider' => 'elevenlabs',
|
||||
]);
|
||||
|
||||
$this->collector->push([
|
||||
'id' => $media->id,
|
||||
'path' => $media->path,
|
||||
'url' => $media->url,
|
||||
'mime_type' => 'audio/mpeg',
|
||||
'type' => 'audio',
|
||||
]);
|
||||
|
||||
return "Generated audio (id: {$media->id}) and attached it to the post.";
|
||||
}
|
||||
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
return [
|
||||
'text' => $schema->string()
|
||||
->description('The text to convert into speech. Write it in the target language — the output voice language will match.')
|
||||
->required(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -1,89 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Ai\Tools;
|
||||
|
||||
use App\Actions\Ai\GenerateImage as GenerateImageAction;
|
||||
use App\Enums\Ai\Orientation;
|
||||
use App\Enums\Media\Type as MediaType;
|
||||
use App\Exceptions\Ai\QuotaExhaustedException;
|
||||
use App\Models\Post;
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Laravel\Ai\Contracts\Tool;
|
||||
use Laravel\Ai\Tools\Request;
|
||||
use Stringable;
|
||||
|
||||
class GenerateImage implements Tool
|
||||
{
|
||||
public function __construct(
|
||||
public Workspace $workspace,
|
||||
public ?Post $post = null,
|
||||
public ?string $userId = null,
|
||||
public ?AttachmentCollector $collector = null,
|
||||
) {
|
||||
$this->collector ??= app(AttachmentCollector::class);
|
||||
}
|
||||
|
||||
public function description(): Stringable|string
|
||||
{
|
||||
return <<<'TXT'
|
||||
Generate an AI image and attach it to the current post.
|
||||
|
||||
Call this when the user asks for an image, photo, carousel slide, or any visual content.
|
||||
Pass a detailed visual prompt describing what to generate, and an orientation:
|
||||
- "square" (1:1) for LinkedIn, Facebook, or when user wants square
|
||||
- "portrait" (4:5) for Instagram Feed, Threads
|
||||
- "vertical" (9:16) for Instagram Reel/Story, Pinterest Pin, TikTok
|
||||
- "horizontal" (16:9) for X/Twitter, YouTube thumbnail
|
||||
|
||||
Choose the orientation that best matches the target platform.
|
||||
|
||||
The image is generated, stored, registered in the workspace's media library,
|
||||
logged in monthly usage tracking, and attached to the assistant's response message.
|
||||
TXT;
|
||||
}
|
||||
|
||||
public function handle(Request $request): Stringable|string
|
||||
{
|
||||
$prompt = (string) data_get($request, 'prompt', '');
|
||||
$orientationString = (string) data_get($request, 'orientation', 'vertical');
|
||||
$orientation = Orientation::tryFrom($orientationString) ?? Orientation::Portrait;
|
||||
|
||||
try {
|
||||
$media = GenerateImageAction::execute(
|
||||
workspace: $this->workspace,
|
||||
prompt: $prompt,
|
||||
orientation: $orientation,
|
||||
userId: $this->userId,
|
||||
postId: $this->post?->id,
|
||||
);
|
||||
} catch (QuotaExhaustedException $e) {
|
||||
return "Image quota exhausted this month ({$e->used} of {$e->limit} used). Ask the user to upgrade their plan or wait until next month.";
|
||||
}
|
||||
|
||||
$this->collector->push([
|
||||
'id' => $media->id,
|
||||
'path' => $media->path,
|
||||
'url' => $media->url,
|
||||
'mime_type' => 'image/png',
|
||||
'type' => MediaType::Image->value,
|
||||
]);
|
||||
|
||||
return "Generated a {$orientationString} image (id: {$media->id}) and attached it to the post.";
|
||||
}
|
||||
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
return [
|
||||
'prompt' => $schema->string()
|
||||
->description('A detailed visual description of the image to generate. Include subject, style, composition, mood, and any text that should appear in the image.')
|
||||
->required(),
|
||||
'orientation' => $schema->string()
|
||||
->enum(['square', 'portrait', 'vertical', 'horizontal'])
|
||||
->description('"square" (1:1) for LinkedIn, Facebook. "portrait" (4:5) for Instagram Feed, Threads. "vertical" (9:16) for Instagram Reel/Story, Pinterest, TikTok. "horizontal" (16:9) for X/Twitter.')
|
||||
->required(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -1,86 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Ai\Tools;
|
||||
|
||||
use App\Actions\Ai\GenerateVideo as GenerateVideoAction;
|
||||
use App\Enums\Ai\Orientation;
|
||||
use App\Enums\Media\Type as MediaType;
|
||||
use App\Exceptions\Ai\QuotaExhaustedException;
|
||||
use App\Models\Post;
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Laravel\Ai\Contracts\Tool;
|
||||
use Laravel\Ai\Tools\Request;
|
||||
use Stringable;
|
||||
|
||||
class GenerateVideo implements Tool
|
||||
{
|
||||
public function __construct(
|
||||
public Workspace $workspace,
|
||||
public ?Post $post = null,
|
||||
public ?string $userId = null,
|
||||
public ?AttachmentCollector $collector = null,
|
||||
) {
|
||||
$this->collector ??= app(AttachmentCollector::class);
|
||||
}
|
||||
|
||||
public function description(): Stringable|string
|
||||
{
|
||||
return <<<'TXT'
|
||||
Generate a short AI video and attach it to the current post.
|
||||
|
||||
Call this when the user asks for a video, Reel, TikTok, YouTube Short,
|
||||
Facebook Reel, or any motion content. Pass a detailed visual description
|
||||
and an orientation:
|
||||
- "vertical" (9:16) for TikTok, Instagram Reel, YouTube Shorts, Facebook Reel, Pinterest Video Pin
|
||||
- "horizontal" (16:9) for X/Twitter video, LinkedIn video
|
||||
|
||||
Videos are generated via Veo 3.1 (not yet supported by the SDK's Image/Audio
|
||||
entry points), which this tool wraps internally.
|
||||
TXT;
|
||||
}
|
||||
|
||||
public function handle(Request $request): Stringable|string
|
||||
{
|
||||
$prompt = (string) data_get($request, 'prompt', '');
|
||||
$orientationString = (string) data_get($request, 'orientation', 'vertical');
|
||||
$orientation = Orientation::tryFrom($orientationString) ?? Orientation::Vertical;
|
||||
|
||||
try {
|
||||
$media = GenerateVideoAction::execute(
|
||||
workspace: $this->workspace,
|
||||
prompt: $prompt,
|
||||
orientation: $orientation,
|
||||
userId: $this->userId,
|
||||
postId: $this->post?->id,
|
||||
);
|
||||
} catch (QuotaExhaustedException $e) {
|
||||
return "Video quota exhausted this month ({$e->used} of {$e->limit} used). Ask the user to upgrade their plan or wait until next month.";
|
||||
}
|
||||
|
||||
$this->collector->push([
|
||||
'id' => $media->id,
|
||||
'path' => $media->path,
|
||||
'url' => $media->url,
|
||||
'mime_type' => 'video/mp4',
|
||||
'type' => MediaType::Video->value,
|
||||
]);
|
||||
|
||||
return "Generated a {$orientationString} video (id: {$media->id}) and attached it to the post.";
|
||||
}
|
||||
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
return [
|
||||
'prompt' => $schema->string()
|
||||
->description('A detailed visual description of the video to generate. Include subject, motion, style, mood, and any key moments.')
|
||||
->required(),
|
||||
'orientation' => $schema->string()
|
||||
->enum(['vertical', 'horizontal'])
|
||||
->description('"vertical" for 9:16 (TikTok, Reels, Shorts, Stories). "horizontal" for 16:9 (X, LinkedIn, Facebook).')
|
||||
->required(),
|
||||
];
|
||||
}
|
||||
}
|
||||
15
app/Broadcasting/UserAiCreationChannel.php
Normal file
15
app/Broadcasting/UserAiCreationChannel.php
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Broadcasting;
|
||||
|
||||
use App\Models\User;
|
||||
|
||||
class UserAiCreationChannel
|
||||
{
|
||||
public function join(User $user, string $userId, string $creationId): bool
|
||||
{
|
||||
return $user->id === $userId;
|
||||
}
|
||||
}
|
||||
15
app/Broadcasting/UserAiGenerationChannel.php
Normal file
15
app/Broadcasting/UserAiGenerationChannel.php
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Broadcasting;
|
||||
|
||||
use App\Models\User;
|
||||
|
||||
class UserAiGenerationChannel
|
||||
{
|
||||
public function join(User $user, string $userId, string $generationId): bool
|
||||
{
|
||||
return $user->id === $userId;
|
||||
}
|
||||
}
|
||||
232
app/Console/Commands/AiTestRender.php
Normal file
232
app/Console/Commands/AiTestRender.php
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Enums\Media\Type;
|
||||
use App\Models\Media;
|
||||
use App\Models\Post;
|
||||
use App\Models\SocialAccount;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Image\TemplateImageGenerator;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/**
|
||||
* Debug helper to iterate on the AI image template visuals without going through
|
||||
* the full wizard + AI flow.
|
||||
*
|
||||
* php artisan ai:test-render --template=A --title="Some title" --body="Some body" --keywords="kitchen,team"
|
||||
* php artisan ai:test-render --post=019deae5-... --template=A
|
||||
*
|
||||
* Outputs the storage path so you can open the file directly.
|
||||
*/
|
||||
class AiTestRender extends Command
|
||||
{
|
||||
protected $signature = 'ai:test-render
|
||||
{--template=A : Template to render (A or B)}
|
||||
{--title= : Slide title}
|
||||
{--body= : Slide body}
|
||||
{--keywords= : Comma-separated Unsplash keywords}
|
||||
{--post= : Post UUID to re-render images for (replaces Post.media in place)}
|
||||
{--workspace= : Workspace UUID (defaults to first)}
|
||||
{--account= : Social account UUID (defaults to first connected on workspace)}
|
||||
{--width=1080 : Canvas width in pixels}
|
||||
{--height=1350 : Canvas height in pixels}';
|
||||
|
||||
protected $description = 'Render an AI image template with custom inputs (debug).';
|
||||
|
||||
public function handle(TemplateImageGenerator $generator): int
|
||||
{
|
||||
$workspace = $this->resolveWorkspace();
|
||||
if (! $workspace) {
|
||||
$this->error('No workspace found. Use --workspace=<uuid>.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$socialAccount = $this->resolveSocialAccount($workspace);
|
||||
if (! $socialAccount) {
|
||||
$this->error('No social account on workspace. Use --account=<uuid> or connect one.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if ($this->option('post')) {
|
||||
return $this->rerenderPost($generator, $workspace, $socialAccount);
|
||||
}
|
||||
|
||||
return $this->renderSingle($generator, $workspace, $socialAccount);
|
||||
}
|
||||
|
||||
private function renderSingle(TemplateImageGenerator $generator, Workspace $workspace, SocialAccount $socialAccount): int
|
||||
{
|
||||
$template = strtoupper((string) $this->option('template'));
|
||||
$title = (string) ($this->option('title') ?: 'Hello world');
|
||||
$body = (string) ($this->option('body') ?: 'A short body that gives more context about the slide.');
|
||||
$keywords = array_values(array_filter(array_map('trim', explode(',', (string) $this->option('keywords'))))) ?: ['business'];
|
||||
|
||||
$this->info("Rendering Template {$template}...");
|
||||
$this->line(" title: {$title}");
|
||||
$this->line(" body: {$body}");
|
||||
$this->line(' keywords: '.implode(', ', $keywords));
|
||||
|
||||
$path = $generator->render(
|
||||
template: $template,
|
||||
workspace: $workspace,
|
||||
socialAccount: $socialAccount,
|
||||
title: $title,
|
||||
body: $body,
|
||||
imageKeywords: $keywords,
|
||||
width: (int) $this->option('width'),
|
||||
height: (int) $this->option('height'),
|
||||
);
|
||||
|
||||
if (! $path) {
|
||||
$this->error('Render failed. Check Unsplash key + recent logs.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->info('OK');
|
||||
$this->line('storage path: '.$path);
|
||||
$this->line('absolute: '.Storage::path($path));
|
||||
$this->line('public url: '.Storage::url($path));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function rerenderPost(TemplateImageGenerator $generator, Workspace $workspace, SocialAccount $socialAccount): int
|
||||
{
|
||||
$postId = (string) $this->option('post');
|
||||
$post = Post::query()->where('id', $postId)->first();
|
||||
if (! $post) {
|
||||
$this->error("Post {$postId} not found.");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
if ($post->workspace_id !== $workspace->id) {
|
||||
$this->error("Post {$postId} belongs to a different workspace.");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$template = strtoupper((string) ($this->option('template') ?: 'A'));
|
||||
$media = $post->media ?? [];
|
||||
if (empty($media)) {
|
||||
$this->error('Post has no media items. Use direct mode (--title/--body/--keywords) instead.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->info("Re-rendering {$template} for ".count($media).' slide(s)...');
|
||||
|
||||
$rendered = [];
|
||||
$newMedia = [];
|
||||
foreach ($media as $i => $item) {
|
||||
$isClosing = (bool) data_get($item, 'meta.is_closing', false);
|
||||
|
||||
$width = (int) $this->option('width');
|
||||
$height = (int) $this->option('height');
|
||||
|
||||
if ($isClosing) {
|
||||
$this->line(" [{$i}] template=C (closing)");
|
||||
$path = $generator->renderClosing(
|
||||
workspace: $workspace,
|
||||
socialAccount: $socialAccount,
|
||||
width: $width,
|
||||
height: $height,
|
||||
);
|
||||
} else {
|
||||
$title = data_get($item, 'meta.slide_title') ?? 'Slide '.($i + 1);
|
||||
$body = data_get($item, 'meta.slide_body') ?? ($post->content ?? '');
|
||||
$keywords = data_get($item, 'meta.slide_keywords') ?: ['business'];
|
||||
// First slide is always Template A; subsequent slides alternate.
|
||||
$slotTemplate = $i === 0 ? 'A' : ($i % 2 === 0 ? 'A' : 'B');
|
||||
|
||||
$this->line(" [{$i}] template={$slotTemplate} title={$title}");
|
||||
|
||||
$path = $generator->render(
|
||||
template: $slotTemplate,
|
||||
workspace: $workspace,
|
||||
socialAccount: $socialAccount,
|
||||
title: $title,
|
||||
body: $body,
|
||||
imageKeywords: $keywords,
|
||||
width: $width,
|
||||
height: $height,
|
||||
);
|
||||
}
|
||||
|
||||
if ($path) {
|
||||
$rendered[] = $path;
|
||||
$this->line(' → '.$path);
|
||||
$newMedia[] = $this->replaceMediaItem($workspace, $item, $path);
|
||||
} else {
|
||||
$this->warn(' → (failed)');
|
||||
$newMedia[] = $item;
|
||||
}
|
||||
}
|
||||
|
||||
$post->media = $newMedia;
|
||||
$post->save();
|
||||
|
||||
$this->info('Done. '.count($rendered).' image(s) replaced in post.');
|
||||
foreach ($rendered as $p) {
|
||||
$this->line(' '.Storage::url($p));
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a new Media row pointing at $path and return a media-array shape
|
||||
* matching the existing $original entry (preserves meta.slide_*).
|
||||
*
|
||||
* @param array<string, mixed> $original
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function replaceMediaItem(Workspace $workspace, array $original, string $path): array
|
||||
{
|
||||
$media = new Media([
|
||||
'collection' => 'ai-generated',
|
||||
'type' => Type::Image,
|
||||
'path' => $path,
|
||||
'original_filename' => basename($path),
|
||||
'mime_type' => 'image/webp',
|
||||
'size' => Storage::size($path),
|
||||
'order' => 0,
|
||||
]);
|
||||
$media->mediable_type = Workspace::class;
|
||||
$media->mediable_id = $workspace->id;
|
||||
$media->save();
|
||||
|
||||
return [
|
||||
'id' => $media->id,
|
||||
'path' => $media->path,
|
||||
'url' => $media->url,
|
||||
'type' => 'image',
|
||||
'mime_type' => 'image/webp',
|
||||
'meta' => data_get($original, 'meta', []),
|
||||
];
|
||||
}
|
||||
|
||||
private function resolveWorkspace(): ?Workspace
|
||||
{
|
||||
if ($id = $this->option('workspace')) {
|
||||
return Workspace::query()->where('id', $id)->first();
|
||||
}
|
||||
|
||||
return Workspace::query()->first();
|
||||
}
|
||||
|
||||
private function resolveSocialAccount(Workspace $workspace): ?SocialAccount
|
||||
{
|
||||
if ($id = $this->option('account')) {
|
||||
return $workspace->socialAccounts()->where('id', $id)->first();
|
||||
}
|
||||
|
||||
return $workspace->socialAccounts()->first();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enums\Ai;
|
||||
|
||||
enum Intent: string
|
||||
{
|
||||
case Text = 'text';
|
||||
case Image = 'image';
|
||||
case Audio = 'audio';
|
||||
case Video = 'video';
|
||||
case Blocked = 'blocked';
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enums\AiMessage;
|
||||
|
||||
enum Status: string
|
||||
{
|
||||
case Pending = 'pending';
|
||||
case Generating = 'generating';
|
||||
case Completed = 'completed';
|
||||
case Failed = 'failed';
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ enum ContentType: string
|
|||
{
|
||||
// Instagram
|
||||
case InstagramFeed = 'instagram_feed';
|
||||
case InstagramCarousel = 'instagram_carousel';
|
||||
case InstagramReel = 'instagram_reel';
|
||||
case InstagramStory = 'instagram_story';
|
||||
|
||||
|
|
@ -53,6 +54,7 @@ public function label(): string
|
|||
{
|
||||
return match ($this) {
|
||||
self::InstagramFeed => 'Feed Post',
|
||||
self::InstagramCarousel => 'Carousel',
|
||||
self::InstagramReel => 'Reel',
|
||||
self::InstagramStory => 'Story',
|
||||
self::LinkedInPost, self::LinkedInPagePost => 'Post',
|
||||
|
|
@ -76,6 +78,7 @@ public function description(): string
|
|||
{
|
||||
return match ($this) {
|
||||
self::InstagramFeed => 'Appears in your feed and profile',
|
||||
self::InstagramCarousel => 'Multi-slide swipeable post (2-10 images)',
|
||||
self::InstagramReel => 'Short video up to 90 seconds',
|
||||
self::InstagramStory => 'Disappears after 24 hours',
|
||||
self::LinkedInPost, self::LinkedInPagePost => 'Standard post with text and media',
|
||||
|
|
@ -98,7 +101,7 @@ public function description(): string
|
|||
public function platform(): SocialPlatform
|
||||
{
|
||||
return match ($this) {
|
||||
self::InstagramFeed, self::InstagramReel, self::InstagramStory => SocialPlatform::Instagram,
|
||||
self::InstagramFeed, self::InstagramCarousel, self::InstagramReel, self::InstagramStory => SocialPlatform::Instagram,
|
||||
self::LinkedInPost, self::LinkedInCarousel => SocialPlatform::LinkedIn,
|
||||
self::LinkedInPagePost, self::LinkedInPageCarousel => SocialPlatform::LinkedInPage,
|
||||
self::FacebookPost, self::FacebookReel, self::FacebookStory => SocialPlatform::Facebook,
|
||||
|
|
@ -112,10 +115,44 @@ public function platform(): SocialPlatform
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Image dimensions used by the AI generator for this format.
|
||||
* Single source of truth — `TemplateImageGenerator` reads from here.
|
||||
*
|
||||
* @return array{width: int, height: int}
|
||||
*/
|
||||
public function aiImageDimensions(): array
|
||||
{
|
||||
return match ($this) {
|
||||
// Vertical 4:5 (Instagram preferred portrait, Threads mirrors it)
|
||||
self::InstagramFeed,
|
||||
self::InstagramCarousel,
|
||||
self::ThreadsPost => ['width' => 1080, 'height' => 1350],
|
||||
|
||||
// Square 1:1 (LinkedIn, X, Facebook, Bluesky, Mastodon)
|
||||
self::LinkedInPost,
|
||||
self::LinkedInPagePost,
|
||||
self::FacebookPost,
|
||||
self::XPost,
|
||||
self::BlueskyPost,
|
||||
self::MastodonPost => ['width' => 1080, 'height' => 1080],
|
||||
|
||||
// Stories 9:16 (Instagram + Facebook)
|
||||
self::InstagramStory,
|
||||
self::FacebookStory => ['width' => 1080, 'height' => 1920],
|
||||
|
||||
// Pinterest pin 2:3
|
||||
self::PinterestPin => ['width' => 1000, 'height' => 1500],
|
||||
|
||||
// Default: 4:5 portrait (used for any other case)
|
||||
default => ['width' => 1080, 'height' => 1350],
|
||||
};
|
||||
}
|
||||
|
||||
public function aspectRatio(): ?string
|
||||
{
|
||||
return match ($this) {
|
||||
self::InstagramFeed => '1:1',
|
||||
self::InstagramFeed, self::InstagramCarousel => '4:5',
|
||||
self::InstagramReel, self::InstagramStory => '9:16',
|
||||
self::FacebookReel, self::FacebookStory => '9:16',
|
||||
self::TikTokVideo, self::YouTubeShort => '9:16',
|
||||
|
|
@ -128,7 +165,8 @@ public function aspectRatio(): ?string
|
|||
public function maxMediaCount(): int
|
||||
{
|
||||
return match ($this) {
|
||||
self::InstagramFeed => 10,
|
||||
self::InstagramFeed => 1,
|
||||
self::InstagramCarousel => 10,
|
||||
self::InstagramReel, self::InstagramStory => 1,
|
||||
self::LinkedInPost, self::LinkedInPagePost => 1,
|
||||
self::LinkedInCarousel, self::LinkedInPageCarousel => 20,
|
||||
|
|
@ -149,6 +187,7 @@ public function supportsVideo(): bool
|
|||
{
|
||||
return match ($this) {
|
||||
self::InstagramFeed, self::InstagramReel, self::InstagramStory => true,
|
||||
self::InstagramCarousel => false,
|
||||
self::LinkedInPost, self::LinkedInPagePost => true,
|
||||
self::LinkedInCarousel, self::LinkedInPageCarousel => false,
|
||||
self::FacebookPost, self::FacebookReel, self::FacebookStory => true,
|
||||
|
|
@ -178,15 +217,39 @@ public function requiresMedia(): bool
|
|||
{
|
||||
return match ($this) {
|
||||
self::LinkedInPost, self::LinkedInPagePost => false,
|
||||
self::FacebookPost => false,
|
||||
self::XPost => false,
|
||||
self::ThreadsPost => false,
|
||||
self::BlueskyPost => false,
|
||||
self::MastodonPost => false,
|
||||
self::InstagramFeed => false,
|
||||
default => true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Content types that the AI generator currently supports. Reels/stories/
|
||||
* videos are excluded because the AI flow only produces text + images.
|
||||
*
|
||||
* @return array<self>
|
||||
*/
|
||||
public static function aiSupported(): array
|
||||
{
|
||||
return [
|
||||
self::InstagramFeed,
|
||||
self::InstagramCarousel,
|
||||
self::InstagramStory,
|
||||
self::LinkedInPost,
|
||||
self::LinkedInPagePost,
|
||||
self::XPost,
|
||||
self::ThreadsPost,
|
||||
self::BlueskyPost,
|
||||
self::MastodonPost,
|
||||
self::FacebookPost,
|
||||
self::FacebookStory,
|
||||
self::PinterestPin,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all content types for a specific platform.
|
||||
*
|
||||
|
|
|
|||
74
app/Enums/Workspace/BrandFont.php
Normal file
74
app/Enums/Workspace/BrandFont.php
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enums\Workspace;
|
||||
|
||||
/**
|
||||
* Curated list of the most popular Google Fonts a workspace can pick as its
|
||||
* brand font. The string value matches the Google Fonts API family name so
|
||||
* the frontend can load it directly via the Fonts CSS endpoint.
|
||||
*/
|
||||
enum BrandFont: string
|
||||
{
|
||||
case Inter = 'Inter';
|
||||
case Roboto = 'Roboto';
|
||||
case OpenSans = 'Open Sans';
|
||||
case NotoSans = 'Noto Sans';
|
||||
case Montserrat = 'Montserrat';
|
||||
case Poppins = 'Poppins';
|
||||
case Lato = 'Lato';
|
||||
case SourceSans3 = 'Source Sans 3';
|
||||
case RobotoCondensed = 'Roboto Condensed';
|
||||
case Oswald = 'Oswald';
|
||||
case Raleway = 'Raleway';
|
||||
case RobotoMono = 'Roboto Mono';
|
||||
case Nunito = 'Nunito';
|
||||
case Ubuntu = 'Ubuntu';
|
||||
case RobotoSlab = 'Roboto Slab';
|
||||
case Merriweather = 'Merriweather';
|
||||
case PlayfairDisplay = 'Playfair Display';
|
||||
case Rubik = 'Rubik';
|
||||
case PtSans = 'PT Sans';
|
||||
case WorkSans = 'Work Sans';
|
||||
case Mukta = 'Mukta';
|
||||
case NotoSerif = 'Noto Serif';
|
||||
case Lora = 'Lora';
|
||||
case Quicksand = 'Quicksand';
|
||||
case Kanit = 'Kanit';
|
||||
case Inconsolata = 'Inconsolata';
|
||||
case Heebo = 'Heebo';
|
||||
case DmSans = 'DM Sans';
|
||||
case Barlow = 'Barlow';
|
||||
case Karla = 'Karla';
|
||||
case Manrope = 'Manrope';
|
||||
case Mulish = 'Mulish';
|
||||
case BebasNeue = 'Bebas Neue';
|
||||
case Cabin = 'Cabin';
|
||||
case PublicSans = 'Public Sans';
|
||||
case FiraSans = 'Fira Sans';
|
||||
case Dosis = 'Dosis';
|
||||
case PlusJakartaSans = 'Plus Jakarta Sans';
|
||||
case Outfit = 'Outfit';
|
||||
case CormorantGaramond = 'Cormorant Garamond';
|
||||
case SourceSerif4 = 'Source Serif 4';
|
||||
case CrimsonPro = 'Crimson Pro';
|
||||
case LibreBaskerville = 'Libre Baskerville';
|
||||
case EbGaramond = 'EB Garamond';
|
||||
case Anton = 'Anton';
|
||||
case IbmPlexSans = 'IBM Plex Sans';
|
||||
case JetBrainsMono = 'JetBrains Mono';
|
||||
case Hind = 'Hind';
|
||||
case ArchivoNarrow = 'Archivo Narrow';
|
||||
case Archivo = 'Archivo';
|
||||
|
||||
public const DEFAULT = self::Inter;
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function values(): array
|
||||
{
|
||||
return array_map(fn (self $f) => $f->value, self::cases());
|
||||
}
|
||||
}
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Events\Ai;
|
||||
|
||||
use App\Models\AiMessage;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class AssistantMessageUpdated implements ShouldBroadcastNow
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
public function __construct(public AiMessage $message) {}
|
||||
|
||||
public function broadcastAs(): string
|
||||
{
|
||||
return 'AssistantMessageUpdated';
|
||||
}
|
||||
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [
|
||||
new PrivateChannel('post.'.$this->message->post_id),
|
||||
];
|
||||
}
|
||||
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
$this->message->refresh();
|
||||
|
||||
return [
|
||||
'message' => [
|
||||
'id' => $this->message->id,
|
||||
'post_id' => $this->message->post_id,
|
||||
'role' => $this->message->role,
|
||||
'content' => $this->message->content,
|
||||
'content_html' => $this->message->content_html,
|
||||
'attachments' => $this->message->attachments,
|
||||
'status' => $this->message->status->value,
|
||||
'error_message' => $this->message->error_message,
|
||||
'metadata' => $this->message->metadata,
|
||||
'created_at' => $this->message->created_at->toISOString(),
|
||||
'updated_at' => $this->message->updated_at->toISOString(),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
45
app/Events/Ai/PostCreationReady.php
Normal file
45
app/Events/Ai/PostCreationReady.php
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Events\Ai;
|
||||
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class PostCreationReady implements ShouldBroadcastNow
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public string $userId,
|
||||
public string $creationId,
|
||||
public ?string $content,
|
||||
public ?string $error = null,
|
||||
) {}
|
||||
|
||||
public function broadcastOn(): PrivateChannel
|
||||
{
|
||||
return new PrivateChannel("users.{$this->userId}.ai-creation.{$this->creationId}");
|
||||
}
|
||||
|
||||
public function broadcastAs(): string
|
||||
{
|
||||
return 'PostCreationReady';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
return [
|
||||
'creation_id' => $this->creationId,
|
||||
'content' => $this->content,
|
||||
'error' => $this->error,
|
||||
];
|
||||
}
|
||||
}
|
||||
193
app/Http/Controllers/App/PostAiCreateController.php
Normal file
193
app/Http/Controllers/App/PostAiCreateController.php
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
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\Media;
|
||||
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\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class PostAiCreateController extends Controller
|
||||
{
|
||||
public function start(StartPostCreationRequest $request): JsonResponse
|
||||
{
|
||||
$workspace = $request->user()->currentWorkspace;
|
||||
|
||||
$this->authorize('createPost', $workspace);
|
||||
|
||||
$socialAccountId = $request->input('social_account_id');
|
||||
|
||||
if ($socialAccountId) {
|
||||
$owned = SocialAccount::where('id', $socialAccountId)
|
||||
->where('workspace_id', $workspace->id)
|
||||
->exists();
|
||||
|
||||
if (! $owned) {
|
||||
abort(Response::HTTP_FORBIDDEN);
|
||||
}
|
||||
}
|
||||
|
||||
$creationId = (string) Str::uuid();
|
||||
|
||||
StreamPostCreation::dispatch(
|
||||
userId: $request->user()->id,
|
||||
creationId: $creationId,
|
||||
workspaceId: $workspace->id,
|
||||
format: $request->string('format')->toString(),
|
||||
socialAccountId: $socialAccountId,
|
||||
imageCount: (int) $request->input('image_count', 0),
|
||||
prompt: $request->string('prompt')->toString(),
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'creation_id' => $creationId,
|
||||
'channel' => "users.{$request->user()->id}.ai-creation.{$creationId}",
|
||||
], Response::HTTP_ACCEPTED);
|
||||
}
|
||||
|
||||
public function finalize(Request $request, string $creationId): JsonResponse
|
||||
{
|
||||
$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);
|
||||
}
|
||||
|
||||
$media = $this->buildMediaArray($workspace, $state);
|
||||
|
||||
$post = CreatePost::execute($workspace, $request->user(), [
|
||||
'content' => data_get($state, 'content', ''),
|
||||
'media' => $media,
|
||||
]);
|
||||
|
||||
// Set the platform's aspect_ratio meta from the same enum that drives
|
||||
// image generation, so the preview matches the rendered image exactly.
|
||||
$format = data_get($state, 'format');
|
||||
$socialAccountId = data_get($state, 'social_account_id');
|
||||
$contentType = $format ? ContentType::tryFrom($format) : null;
|
||||
if ($contentType && $socialAccountId) {
|
||||
$aspectRatio = $this->aspectRatioFor($contentType);
|
||||
|
||||
$post->postPlatforms()
|
||||
->where('social_account_id', $socialAccountId)
|
||||
->each(function ($platform) use ($aspectRatio): void {
|
||||
$meta = $platform->meta ?? [];
|
||||
if ($aspectRatio !== null) {
|
||||
$meta['aspect_ratio'] = $aspectRatio;
|
||||
}
|
||||
$platform->meta = $meta;
|
||||
$platform->enabled = true;
|
||||
$platform->save();
|
||||
});
|
||||
}
|
||||
|
||||
Cache::forget("ai-creation:{$creationId}");
|
||||
|
||||
return response()->json([
|
||||
'post_id' => $post->id,
|
||||
'redirect_url' => route('app.posts.edit', $post),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the media array for post creation from the AI creation state.
|
||||
*
|
||||
* @param array<string, mixed> $state
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
$media = new Media([
|
||||
'collection' => 'ai-generated',
|
||||
'type' => MediaType::Image,
|
||||
'path' => $path,
|
||||
'original_filename' => basename($path),
|
||||
'mime_type' => 'image/webp',
|
||||
'size' => Storage::size($path),
|
||||
'order' => 0,
|
||||
]);
|
||||
|
||||
$media->mediable_type = Workspace::class;
|
||||
$media->mediable_id = $workspace->id;
|
||||
$media->save();
|
||||
|
||||
return [
|
||||
'id' => $media->id,
|
||||
'path' => $media->path,
|
||||
'url' => $media->url,
|
||||
'type' => 'image',
|
||||
'mime_type' => 'image/webp',
|
||||
'meta' => $meta,
|
||||
];
|
||||
}
|
||||
}
|
||||
39
app/Http/Controllers/App/PostAiGenerateController.php
Normal file
39
app/Http/Controllers/App/PostAiGenerateController.php
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\App;
|
||||
|
||||
use App\Http\Requests\App\Ai\GeneratePostContentRequest;
|
||||
use App\Jobs\Ai\StreamPostContent;
|
||||
use App\Models\Post;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class PostAiGenerateController extends Controller
|
||||
{
|
||||
public function generate(GeneratePostContentRequest $request, Post $post): JsonResponse
|
||||
{
|
||||
$workspace = $request->user()->currentWorkspace;
|
||||
|
||||
if ($post->workspace_id !== $workspace->id) {
|
||||
abort(Response::HTTP_FORBIDDEN);
|
||||
}
|
||||
|
||||
$generationId = (string) Str::uuid();
|
||||
|
||||
StreamPostContent::dispatch(
|
||||
workspaceId: $workspace->id,
|
||||
userId: $request->user()->id,
|
||||
generationId: $generationId,
|
||||
prompt: $request->string('prompt')->toString(),
|
||||
currentContent: $request->input('current_content'),
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'generation_id' => $generationId,
|
||||
'channel' => "users.{$request->user()->id}.ai-gen.{$generationId}",
|
||||
], Response::HTTP_ACCEPTED);
|
||||
}
|
||||
}
|
||||
30
app/Http/Controllers/App/PostAiReviewController.php
Normal file
30
app/Http/Controllers/App/PostAiReviewController.php
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\App;
|
||||
|
||||
use App\Ai\Agents\PostContentReviewer;
|
||||
use App\Http\Requests\App\Ai\ReviewPostContentRequest;
|
||||
use App\Models\Post;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class PostAiReviewController extends Controller
|
||||
{
|
||||
public function review(ReviewPostContentRequest $request, Post $post): JsonResponse
|
||||
{
|
||||
$workspace = $request->user()->currentWorkspace;
|
||||
|
||||
if ($post->workspace_id !== $workspace->id) {
|
||||
abort(Response::HTTP_FORBIDDEN);
|
||||
}
|
||||
|
||||
$agent = new PostContentReviewer(workspace: $workspace);
|
||||
$result = $agent->prompt($request->string('content')->toString());
|
||||
|
||||
return response()->json([
|
||||
'suggestions' => data_get($result, 'suggestions', []),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,102 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\App;
|
||||
|
||||
use App\Enums\Ai\Intent;
|
||||
use App\Enums\AiMessage\Status;
|
||||
use App\Http\Requests\App\Assistant\StoreAssistantMessageRequest;
|
||||
use App\Jobs\Ai\GenerateAssistantResponse;
|
||||
use App\Models\Post;
|
||||
use App\Services\Ai\IntentDetector;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class PostAssistantController extends Controller
|
||||
{
|
||||
public function index(Request $request, Post $post): JsonResponse
|
||||
{
|
||||
$workspace = $request->user()->currentWorkspace;
|
||||
|
||||
if ($post->workspace_id !== $workspace->id) {
|
||||
abort(Response::HTTP_FORBIDDEN);
|
||||
}
|
||||
|
||||
$messages = $post->aiMessages()
|
||||
->with('user')
|
||||
->oldest()
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
return response()->json(['messages' => $messages]);
|
||||
}
|
||||
|
||||
public function store(
|
||||
StoreAssistantMessageRequest $request,
|
||||
Post $post,
|
||||
IntentDetector $intentDetector,
|
||||
): JsonResponse {
|
||||
$workspace = $request->user()->currentWorkspace;
|
||||
|
||||
if ($post->workspace_id !== $workspace->id) {
|
||||
abort(Response::HTTP_FORBIDDEN);
|
||||
}
|
||||
|
||||
$validated = $request->validated();
|
||||
|
||||
$prompt = data_get($validated, 'body');
|
||||
|
||||
$userMessage = $post->aiMessages()->create([
|
||||
'user_id' => $request->user()->id,
|
||||
'role' => 'user',
|
||||
'content' => $prompt,
|
||||
'status' => Status::Completed,
|
||||
]);
|
||||
|
||||
if ($request->hasFile('image')) {
|
||||
$media = $workspace->addMedia($request->file('image'), 'assets');
|
||||
|
||||
$userMessage->update([
|
||||
'attachments' => [['id' => $media->id, 'path' => $media->path, 'url' => $media->url, 'type' => 'image', 'mime_type' => $media->mime_type]],
|
||||
]);
|
||||
}
|
||||
|
||||
$userMessage->load('user');
|
||||
|
||||
$intent = $intentDetector->detect($prompt);
|
||||
|
||||
if ($intent === Intent::Blocked) {
|
||||
$assistantMessage = $post->aiMessages()->create([
|
||||
'role' => 'assistant',
|
||||
'content' => __('assistant.content_blocked'),
|
||||
'status' => Status::Completed,
|
||||
'metadata' => ['intent' => $intent->value, 'error' => true],
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'user_message' => $userMessage,
|
||||
'assistant_message' => $assistantMessage,
|
||||
], Response::HTTP_CREATED);
|
||||
}
|
||||
|
||||
$assistantMessage = $post->aiMessages()->create([
|
||||
'role' => 'assistant',
|
||||
'content' => '',
|
||||
'status' => Status::Pending,
|
||||
'metadata' => ['intent' => $intent->value],
|
||||
]);
|
||||
|
||||
GenerateAssistantResponse::dispatch(
|
||||
assistantMessage: $assistantMessage,
|
||||
prompt: $prompt,
|
||||
intent: $intent->value,
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'user_message' => $userMessage,
|
||||
'assistant_message' => $assistantMessage,
|
||||
], Response::HTTP_ACCEPTED);
|
||||
}
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@
|
|||
use App\Enums\SocialAccount\Platform;
|
||||
use App\Http\Requests\App\Post\UpdatePostRequest;
|
||||
use App\Http\Resources\App\PlatformConfigResource;
|
||||
use App\Http\Resources\App\SocialAccountResource;
|
||||
use App\Models\Post;
|
||||
use App\Models\PostPlatform;
|
||||
use App\Services\Social\BlueskyAnalytics;
|
||||
|
|
@ -128,6 +129,20 @@ public function calendar(Request $request): Response|RedirectResponse
|
|||
]);
|
||||
}
|
||||
|
||||
public function create(Request $request): Response
|
||||
{
|
||||
$workspace = $request->user()->currentWorkspace;
|
||||
|
||||
$this->authorize('createPost', $workspace);
|
||||
|
||||
return Inertia::render('posts/Create', [
|
||||
'date' => $request->query('date'),
|
||||
'socialAccounts' => SocialAccountResource::collection(
|
||||
$workspace->socialAccounts()->active()->get()
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse|\Symfony\Component\HttpFoundation\Response
|
||||
{
|
||||
$workspace = $request->user()->currentWorkspace;
|
||||
|
|
|
|||
148
app/Http/Controllers/App/PostTemplateController.php
Normal file
148
app/Http/Controllers/App/PostTemplateController.php
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\App;
|
||||
|
||||
use App\Actions\Post\CreatePost;
|
||||
use App\Enums\Media\Type as MediaType;
|
||||
use App\Http\Resources\App\PostTemplateResource;
|
||||
use App\Models\Media;
|
||||
use App\Models\PostTemplate;
|
||||
use App\Models\SocialAccount;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Image\TemplateImageGenerator;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response as InertiaResponse;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class PostTemplateController extends Controller
|
||||
{
|
||||
public function index(Request $request): InertiaResponse
|
||||
{
|
||||
$request->validate([
|
||||
'platform' => ['nullable', 'string'],
|
||||
'search' => ['nullable', 'string', 'max:120'],
|
||||
]);
|
||||
|
||||
$templates = PostTemplate::query()
|
||||
->when($request->input('platform'), fn ($q, $p) => $q->where('platform', $p))
|
||||
->when($request->input('search'), function ($q, $search) {
|
||||
$q->where(function ($inner) use ($search): void {
|
||||
$inner->where('name', 'ilike', "%{$search}%")
|
||||
->orWhere('description', 'ilike', "%{$search}%");
|
||||
});
|
||||
})
|
||||
->orderBy('category')
|
||||
->orderBy('name')
|
||||
->paginate(config('app.pagination.default'));
|
||||
|
||||
return Inertia::render('posts/templates/Index', [
|
||||
'templates' => Inertia::scroll(fn () => PostTemplateResource::collection($templates)),
|
||||
'filters' => [
|
||||
'search' => $request->input('search', ''),
|
||||
'platform' => $request->input('platform', ''),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function apply(Request $request, PostTemplate $template, TemplateImageGenerator $generator): JsonResponse
|
||||
{
|
||||
$workspace = $request->user()->currentWorkspace;
|
||||
|
||||
$this->authorize('createPost', $workspace);
|
||||
|
||||
$request->validate([
|
||||
'social_account_id' => ['nullable', 'uuid'],
|
||||
]);
|
||||
|
||||
$socialAccountId = $request->input('social_account_id');
|
||||
$socialAccount = null;
|
||||
|
||||
if ($socialAccountId) {
|
||||
$socialAccount = SocialAccount::where('id', $socialAccountId)
|
||||
->where('workspace_id', $workspace->id)
|
||||
->first();
|
||||
|
||||
if (! $socialAccount) {
|
||||
abort(Response::HTTP_FORBIDDEN);
|
||||
}
|
||||
}
|
||||
|
||||
$content = $this->interpolate($template->content, $workspace);
|
||||
|
||||
$media = [];
|
||||
|
||||
if ($socialAccount && $template->slides) {
|
||||
foreach ($template->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.
|
||||
$tmpl = $i === 0 ? 'A' : ($i % 2 === 0 ? 'A' : 'B');
|
||||
$path = $generator->render(
|
||||
template: $tmpl,
|
||||
workspace: $workspace,
|
||||
socialAccount: $socialAccount,
|
||||
title: $this->interpolate(data_get($slide, 'title', ''), $workspace),
|
||||
body: $this->interpolate(data_get($slide, 'body', ''), $workspace),
|
||||
imageKeywords: data_get($slide, 'image_keywords', []),
|
||||
);
|
||||
|
||||
if ($path) {
|
||||
$mediaItem = $this->createMediaItem($workspace, $path);
|
||||
$media[] = $mediaItem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$post = CreatePost::execute($workspace, $request->user(), [
|
||||
'content' => $content,
|
||||
'media' => $media,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'post_id' => $post->id,
|
||||
'redirect_url' => route('app.posts.edit', $post),
|
||||
]);
|
||||
}
|
||||
|
||||
private function interpolate(string $text, Workspace $workspace): string
|
||||
{
|
||||
return strtr($text, [
|
||||
'{{brand_name}}' => $workspace->name ?? '',
|
||||
'{{brand_description}}' => $workspace->brand_description ?? '',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Media record for a generated image and return it as an array.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function createMediaItem(Workspace $workspace, string $path): array
|
||||
{
|
||||
$media = new Media([
|
||||
'collection' => 'ai-generated',
|
||||
'type' => MediaType::Image,
|
||||
'path' => $path,
|
||||
'original_filename' => basename($path),
|
||||
'mime_type' => 'image/webp',
|
||||
'size' => Storage::size($path),
|
||||
'order' => 0,
|
||||
]);
|
||||
|
||||
$media->mediable_type = Workspace::class;
|
||||
$media->mediable_id = $workspace->id;
|
||||
$media->save();
|
||||
|
||||
return [
|
||||
'id' => $media->id,
|
||||
'path' => $media->path,
|
||||
'url' => $media->url,
|
||||
'type' => 'image',
|
||||
'mime_type' => 'image/webp',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@
|
|||
use App\Actions\Ai\AutofillBrand;
|
||||
use App\Actions\Workspace\CreateWorkspace;
|
||||
use App\Actions\Workspace\DeleteWorkspace;
|
||||
use App\Enums\Workspace\BrandFont;
|
||||
use App\Http\Requests\App\Workspace\StoreWorkspaceRequest;
|
||||
use App\Http\Requests\App\Workspace\UpdateWorkspaceRequest;
|
||||
use App\Http\Resources\App\WorkspaceMemberResource;
|
||||
|
|
@ -158,6 +159,7 @@ public function brandSettings(Request $request): Response|RedirectResponse
|
|||
|
||||
return Inertia::render('settings/Brand', [
|
||||
'workspace' => $workspace,
|
||||
'availableFonts' => BrandFont::values(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
26
app/Http/Requests/App/Ai/GeneratePostContentRequest.php
Normal file
26
app/Http/Requests/App/Ai/GeneratePostContentRequest.php
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\App\Ai;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class GeneratePostContentRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<int, string>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'prompt' => ['required', 'string', 'max:2000'],
|
||||
'current_content' => ['nullable', 'string', 'max:10000'],
|
||||
];
|
||||
}
|
||||
}
|
||||
25
app/Http/Requests/App/Ai/ReviewPostContentRequest.php
Normal file
25
app/Http/Requests/App/Ai/ReviewPostContentRequest.php
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\App\Ai;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class ReviewPostContentRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<int, string>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'content' => ['required', 'string', 'max:10000'],
|
||||
];
|
||||
}
|
||||
}
|
||||
35
app/Http/Requests/App/Ai/StartPostCreationRequest.php
Normal file
35
app/Http/Requests/App/Ai/StartPostCreationRequest.php
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\App\Ai;
|
||||
|
||||
use App\Enums\PostPlatform\ContentType;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class StartPostCreationRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'format' => [
|
||||
'required',
|
||||
'string',
|
||||
Rule::in(array_map(fn (ContentType $t) => $t->value, ContentType::aiSupported())),
|
||||
],
|
||||
'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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\App\Assistant;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreAssistantMessageRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'body' => ['required', 'string', 'max:2000'],
|
||||
'image' => ['nullable', 'file', 'max:10240', 'mimetypes:image/jpeg,image/png,image/gif,image/webp'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -4,7 +4,9 @@
|
|||
|
||||
namespace App\Http\Requests\App\Workspace;
|
||||
|
||||
use App\Enums\Workspace\BrandFont;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class StoreWorkspaceRequest extends FormRequest
|
||||
{
|
||||
|
|
@ -26,6 +28,7 @@ public function rules(): array
|
|||
'brand_color' => $hex,
|
||||
'background_color' => $hex,
|
||||
'text_color' => $hex,
|
||||
'brand_font' => ['sometimes', 'string', Rule::in(BrandFont::values())],
|
||||
'content_language' => ['nullable', 'string', 'in:en,pt-BR,es'],
|
||||
'logo_url' => ['nullable', 'url', 'max:1024'],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -4,7 +4,9 @@
|
|||
|
||||
namespace App\Http\Requests\App\Workspace;
|
||||
|
||||
use App\Enums\Workspace\BrandFont;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class UpdateWorkspaceRequest extends FormRequest
|
||||
{
|
||||
|
|
@ -26,6 +28,7 @@ public function rules(): array
|
|||
'brand_color' => $hex,
|
||||
'background_color' => $hex,
|
||||
'text_color' => $hex,
|
||||
'brand_font' => ['required', 'string', Rule::in(BrandFont::values())],
|
||||
'content_language' => ['sometimes', 'string', 'in:en,pt-BR,es'],
|
||||
];
|
||||
}
|
||||
|
|
|
|||
27
app/Http/Resources/App/PostTemplateResource.php
Normal file
27
app/Http/Resources/App/PostTemplateResource.php
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\App;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class PostTemplateResource extends JsonResource
|
||||
{
|
||||
/** @return array<string, mixed> */
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
'description' => $this->description,
|
||||
'category' => $this->category,
|
||||
'platform' => $this->platform,
|
||||
'content' => $this->content,
|
||||
'slides' => $this->slides,
|
||||
'image_count' => $this->image_count,
|
||||
'image_keywords' => $this->image_keywords,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -1,151 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs\Ai;
|
||||
|
||||
use App\Ai\Agents\SocialMediaAssistant;
|
||||
use App\Ai\Tools\AttachmentCollector;
|
||||
use App\Enums\Ai\Intent;
|
||||
use App\Enums\Ai\UsageType;
|
||||
use App\Enums\AiMessage\Status;
|
||||
use App\Events\Ai\AssistantMessageUpdated;
|
||||
use App\Features\AiImagesLimit;
|
||||
use App\Features\AiVideosLimit;
|
||||
use App\Models\AiMessage;
|
||||
use App\Models\AiUsageLog;
|
||||
use App\Services\Ai\HumanizerService;
|
||||
use App\Services\Ai\IntentDetector;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Laravel\Pennant\Feature;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
class GenerateAssistantResponse implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public int $tries = 1;
|
||||
|
||||
public int $timeout = 900;
|
||||
|
||||
public function __construct(
|
||||
public AiMessage $assistantMessage,
|
||||
public string $prompt,
|
||||
public string $intent,
|
||||
) {
|
||||
$this->onQueue('ai');
|
||||
}
|
||||
|
||||
public function handle(IntentDetector $intentDetector, AttachmentCollector $collector, HumanizerService $humanizer): void
|
||||
{
|
||||
$this->assistantMessage->update(['status' => Status::Generating]);
|
||||
|
||||
AssistantMessageUpdated::dispatch($this->assistantMessage);
|
||||
|
||||
$post = $this->assistantMessage->post()->with('postPlatforms')->firstOrFail();
|
||||
$workspace = $post->workspace;
|
||||
|
||||
$intent = Intent::tryFrom($this->intent) ?? Intent::Text;
|
||||
|
||||
$assistantMessages = $post->aiMessages()
|
||||
->where('role', 'assistant')
|
||||
->where('id', '!=', $this->assistantMessage->id)
|
||||
->get();
|
||||
|
||||
$imagesInThread = $assistantMessages->sum(
|
||||
fn ($m) => collect($m->attachments ?? [])->where('type', 'image')->count()
|
||||
);
|
||||
|
||||
$videosInThread = $assistantMessages->sum(
|
||||
fn ($m) => collect($m->attachments ?? [])->where('type', 'video')->count()
|
||||
);
|
||||
|
||||
$imageLimit = (int) Feature::for($workspace->account)->value(AiImagesLimit::class);
|
||||
$imageUsed = AiUsageLog::monthlyCount($workspace->account_id, UsageType::Image);
|
||||
$imageRemaining = max(0, $imageLimit - $imageUsed);
|
||||
|
||||
$videoLimit = (int) Feature::for($workspace->account)->value(AiVideosLimit::class);
|
||||
$videoUsed = AiUsageLog::monthlyCount($workspace->account_id, UsageType::Video);
|
||||
$videoRemaining = max(0, $videoLimit - $videoUsed);
|
||||
|
||||
$stateContext = sprintf(
|
||||
"[Session state — use this to track progress and respect quotas]\n".
|
||||
"- Images already generated in this conversation: %d\n".
|
||||
"- Videos already generated in this conversation: %d\n".
|
||||
"- Monthly quota remaining: %d images, %d videos\n",
|
||||
$imagesInThread,
|
||||
$videosInThread,
|
||||
$imageRemaining,
|
||||
$videoRemaining,
|
||||
);
|
||||
|
||||
$promptWithState = "{$stateContext}\n{$this->prompt}";
|
||||
|
||||
$collector->clear();
|
||||
|
||||
$response = (new SocialMediaAssistant(
|
||||
workspace: $workspace,
|
||||
post: $post,
|
||||
userId: $this->assistantMessage->user_id,
|
||||
))->prompt($promptWithState);
|
||||
|
||||
$responseContent = (string) ($response['message'] ?? $response->text ?? '');
|
||||
$quickActions = $response['quick_actions'] ?? [];
|
||||
|
||||
$attachments = $collector->all();
|
||||
|
||||
// Only humanize actual post captions (turns that generated media).
|
||||
// Conversational turns (greetings, questions, plan summaries) skip
|
||||
// the humanizer to preserve the agent's natural short responses.
|
||||
if (! empty($attachments)) {
|
||||
$responseContent = $humanizer->humanize($responseContent, $workspace);
|
||||
}
|
||||
|
||||
$generatedIntent = $intent->value;
|
||||
foreach ($attachments as $attachment) {
|
||||
if (isset($attachment['type'])) {
|
||||
$generatedIntent = $attachment['type'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$this->assistantMessage->update([
|
||||
'content' => $responseContent,
|
||||
'attachments' => $attachments,
|
||||
'status' => Status::Completed,
|
||||
'metadata' => array_merge(
|
||||
$this->assistantMessage->metadata ?? [],
|
||||
['intent' => $generatedIntent, 'quick_actions' => $quickActions],
|
||||
),
|
||||
]);
|
||||
|
||||
AssistantMessageUpdated::dispatch($this->assistantMessage);
|
||||
}
|
||||
|
||||
public function failed(?Throwable $exception): void
|
||||
{
|
||||
Log::error('GenerateAssistantResponse job failed', [
|
||||
'assistant_message_id' => $this->assistantMessage->id,
|
||||
'error' => $exception?->getMessage(),
|
||||
]);
|
||||
|
||||
$errorMessage = $exception instanceof RuntimeException
|
||||
? $exception->getMessage()
|
||||
: __('assistant.error');
|
||||
|
||||
$this->assistantMessage->update([
|
||||
'content' => $errorMessage,
|
||||
'status' => Status::Failed,
|
||||
'error_message' => $exception?->getMessage(),
|
||||
'metadata' => array_merge(
|
||||
$this->assistantMessage->metadata ?? [],
|
||||
['error' => true],
|
||||
),
|
||||
]);
|
||||
|
||||
AssistantMessageUpdated::dispatch($this->assistantMessage);
|
||||
}
|
||||
}
|
||||
52
app/Jobs/Ai/StreamPostContent.php
Normal file
52
app/Jobs/Ai/StreamPostContent.php
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs\Ai;
|
||||
|
||||
use App\Ai\Agents\PostContentStreamer;
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
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\Log;
|
||||
|
||||
class StreamPostContent implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public string $workspaceId,
|
||||
public string $userId,
|
||||
public string $generationId,
|
||||
public string $prompt,
|
||||
public ?string $currentContent,
|
||||
) {
|
||||
$this->onQueue('ai');
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$workspace = Workspace::findOrFail($this->workspaceId);
|
||||
|
||||
$agent = new PostContentStreamer(
|
||||
workspace: $workspace,
|
||||
currentContent: $this->currentContent,
|
||||
);
|
||||
|
||||
$channel = new PrivateChannel("users.{$this->userId}.ai-gen.{$this->generationId}");
|
||||
|
||||
try {
|
||||
$agent->broadcast($this->prompt, $channel, now: true);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('PostContentGenerator stream failed', [
|
||||
'generation_id' => $this->generationId,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
283
app/Jobs/Ai/StreamPostCreation.php
Normal file
283
app/Jobs/Ai/StreamPostCreation.php
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs\Ai;
|
||||
|
||||
use App\Ai\Agents\PostContentGenerator;
|
||||
use App\Ai\Agents\PostContentHumanizer;
|
||||
use App\Enums\PostPlatform\ContentType;
|
||||
use App\Events\Ai\PostCreationReady;
|
||||
use App\Models\SocialAccount;
|
||||
use App\Models\Workspace;
|
||||
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;
|
||||
|
||||
class StreamPostCreation implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public string $userId,
|
||||
public string $creationId,
|
||||
public string $workspaceId,
|
||||
public string $format,
|
||||
public ?string $socialAccountId,
|
||||
public int $imageCount,
|
||||
public string $prompt,
|
||||
) {
|
||||
$this->onQueue('ai');
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$workspace = Workspace::findOrFail($this->workspaceId);
|
||||
$socialAccount = $this->socialAccountId ? SocialAccount::find($this->socialAccountId) : null;
|
||||
|
||||
$isCarousel = $this->format === 'instagram_carousel';
|
||||
$agentFormat = $isCarousel ? 'carousel' : 'single';
|
||||
$slideCount = $isCarousel && $this->imageCount > 0 ? $this->imageCount : 1;
|
||||
|
||||
$agent = new PostContentGenerator(
|
||||
workspace: $workspace,
|
||||
format: $agentFormat,
|
||||
slideCount: $slideCount,
|
||||
platformContext: $this->format,
|
||||
);
|
||||
|
||||
try {
|
||||
$response = $agent->prompt($this->prompt);
|
||||
|
||||
// StructuredAgentResponse implements ArrayAccess: access via $response['key']
|
||||
$structured = $response->structured ?? [];
|
||||
|
||||
// Second pass: rewrite human-readable text to remove AI-tells. Image
|
||||
// keywords pass through untouched (they need to stay in English).
|
||||
$structured = $this->humanize($workspace, $structured, $isCarousel ? 'carousel' : 'single');
|
||||
|
||||
if ($isCarousel) {
|
||||
$this->handleCarousel($workspace, $socialAccount, $structured);
|
||||
} else {
|
||||
$this->handleSingle($workspace, $socialAccount, $structured);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('StreamPostCreation failed', [
|
||||
'creation_id' => $this->creationId,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
PostCreationReady::dispatch($this->userId, $this->creationId, null, $e->getMessage());
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the structured generator output through the humanizer pass and merge
|
||||
* the humanized text fields back over the original structure (preserving
|
||||
* image_keywords and slide order/count). Failures are logged and the
|
||||
* original structure is returned so generation never breaks because of the
|
||||
* polish step.
|
||||
*
|
||||
* @param array<string, mixed> $structured
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
/**
|
||||
* Look up the AI image dimensions for the current format. Falls back to
|
||||
* the generator's defaults (4:5 portrait) if the format string isn't a
|
||||
* known ContentType case.
|
||||
*
|
||||
* @return array{width: int, height: int}
|
||||
*/
|
||||
private function dimensionsForFormat(): array
|
||||
{
|
||||
$type = ContentType::tryFrom($this->format);
|
||||
|
||||
return $type
|
||||
? $type->aiImageDimensions()
|
||||
: ['width' => TemplateImageGenerator::DEFAULT_WIDTH, 'height' => TemplateImageGenerator::DEFAULT_HEIGHT];
|
||||
}
|
||||
|
||||
private function humanize(Workspace $workspace, array $structured, string $format): array
|
||||
{
|
||||
try {
|
||||
$input = $format === 'carousel'
|
||||
? [
|
||||
'caption' => data_get($structured, 'caption', ''),
|
||||
'slides' => array_map(
|
||||
fn ($s) => [
|
||||
'title' => data_get($s, 'title', ''),
|
||||
'body' => data_get($s, 'body', ''),
|
||||
],
|
||||
data_get($structured, 'slides', []),
|
||||
),
|
||||
]
|
||||
: [
|
||||
'content' => data_get($structured, 'content', ''),
|
||||
'image_title' => data_get($structured, 'image_title', ''),
|
||||
'image_body' => data_get($structured, 'image_body', ''),
|
||||
];
|
||||
|
||||
$humanizer = new PostContentHumanizer($workspace, $format);
|
||||
$response = $humanizer->prompt(json_encode($input, JSON_UNESCAPED_UNICODE));
|
||||
$humanized = $response->structured ?? [];
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('PostContentHumanizer failed, using generator output as-is', [
|
||||
'creation_id' => $this->creationId,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return $structured;
|
||||
}
|
||||
|
||||
if ($format === 'carousel') {
|
||||
$structured['caption'] = data_get($humanized, 'caption', $structured['caption'] ?? '');
|
||||
$originalSlides = $structured['slides'] ?? [];
|
||||
$humanizedSlides = data_get($humanized, 'slides', []);
|
||||
|
||||
foreach ($originalSlides as $i => $slide) {
|
||||
if (isset($humanizedSlides[$i])) {
|
||||
$originalSlides[$i]['title'] = data_get($humanizedSlides[$i], 'title', $slide['title'] ?? '');
|
||||
$originalSlides[$i]['body'] = data_get($humanizedSlides[$i], 'body', $slide['body'] ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
$structured['slides'] = $originalSlides;
|
||||
} else {
|
||||
$structured['content'] = data_get($humanized, 'content', $structured['content'] ?? '');
|
||||
$structured['image_title'] = data_get($humanized, 'image_title', $structured['image_title'] ?? '');
|
||||
$structured['image_body'] = data_get($humanized, 'image_body', $structured['image_body'] ?? '');
|
||||
}
|
||||
|
||||
return $structured;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $structured
|
||||
*/
|
||||
private function handleCarousel(Workspace $workspace, ?SocialAccount $socialAccount, array $structured): void
|
||||
{
|
||||
$caption = data_get($structured, 'caption', '');
|
||||
$slides = data_get($structured, 'slides', []);
|
||||
|
||||
$renderedSlides = [];
|
||||
|
||||
if ($socialAccount) {
|
||||
$generator = new TemplateImageGenerator(new UnsplashClient, new BrandColorMapper);
|
||||
['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,
|
||||
workspace: $workspace,
|
||||
socialAccount: $socialAccount,
|
||||
title: data_get($slide, 'title', ''),
|
||||
body: data_get($slide, 'body', ''),
|
||||
imageKeywords: data_get($slide, 'image_keywords', []),
|
||||
width: $width,
|
||||
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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
'created_at' => now()->toIso8601String(),
|
||||
], now()->addMinutes(30));
|
||||
|
||||
PostCreationReady::dispatch($this->userId, $this->creationId, $caption);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $structured
|
||||
*/
|
||||
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', '');
|
||||
$keywords = data_get($structured, 'image_keywords', []);
|
||||
|
||||
$imagePath = null;
|
||||
|
||||
if ($this->imageCount > 0 && $socialAccount) {
|
||||
$generator = new TemplateImageGenerator(new UnsplashClient, new BrandColorMapper);
|
||||
['width' => $width, 'height' => $height] = $this->dimensionsForFormat();
|
||||
|
||||
$imagePath = $generator->render(
|
||||
template: 'A',
|
||||
workspace: $workspace,
|
||||
socialAccount: $socialAccount,
|
||||
title: $imageTitle,
|
||||
body: $imageBody,
|
||||
imageKeywords: $keywords,
|
||||
width: $width,
|
||||
height: $height,
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
'created_at' => now()->toIso8601String(),
|
||||
], now()->addMinutes(30));
|
||||
|
||||
PostCreationReady::dispatch($this->userId, $this->creationId, $content);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,63 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\AiMessage\Status;
|
||||
use Database\Factories\AiMessageFactory;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class AiMessage extends Model
|
||||
{
|
||||
/** @use HasFactory<AiMessageFactory> */
|
||||
use HasFactory, HasUuids;
|
||||
|
||||
protected $fillable = [
|
||||
'post_id',
|
||||
'user_id',
|
||||
'role',
|
||||
'content',
|
||||
'attachments',
|
||||
'status',
|
||||
'error_message',
|
||||
'metadata',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'attachments' => 'array',
|
||||
'metadata' => 'array',
|
||||
'status' => Status::class,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
protected $appends = ['content_html'];
|
||||
|
||||
protected function contentHtml(): Attribute
|
||||
{
|
||||
return Attribute::get(fn () => $this->role === 'assistant' && $this->content
|
||||
? Str::markdown($this->content)
|
||||
: null
|
||||
);
|
||||
}
|
||||
|
||||
public function post(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Post::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
|
|
@ -69,11 +69,6 @@ public function postPlatforms(): HasMany
|
|||
return $this->hasMany(PostPlatform::class)->orderBy('id');
|
||||
}
|
||||
|
||||
public function aiMessages(): HasMany
|
||||
{
|
||||
return $this->hasMany(AiMessage::class);
|
||||
}
|
||||
|
||||
public function comments(): HasMany
|
||||
{
|
||||
return $this->hasMany(PostComment::class);
|
||||
|
|
|
|||
36
app/Models/PostTemplate.php
Normal file
36
app/Models/PostTemplate.php
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Database\Factories\PostTemplateFactory;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class PostTemplate extends Model
|
||||
{
|
||||
/** @use HasFactory<PostTemplateFactory> */
|
||||
use HasFactory, HasUuids;
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'description',
|
||||
'category',
|
||||
'platform',
|
||||
'content',
|
||||
'slides',
|
||||
'image_count',
|
||||
'image_keywords',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'slides' => 'array',
|
||||
'image_keywords' => 'array',
|
||||
'image_count' => 'integer',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -30,6 +30,7 @@ class Workspace extends Model
|
|||
'brand_color',
|
||||
'background_color',
|
||||
'text_color',
|
||||
'brand_font',
|
||||
'content_language',
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -16,11 +16,9 @@
|
|||
use App\Ai\PlatformRules\XRules;
|
||||
use App\Ai\PlatformRules\YouTubeRules;
|
||||
use App\Ai\Providers\ExtendedGeminiProvider;
|
||||
use App\Ai\Tools\AttachmentCollector;
|
||||
use App\Enums\SocialAccount\Platform;
|
||||
use App\Listeners\StripeEventListener;
|
||||
use App\Models\Account;
|
||||
use App\Models\AiMessage;
|
||||
use App\Models\AiUsageLog;
|
||||
use App\Models\Invite;
|
||||
use App\Models\Media;
|
||||
|
|
@ -82,8 +80,6 @@ public function register(): void
|
|||
$this->app->register(\Laravel\Telescope\TelescopeServiceProvider::class);
|
||||
$this->app->register(TelescopeServiceProvider::class);
|
||||
}
|
||||
|
||||
$this->app->scoped(AttachmentCollector::class);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -149,7 +145,6 @@ protected function configureMorphMap(): void
|
|||
{
|
||||
Relation::enforceMorphMap([
|
||||
'account' => Account::class,
|
||||
'aiMessage' => AiMessage::class,
|
||||
'aiUsageLog' => AiUsageLog::class,
|
||||
'invite' => Invite::class,
|
||||
'media' => Media::class,
|
||||
|
|
|
|||
|
|
@ -1,42 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Ai;
|
||||
|
||||
use App\Ai\Agents\Humanizer;
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Throwable;
|
||||
|
||||
class HumanizerService
|
||||
{
|
||||
public function humanize(string $text, Workspace $workspace): string
|
||||
{
|
||||
if (trim($text) === '') {
|
||||
return $text;
|
||||
}
|
||||
|
||||
$instructions = view('prompts.assistant.humanize', [
|
||||
'brand_name' => $workspace->name,
|
||||
'brand_tone' => $workspace->brand_tone,
|
||||
'brand_voice_notes' => $workspace->brand_voice_notes,
|
||||
'content_language' => $workspace->content_language,
|
||||
])->render();
|
||||
|
||||
try {
|
||||
$response = (new Humanizer($instructions))->prompt($text);
|
||||
|
||||
$rewritten = trim((string) $response->text);
|
||||
|
||||
return $rewritten !== '' ? $rewritten : $text;
|
||||
} catch (Throwable $e) {
|
||||
Log::warning('Humanizer pass failed; returning original text.', [
|
||||
'workspace_id' => $workspace->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return $text;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,64 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Ai;
|
||||
|
||||
use App\Enums\Ai\Intent;
|
||||
|
||||
class IntentDetector
|
||||
{
|
||||
public function detect(string $prompt): Intent
|
||||
{
|
||||
$lower = mb_strtolower($prompt);
|
||||
|
||||
if ($this->isProhibited($lower)) {
|
||||
return Intent::Blocked;
|
||||
}
|
||||
|
||||
$videoKeywords = ['video', 'clip', 'reel', 'animation', 'animate', 'footage'];
|
||||
$imageKeywords = ['image', 'photo', 'picture', 'illustration', 'draw', 'design', 'visual', 'graphic'];
|
||||
$audioKeywords = ['audio', 'voice', 'narrate', 'speak', 'tts', 'voiceover', 'text to speech'];
|
||||
|
||||
foreach ($videoKeywords as $keyword) {
|
||||
if (str_contains($lower, $keyword)) {
|
||||
return Intent::Video;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($imageKeywords as $keyword) {
|
||||
if (str_contains($lower, $keyword)) {
|
||||
return Intent::Image;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($audioKeywords as $keyword) {
|
||||
if (str_contains($lower, $keyword)) {
|
||||
return Intent::Audio;
|
||||
}
|
||||
}
|
||||
|
||||
return Intent::Text;
|
||||
}
|
||||
|
||||
private function isProhibited(string $lower): bool
|
||||
{
|
||||
$prohibited = [
|
||||
'porn', 'xxx', 'nude', 'naked', 'hentai', 'nsfw',
|
||||
'cocaine', 'heroin', 'meth',
|
||||
'murder', 'suicide', 'self-harm', 'self harm',
|
||||
'pedophil', 'child porn', 'underage',
|
||||
'terrorist', 'terrorism',
|
||||
'racist', 'racism', 'nazi', 'white supremac',
|
||||
'gore', 'torture', 'dismember',
|
||||
];
|
||||
|
||||
foreach ($prohibited as $word) {
|
||||
if (preg_match('/\b'.preg_quote($word, '/').'/i', $lower)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
25
app/Services/Ai/TemplateContextResolver.php
Normal file
25
app/Services/Ai/TemplateContextResolver.php
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Ai;
|
||||
|
||||
use App\Models\PostTemplate;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
|
||||
class TemplateContextResolver
|
||||
{
|
||||
/**
|
||||
* Pick up to N templates relevant to the platform.
|
||||
*
|
||||
* @return Collection<int, PostTemplate>
|
||||
*/
|
||||
public function relevantFor(?string $platform, int $limit = 3): Collection
|
||||
{
|
||||
return PostTemplate::query()
|
||||
->when($platform, fn ($q, $p) => $q->where('platform', $p))
|
||||
->inRandomOrder()
|
||||
->limit($limit)
|
||||
->get();
|
||||
}
|
||||
}
|
||||
90
app/Services/Image/BrandColorMapper.php
Normal file
90
app/Services/Image/BrandColorMapper.php
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Image;
|
||||
|
||||
use App\Models\Workspace;
|
||||
|
||||
class BrandColorMapper
|
||||
{
|
||||
/**
|
||||
* Convert a workspace's brand color (hex) to an Unsplash color bucket.
|
||||
* Falls back to background_color, then null. Null = no color filter.
|
||||
*/
|
||||
public function fromWorkspace(Workspace $workspace): ?string
|
||||
{
|
||||
$hex = $workspace->brand_color ?: $workspace->background_color;
|
||||
|
||||
return $hex ? $this->fromHex($hex) : null;
|
||||
}
|
||||
|
||||
public function fromHex(string $hex): ?string
|
||||
{
|
||||
$hex = ltrim($hex, '#');
|
||||
if (strlen($hex) !== 6 || ! ctype_xdigit($hex)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
[$r, $g, $b] = [
|
||||
hexdec(substr($hex, 0, 2)),
|
||||
hexdec(substr($hex, 2, 2)),
|
||||
hexdec(substr($hex, 4, 2)),
|
||||
];
|
||||
|
||||
[$h, $s, $l] = $this->rgbToHsl($r, $g, $b);
|
||||
|
||||
// Grayscale handling
|
||||
if ($s < 0.10) {
|
||||
if ($l < 0.30) {
|
||||
return 'black';
|
||||
}
|
||||
if ($l > 0.70) {
|
||||
return 'white';
|
||||
}
|
||||
|
||||
return 'black_and_white';
|
||||
}
|
||||
|
||||
$hueDeg = $h * 360;
|
||||
|
||||
return match (true) {
|
||||
$hueDeg >= 345 || $hueDeg < 15 => 'red',
|
||||
$hueDeg < 45 => 'orange',
|
||||
$hueDeg < 65 => 'yellow',
|
||||
$hueDeg < 150 => 'green',
|
||||
$hueDeg < 200 => 'teal',
|
||||
$hueDeg < 260 => 'blue',
|
||||
$hueDeg < 300 => 'purple',
|
||||
$hueDeg < 345 => 'magenta',
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
/** @return array{0: float, 1: float, 2: float} */
|
||||
private function rgbToHsl(int $r, int $g, int $b): array
|
||||
{
|
||||
$r /= 255;
|
||||
$g /= 255;
|
||||
$b /= 255;
|
||||
$max = max($r, $g, $b);
|
||||
$min = min($r, $g, $b);
|
||||
$l = ($max + $min) / 2;
|
||||
|
||||
if ($max === $min) {
|
||||
return [0, 0, $l];
|
||||
}
|
||||
|
||||
$d = $max - $min;
|
||||
$s = $l > 0.5 ? $d / (2 - $max - $min) : $d / ($max + $min);
|
||||
|
||||
$h = match ($max) {
|
||||
$r => ($g - $b) / $d + ($g < $b ? 6 : 0),
|
||||
$g => ($b - $r) / $d + 2,
|
||||
default => ($r - $g) / $d + 4,
|
||||
};
|
||||
$h /= 6;
|
||||
|
||||
return [$h, $s, $l];
|
||||
}
|
||||
}
|
||||
687
app/Services/Image/TemplateImageGenerator.php
Normal file
687
app/Services/Image/TemplateImageGenerator.php
Normal file
|
|
@ -0,0 +1,687 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Image;
|
||||
|
||||
use App\Models\SocialAccount;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Unsplash\UnsplashClient;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Intervention\Image\Drivers\Gd\Driver;
|
||||
use Intervention\Image\Encoders\WebpEncoder;
|
||||
use Intervention\Image\ImageManager;
|
||||
use Intervention\Image\Interfaces\ImageInterface;
|
||||
use Intervention\Image\Typography\FontFactory;
|
||||
|
||||
class TemplateImageGenerator
|
||||
{
|
||||
public const DEFAULT_WIDTH = 1080;
|
||||
|
||||
public const DEFAULT_HEIGHT = 1350;
|
||||
|
||||
/** Active canvas width. Set per render call so templates can scale. */
|
||||
private int $width = self::DEFAULT_WIDTH;
|
||||
|
||||
/** Active canvas height. Set per render call so templates can scale. */
|
||||
private int $height = self::DEFAULT_HEIGHT;
|
||||
|
||||
public function __construct(
|
||||
private UnsplashClient $unsplash,
|
||||
private BrandColorMapper $colorMapper,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Render a slide using Template A (full bleed) or Template B (photo card).
|
||||
*
|
||||
* @param array<int, string> $imageKeywords
|
||||
* @return string|null The storage path, or null on failure.
|
||||
*/
|
||||
/**
|
||||
* 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 at 0.8 opacity over bg — slightly muted accent.
|
||||
$handleColor = $this->blendHex($brandColor, $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)));
|
||||
|
||||
return $filename;
|
||||
}
|
||||
|
||||
public function render(
|
||||
string $template,
|
||||
Workspace $workspace,
|
||||
SocialAccount $socialAccount,
|
||||
string $title,
|
||||
string $body,
|
||||
array $imageKeywords,
|
||||
int $width = self::DEFAULT_WIDTH,
|
||||
int $height = self::DEFAULT_HEIGHT,
|
||||
): ?string {
|
||||
$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']]);
|
||||
|
||||
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);
|
||||
|
||||
$filename = 'ai-images/'.uniqid('slide_', true).'.webp';
|
||||
Storage::put($filename, (string) $canvas->encode(new WebpEncoder(quality: 85)));
|
||||
|
||||
return $filename;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the closest Unsplash orientation for the active canvas. Avoids
|
||||
* stretching/cropping a portrait photo into a landscape canvas.
|
||||
*/
|
||||
private function unsplashOrientation(): string
|
||||
{
|
||||
$ratio = $this->width / $this->height;
|
||||
if ($ratio > 1.1) {
|
||||
return 'landscape';
|
||||
}
|
||||
if ($ratio < 0.9) {
|
||||
return 'portrait';
|
||||
}
|
||||
|
||||
return 'squarish';
|
||||
}
|
||||
|
||||
private function renderTemplateA(ImageManager $manager, string $imageData, string $title, string $body): ImageInterface
|
||||
{
|
||||
// Cover-fit Unsplash image to active canvas size.
|
||||
$image = $manager->decodeBinary($imageData)->cover($this->width, $this->height);
|
||||
|
||||
// Smooth gradient mask: covers full image height, peaks at 0.9 alpha (linear).
|
||||
$this->applyBottomGradient($image, 1.0, 0.9, 1.0);
|
||||
|
||||
$fontBold = $this->fontPath('Inter-Bold.ttf');
|
||||
$fontMedium = $this->fontPath('Inter-Medium.ttf');
|
||||
|
||||
// Layout (bottom-up): footer area → body → title. All text rendered via raw GD
|
||||
// for pixel-precise positioning. Same wrap+measure helper used for layout math.
|
||||
$titleSize = 56;
|
||||
$bodySize = 28;
|
||||
$titleLineHeight = 1.25;
|
||||
$bodyLineHeight = 1.55;
|
||||
$footerReserved = 150;
|
||||
$bodyMargin = 16;
|
||||
$titleMargin = 36;
|
||||
$padding = 60;
|
||||
$maxWidth = $this->width - 2 * $padding;
|
||||
|
||||
$bodyLines = $fontMedium ? $this->wrapText($body, $fontMedium, $bodySize, $maxWidth) : [];
|
||||
$titleLines = $fontBold ? $this->wrapText($title, $fontBold, $titleSize, $maxWidth) : [];
|
||||
|
||||
$bodyHeight = $this->measureBlockHeight($bodyLines, $bodySize, $bodyLineHeight);
|
||||
$titleHeight = $this->measureBlockHeight($titleLines, $titleSize, $titleLineHeight);
|
||||
|
||||
$bodyTopY = $this->height - $footerReserved - $bodyMargin - $bodyHeight;
|
||||
$titleTopY = $bodyTopY - $titleMargin - $titleHeight;
|
||||
|
||||
$core = $image->core()->native();
|
||||
|
||||
if ($fontBold && $titleLines) {
|
||||
$this->renderTextLines($core, $titleLines, $fontBold, $titleSize, $titleLineHeight, '#ffffff', $padding, $titleTopY);
|
||||
}
|
||||
if ($fontMedium && $bodyLines) {
|
||||
$this->renderTextLines($core, $bodyLines, $fontMedium, $bodySize, $bodyLineHeight, '#f5f5f5', $padding, $bodyTopY);
|
||||
}
|
||||
|
||||
return $image;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap text into lines that fit within $maxWidth using the given font.
|
||||
* Respects explicit \n line breaks. Returns an array of line strings.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function wrapText(string $text, string $fontPath, int $fontSize, int $maxWidth): array
|
||||
{
|
||||
$lines = [];
|
||||
foreach (explode("\n", $text) as $paragraph) {
|
||||
$words = preg_split('/\s+/', trim($paragraph)) ?: [];
|
||||
if (empty($words)) {
|
||||
$lines[] = '';
|
||||
|
||||
continue;
|
||||
}
|
||||
$current = '';
|
||||
foreach ($words as $word) {
|
||||
$candidate = $current === '' ? $word : $current.' '.$word;
|
||||
$box = imagettfbbox($fontSize, 0, $fontPath, $candidate);
|
||||
$width = abs($box[2] - $box[0]);
|
||||
if ($width > $maxWidth && $current !== '') {
|
||||
$lines[] = $current;
|
||||
$current = $word;
|
||||
} else {
|
||||
$current = $candidate;
|
||||
}
|
||||
}
|
||||
if ($current !== '') {
|
||||
$lines[] = $current;
|
||||
}
|
||||
}
|
||||
|
||||
return $lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute total visual height of a wrapped text block. We rely on the actual
|
||||
* font ascent (from imagettfbbox of an x-height-tall sample) plus
|
||||
* (n-1) * line_spacing for a tight fit.
|
||||
*
|
||||
* @param array<int, string> $lines
|
||||
*/
|
||||
private function measureBlockHeight(array $lines, int $fontSize, float $lineHeight): int
|
||||
{
|
||||
if (empty($lines)) {
|
||||
return 0;
|
||||
}
|
||||
$lineSpacing = (int) round($fontSize * $lineHeight);
|
||||
|
||||
return $lineSpacing * count($lines);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render an array of lines line-by-line via imagettftext at explicit y positions.
|
||||
* $topY is where the first line's bounding box starts (top of first glyph row).
|
||||
*
|
||||
* @param array<int, string> $lines
|
||||
*/
|
||||
private function renderTextLines($core, array $lines, string $fontPath, int $fontSize, float $lineHeight, string $hexColor, int $x, int $topY): void
|
||||
{
|
||||
$color = $this->allocateColor($core, $hexColor);
|
||||
$lineSpacing = (int) round($fontSize * $lineHeight);
|
||||
// imagettftext positions the text at the BASELINE. The font's ascent for our
|
||||
// body line height is roughly fontSize * 0.78 — we use that as the offset
|
||||
// from $topY to the first baseline.
|
||||
$ascent = (int) round($fontSize * 0.82);
|
||||
$baselineY = $topY + $ascent;
|
||||
|
||||
foreach ($lines as $line) {
|
||||
imagettftext($core, $fontSize, 0, $x, $baselineY, $color, $fontPath, $line);
|
||||
$baselineY += $lineSpacing;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
*
|
||||
* @return int Color identifier suitable for GD draw functions.
|
||||
*/
|
||||
private function allocateColor($core, string $hex): int
|
||||
{
|
||||
[$r, $g, $b] = $this->hexToRgb($hex);
|
||||
|
||||
$color = imagecolorallocate($core, $r, $g, $b);
|
||||
|
||||
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}
|
||||
*/
|
||||
private function hexToRgb(string $hex): array
|
||||
{
|
||||
$hex = ltrim($hex, '#');
|
||||
if (strlen($hex) === 3) {
|
||||
$hex = $hex[0].$hex[0].$hex[1].$hex[1].$hex[2].$hex[2];
|
||||
}
|
||||
|
||||
return [
|
||||
hexdec(substr($hex, 0, 2)),
|
||||
hexdec(substr($hex, 2, 2)),
|
||||
hexdec(substr($hex, 4, 2)),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Paint a smooth black-to-transparent gradient over the bottom of the image
|
||||
* directly on the GD resource. Avoids visible bands from stacked rectangles.
|
||||
*
|
||||
* @param float $easingPower 1.0 = linear, 2.0 = quadratic (slow start),
|
||||
* <1.0 = ramps up faster at the top.
|
||||
*/
|
||||
private function applyBottomGradient(ImageInterface $image, float $heightFraction, float $maxAlpha, float $easingPower = 2.0): void
|
||||
{
|
||||
$maskHeight = (int) ($this->height * $heightFraction);
|
||||
$maskStart = $this->height - $maskHeight;
|
||||
|
||||
$core = $image->core()->native(); // GD resource
|
||||
imagealphablending($core, true);
|
||||
|
||||
for ($y = 0; $y < $maskHeight; $y++) {
|
||||
$progress = $y / max(1, $maskHeight - 1);
|
||||
$alphaFraction = (float) (pow($progress, $easingPower) * $maxAlpha);
|
||||
// GD alpha is inverted: 0=opaque, 127=transparent.
|
||||
$gdAlpha = (int) round(127 * (1 - $alphaFraction));
|
||||
$color = imagecolorallocatealpha($core, 0, 0, 0, $gdAlpha);
|
||||
imagefilledrectangle($core, 0, $maskStart + $y, $this->width - 1, $maskStart + $y, $color);
|
||||
imagecolordeallocate($core, $color);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
{
|
||||
$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);
|
||||
|
||||
$username = $socialAccount->username ?? '';
|
||||
$displayName = $socialAccount->display_name ?? '';
|
||||
|
||||
// Footer row anchored from the bottom: avatar + handle + displayName
|
||||
// share the same vertical center so they line up cleanly.
|
||||
$avatarSize = 48;
|
||||
$avatarX = 60;
|
||||
$rowCenterY = $this->height - 100; // 100px from the bottom edge
|
||||
|
||||
$avatarY = $rowCenterY - (int) ($avatarSize / 2);
|
||||
|
||||
$textX = $avatarX + $avatarSize + 16;
|
||||
// intervention/image's `align('left', 'top')` positions text at its EM-box
|
||||
// top. Inter's visual glyph midpoint sits roughly at top + size * 0.42, so
|
||||
// we shift textY up by that amount to land its center on rowCenterY.
|
||||
$textY = $rowCenterY - (int) round(24 * 0.42);
|
||||
|
||||
// Avatar (circular)
|
||||
$avatarBinary = $this->fetchAvatarBinary($socialAccount);
|
||||
if ($avatarBinary !== null) {
|
||||
$this->drawCircularAvatar($canvas, $avatarBinary, $avatarX, $avatarY, $avatarSize);
|
||||
}
|
||||
|
||||
$fontLight = $this->fontPath('Inter-Light.ttf');
|
||||
if (! $fontLight || ! file_exists($fontLight)) {
|
||||
return $canvas;
|
||||
}
|
||||
|
||||
if ($username) {
|
||||
$canvas->text('@'.$username, $textX, $textY, function (FontFactory $font) use ($fontLight, $footerColor) {
|
||||
$font->filename($fontLight);
|
||||
$font->size(24);
|
||||
$font->color($footerColor);
|
||||
$font->align('left', 'top');
|
||||
});
|
||||
}
|
||||
|
||||
if ($displayName) {
|
||||
$canvas->text($displayName, $this->width - 60, $textY, function (FontFactory $font) use ($fontLight, $footerColor) {
|
||||
$font->filename($fontLight);
|
||||
$font->size(24);
|
||||
$font->color($footerColor);
|
||||
$font->align('right', 'top');
|
||||
});
|
||||
}
|
||||
|
||||
return $canvas;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the avatar binary via Storage (works with local, R2, S3 — whatever
|
||||
* `filesystems.default` is). Returns null when there's no avatar or the read fails.
|
||||
*/
|
||||
private function fetchAvatarBinary(SocialAccount $socialAccount): ?string
|
||||
{
|
||||
$rawPath = $socialAccount->getRawOriginal('avatar_url');
|
||||
if (! $rawPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
if (! Storage::exists($rawPath)) {
|
||||
return null;
|
||||
}
|
||||
$contents = Storage::get($rawPath);
|
||||
|
||||
return $contents ?: null;
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('TemplateImageGenerator: avatar fetch failed', [
|
||||
'account' => $socialAccount->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws a circular avatar by overlaying a per-pixel alpha-masked GD truecolor
|
||||
* onto the canvas. Pixels outside the inscribed circle become fully transparent.
|
||||
*/
|
||||
private function drawCircularAvatar(ImageInterface $canvas, string $avatarBinary, int $x, int $y, int $size): void
|
||||
{
|
||||
$core = $canvas->core()->native(); // GD resource
|
||||
|
||||
$src = @imagecreatefromstring($avatarBinary);
|
||||
if (! $src) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Square-crop center, then resize to $size x $size.
|
||||
$sw = imagesx($src);
|
||||
$sh = imagesy($src);
|
||||
$crop = min($sw, $sh);
|
||||
$cx = (int) (($sw - $crop) / 2);
|
||||
$cy = (int) (($sh - $crop) / 2);
|
||||
$resized = imagecreatetruecolor($size, $size);
|
||||
imagealphablending($resized, false);
|
||||
imagesavealpha($resized, true);
|
||||
$transparent = imagecolorallocatealpha($resized, 0, 0, 0, 127);
|
||||
imagefill($resized, 0, 0, $transparent);
|
||||
imagecopyresampled($resized, $src, 0, 0, $cx, $cy, $size, $size, $crop, $crop);
|
||||
imagedestroy($src);
|
||||
|
||||
// Apply circular alpha mask.
|
||||
$cx = $size / 2;
|
||||
$cy = $size / 2;
|
||||
$r = $size / 2;
|
||||
for ($py = 0; $py < $size; $py++) {
|
||||
for ($px = 0; $px < $size; $px++) {
|
||||
$dx = $px + 0.5 - $cx;
|
||||
$dy = $py + 0.5 - $cy;
|
||||
$dist = sqrt($dx * $dx + $dy * $dy);
|
||||
if ($dist > $r) {
|
||||
imagesetpixel($resized, $px, $py, $transparent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Composite onto the main canvas (alpha-aware).
|
||||
imagealphablending($core, true);
|
||||
imagecopy($core, $resized, $x, $y, 0, 0, $size, $size);
|
||||
imagedestroy($resized);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render text at a precise (x, baselineY) position with optional letter spacing.
|
||||
* Letter spacing > 0 renders each character individually with extra pixels between glyphs.
|
||||
*/
|
||||
private function drawTextAt($core, string $text, string $fontPath, int $fontSize, string $hexColor, int $x, int $baselineY, int $letterSpacing = 0): void
|
||||
{
|
||||
$color = $this->allocateColor($core, $hexColor);
|
||||
|
||||
if ($letterSpacing <= 0) {
|
||||
imagettftext($core, $fontSize, 0, $x, $baselineY, $color, $fontPath, $text);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$cursor = $x;
|
||||
$chars = mb_str_split($text);
|
||||
foreach ($chars as $char) {
|
||||
imagettftext($core, $fontSize, 0, $cursor, $baselineY, $color, $fontPath, $char);
|
||||
$bbox = imagettfbbox($fontSize, 0, $fontPath, $char);
|
||||
$cursor += abs($bbox[2] - $bbox[0]) + $letterSpacing;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Measure the on-screen width of a string when rendered with extra letter spacing.
|
||||
*/
|
||||
private function measureLetterSpacedWidth(string $text, string $fontPath, int $fontSize, int $letterSpacing): int
|
||||
{
|
||||
if ($letterSpacing <= 0) {
|
||||
$bbox = imagettfbbox($fontSize, 0, $fontPath, $text);
|
||||
|
||||
return abs($bbox[2] - $bbox[0]);
|
||||
}
|
||||
|
||||
$width = 0;
|
||||
$chars = mb_str_split($text);
|
||||
$count = count($chars);
|
||||
foreach ($chars as $i => $char) {
|
||||
$bbox = imagettfbbox($fontSize, 0, $fontPath, $char);
|
||||
$width += abs($bbox[2] - $bbox[0]);
|
||||
if ($i < $count - 1) {
|
||||
$width += $letterSpacing;
|
||||
}
|
||||
}
|
||||
|
||||
return $width;
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw a 1px horizontal line on the GD resource with optional alpha (0..1).
|
||||
*/
|
||||
private function drawHorizontalLine($core, int $x1, int $x2, int $y, string $hexColor, float $alpha = 1.0): void
|
||||
{
|
||||
[$r, $g, $b] = $this->hexToRgb($hexColor);
|
||||
// GD alpha: 0 opaque, 127 transparent.
|
||||
$gdAlpha = (int) round(127 * (1 - max(0.0, min(1.0, $alpha))));
|
||||
imagealphablending($core, true);
|
||||
$color = imagecolorallocatealpha($core, $r, $g, $b, $gdAlpha);
|
||||
imageline($core, $x1, $y, $x2, $y, $color);
|
||||
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);
|
||||
|
||||
return file_exists($path) ? $path : null;
|
||||
}
|
||||
}
|
||||
152
app/Services/Unsplash/UnsplashClient.php
Normal file
152
app/Services/Unsplash/UnsplashClient.php
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
<?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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\AiMessage;
|
||||
use App\Models\Post;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/** @extends Factory<AiMessage> */
|
||||
class AiMessageFactory extends Factory
|
||||
{
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'post_id' => Post::factory(),
|
||||
'user_id' => User::factory(),
|
||||
'role' => 'user',
|
||||
'content' => $this->faker->sentence(),
|
||||
'attachments' => [],
|
||||
];
|
||||
}
|
||||
|
||||
public function assistant(): static
|
||||
{
|
||||
return $this->state(fn () => [
|
||||
'role' => 'assistant',
|
||||
'user_id' => null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
30
database/factories/PostTemplateFactory.php
Normal file
30
database/factories/PostTemplateFactory.php
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\PostTemplate;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<PostTemplate>
|
||||
*/
|
||||
class PostTemplateFactory extends Factory
|
||||
{
|
||||
protected $model = PostTemplate::class;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'name' => fake()->sentence(3),
|
||||
'description' => fake()->sentence(),
|
||||
'category' => 'product_launch',
|
||||
'platform' => 'instagram_carousel',
|
||||
'content' => fake()->paragraph(),
|
||||
'slides' => null,
|
||||
'image_count' => 0,
|
||||
'image_keywords' => null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -25,6 +25,7 @@ public function up(): void
|
|||
$table->string('brand_color', 9)->nullable();
|
||||
$table->string('background_color', 9)->nullable();
|
||||
$table->string('text_color', 9)->nullable();
|
||||
$table->string('brand_font')->default('Inter');
|
||||
$table->string('content_language', 10)->default('en');
|
||||
$table->timestamps();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,33 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
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::create('ai_messages', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
$table->uuid('post_id');
|
||||
$table->uuid('user_id')->nullable();
|
||||
$table->string('role');
|
||||
$table->text('content');
|
||||
$table->json('attachments')->default('[]');
|
||||
$table->json('metadata')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->foreign('post_id')->references('id')->on('posts')->cascadeOnDelete();
|
||||
$table->foreign('user_id')->references('id')->on('users')->nullOnDelete();
|
||||
$table->index(['post_id', 'created_at']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('ai_messages');
|
||||
}
|
||||
};
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
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('ai_messages', function (Blueprint $table) {
|
||||
$table->string('status')->default('completed')->after('attachments');
|
||||
$table->text('error_message')->nullable()->after('status');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('ai_messages', function (Blueprint $table) {
|
||||
$table->dropColumn(['status', 'error_message']);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
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::create('post_templates', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
$table->string('name');
|
||||
$table->text('description')->nullable();
|
||||
$table->string('category');
|
||||
$table->string('platform');
|
||||
$table->text('content');
|
||||
$table->json('slides')->nullable();
|
||||
$table->integer('image_count')->default(0);
|
||||
$table->json('image_keywords')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['platform', 'category']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('post_templates');
|
||||
}
|
||||
};
|
||||
|
|
@ -4,7 +4,6 @@
|
|||
|
||||
namespace Database\Seeders;
|
||||
|
||||
// use Illuminate\Database\Console\Seeds\WithoutModelEvents;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class DatabaseSeeder extends Seeder
|
||||
|
|
|
|||
219
database/seeders/PostTemplateSeeder.php
Normal file
219
database/seeders/PostTemplateSeeder.php
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\PostTemplate;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class PostTemplateSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
$templates = [
|
||||
// --- product_launch ---
|
||||
[
|
||||
'name' => 'Feature launch — carousel',
|
||||
'description' => 'Announce a new product feature with a 5-slide story.',
|
||||
'category' => 'product_launch',
|
||||
'platform' => 'instagram_carousel',
|
||||
'content' => "{{brand_name}} just launched something we're excited about. Swipe to see what's new.",
|
||||
'slides' => [
|
||||
['title' => "What's new", 'body' => 'A quick look at the latest update.', 'image_keywords' => ['product', 'launch']],
|
||||
['title' => 'How it works', 'body' => 'Three steps and you are running.', 'image_keywords' => ['workflow']],
|
||||
['title' => 'Why it matters', 'body' => 'Faster, simpler, fewer clicks.', 'image_keywords' => ['speed']],
|
||||
['title' => 'Who is it for', 'body' => 'Built for teams who ship.', 'image_keywords' => ['team']],
|
||||
['title' => 'Try it now', 'body' => 'Available today. Link in bio.', 'image_keywords' => ['phone', 'app']],
|
||||
],
|
||||
'image_count' => 5,
|
||||
'image_keywords' => null,
|
||||
],
|
||||
[
|
||||
'name' => 'Product launch announcement',
|
||||
'description' => 'Short LinkedIn post announcing a new product.',
|
||||
'category' => 'product_launch',
|
||||
'platform' => 'linkedin_post',
|
||||
'content' => "Excited to share: {{brand_name}} just launched [Product Name].\n\nHere's why this matters for [your audience]:\n→ [Benefit 1]\n→ [Benefit 2]\n→ [Benefit 3]\n\nLearn more at [link]. We'd love your feedback.",
|
||||
'slides' => null,
|
||||
'image_count' => 0,
|
||||
'image_keywords' => null,
|
||||
],
|
||||
// --- promotion ---
|
||||
[
|
||||
'name' => 'Limited-time offer — feed',
|
||||
'description' => 'Promote a time-sensitive deal on Instagram Feed.',
|
||||
'category' => 'promotion',
|
||||
'platform' => 'instagram_feed',
|
||||
'content' => "⏰ Don't miss it. {{brand_name}} is offering [discount]% off [product/service] — today only.\n\nTap the link in bio to grab yours before it's gone.",
|
||||
'slides' => null,
|
||||
'image_count' => 1,
|
||||
'image_keywords' => ['sale', 'offer', 'shopping'],
|
||||
],
|
||||
[
|
||||
'name' => 'Flash sale — X post',
|
||||
'description' => 'Short X post for a flash sale with urgency.',
|
||||
'category' => 'promotion',
|
||||
'platform' => 'x_post',
|
||||
'content' => "🔥 Flash sale at {{brand_name}}!\n\n[Discount]% off [product/service] — ends in 24 hours.\n\nNo code needed → [link]",
|
||||
'slides' => null,
|
||||
'image_count' => 0,
|
||||
'image_keywords' => null,
|
||||
],
|
||||
// --- educational ---
|
||||
[
|
||||
'name' => '5 tips carousel',
|
||||
'description' => 'Share five actionable tips in a swipeable carousel.',
|
||||
'category' => 'educational',
|
||||
'platform' => 'instagram_carousel',
|
||||
'content' => '5 things I wish I knew about [topic] sooner. Save this for later. 👇',
|
||||
'slides' => [
|
||||
['title' => 'Tip #1', 'body' => '[First tip — be specific and actionable.]', 'image_keywords' => ['idea', 'lightbulb']],
|
||||
['title' => 'Tip #2', 'body' => '[Second tip — use a real example if you can.]', 'image_keywords' => ['notebook']],
|
||||
['title' => 'Tip #3', 'body' => '[Third tip — keep it short.]', 'image_keywords' => ['focus']],
|
||||
['title' => 'Tip #4', 'body' => '[Fourth tip — something surprising or counterintuitive.]', 'image_keywords' => ['surprise']],
|
||||
['title' => 'Tip #5', 'body' => '[Fifth tip — finish strong with a takeaway.]', 'image_keywords' => ['success']],
|
||||
],
|
||||
'image_count' => 5,
|
||||
'image_keywords' => null,
|
||||
],
|
||||
[
|
||||
'name' => 'How-to guide — LinkedIn',
|
||||
'description' => 'Step-by-step educational post for LinkedIn.',
|
||||
'category' => 'educational',
|
||||
'platform' => 'linkedin_post',
|
||||
'content' => "How to [achieve X] in [timeframe] — a step-by-step guide.\n\nStep 1: [Action]\nStep 2: [Action]\nStep 3: [Action]\nStep 4: [Action]\n\nThe key insight most people miss: [insight].\n\nWhich step do you find most challenging? Drop a comment below.",
|
||||
'slides' => null,
|
||||
'image_count' => 0,
|
||||
'image_keywords' => null,
|
||||
],
|
||||
// --- behind_the_scenes ---
|
||||
[
|
||||
'name' => 'Team behind the scenes — feed',
|
||||
'description' => 'Humanize your brand by showing the team at work.',
|
||||
'category' => 'behind_the_scenes',
|
||||
'platform' => 'instagram_feed',
|
||||
'content' => "This is what a typical day at {{brand_name}} looks like. 👀\n\nWe believe great work happens when people feel at home. Here's a peek behind the curtain.\n\n#{{brand_name}} #BehindTheScenes",
|
||||
'slides' => null,
|
||||
'image_count' => 1,
|
||||
'image_keywords' => ['office', 'team', 'workspace'],
|
||||
],
|
||||
[
|
||||
'name' => 'Process reveal — carousel',
|
||||
'description' => 'Walk followers through how you create your product or service.',
|
||||
'category' => 'behind_the_scenes',
|
||||
'platform' => 'instagram_carousel',
|
||||
'content' => 'Ever wondered how [product/service] is made? Swipe to see every step. ✨',
|
||||
'slides' => [
|
||||
['title' => 'It starts with research', 'body' => 'Every project begins with understanding the problem deeply.', 'image_keywords' => ['research', 'desk']],
|
||||
['title' => 'Design & iteration', 'body' => 'We prototype fast and test often.', 'image_keywords' => ['design', 'sketch']],
|
||||
['title' => 'Building the real thing', 'body' => 'Craft and precision in every detail.', 'image_keywords' => ['craft', 'build']],
|
||||
['title' => 'The final result', 'body' => 'Quality you can feel the moment you use it.', 'image_keywords' => ['product', 'final']],
|
||||
],
|
||||
'image_count' => 4,
|
||||
'image_keywords' => null,
|
||||
],
|
||||
// --- testimonial ---
|
||||
[
|
||||
'name' => 'Customer quote — LinkedIn',
|
||||
'description' => 'Share a compelling customer testimonial on LinkedIn.',
|
||||
'category' => 'testimonial',
|
||||
'platform' => 'linkedin_post',
|
||||
'content' => "\"[Customer quote about the result they achieved with your product or service.]\"\n— [Customer Name], [Role] at [Company]\n\nThis is exactly why we built {{brand_name}}. Real results for real teams.\n\nRead the full story → [link]",
|
||||
'slides' => null,
|
||||
'image_count' => 0,
|
||||
'image_keywords' => null,
|
||||
],
|
||||
[
|
||||
'name' => 'Success story — X post',
|
||||
'description' => 'Brief success-story format for X.',
|
||||
'category' => 'testimonial',
|
||||
'platform' => 'x_post',
|
||||
'content' => "\"[Short customer quote.]\"\n— @[handle]\n\nThis is why we do what we do at {{brand_name}}. 🙌",
|
||||
'slides' => null,
|
||||
'image_count' => 0,
|
||||
'image_keywords' => null,
|
||||
],
|
||||
// --- industry_tip ---
|
||||
[
|
||||
'name' => 'Industry insight — LinkedIn',
|
||||
'description' => 'Share a timely insight or hot take relevant to your industry.',
|
||||
'category' => 'industry_tip',
|
||||
'platform' => 'linkedin_post',
|
||||
'content' => "Here's something most people in [industry] get wrong:\n\n[Counterintuitive statement or hot take]\n\nWhy? Because [reason].\n\nThe smarter approach: [better way to do it].\n\nAt {{brand_name}}, we've seen this play out with hundreds of [customers/teams]. The data is clear.\n\nWhat's your take?",
|
||||
'slides' => null,
|
||||
'image_count' => 0,
|
||||
'image_keywords' => null,
|
||||
],
|
||||
[
|
||||
'name' => 'Quick stat carousel',
|
||||
'description' => 'Present industry stats in a visual carousel format.',
|
||||
'category' => 'industry_tip',
|
||||
'platform' => 'instagram_carousel',
|
||||
'content' => "The [industry] numbers that should be on every marketer's radar this year. 📊",
|
||||
'slides' => [
|
||||
['title' => 'Stat #1', 'body' => '[X]% of [audience] say [finding]. Source: [source]', 'image_keywords' => ['chart', 'data']],
|
||||
['title' => 'Stat #2', 'body' => '[Y]% increase in [metric] year over year.', 'image_keywords' => ['growth', 'graph']],
|
||||
['title' => 'Stat #3', 'body' => 'By [year], [projection].', 'image_keywords' => ['future', 'trend']],
|
||||
['title' => 'What this means for you', 'body' => '[Actionable takeaway based on the stats above.]', 'image_keywords' => ['action', 'plan']],
|
||||
],
|
||||
'image_count' => 4,
|
||||
'image_keywords' => null,
|
||||
],
|
||||
// --- event ---
|
||||
[
|
||||
'name' => 'Event announcement — LinkedIn',
|
||||
'description' => 'Announce an upcoming webinar or live event.',
|
||||
'category' => 'event',
|
||||
'platform' => 'linkedin_post',
|
||||
'content' => "📅 Save the date: {{brand_name}} is hosting [Event Name] on [Date] at [Time].\n\nWhat to expect:\n✔ [Topic 1]\n✔ [Topic 2]\n✔ [Topic 3]\n\nSpots are limited. Register now → [link]",
|
||||
'slides' => null,
|
||||
'image_count' => 0,
|
||||
'image_keywords' => null,
|
||||
],
|
||||
[
|
||||
'name' => 'Event recap — carousel',
|
||||
'description' => 'Recap highlights from a recent event or conference.',
|
||||
'category' => 'event',
|
||||
'platform' => 'instagram_carousel',
|
||||
'content' => 'We just wrapped [Event Name] and it was incredible. Here are the highlights. 🙌',
|
||||
'slides' => [
|
||||
['title' => 'It all started with…', 'body' => '[Opening moment or keynote highlight.]', 'image_keywords' => ['conference', 'stage']],
|
||||
['title' => 'The session everyone talked about', 'body' => '[Key insight from the standout talk.]', 'image_keywords' => ['presentation', 'crowd']],
|
||||
['title' => 'Connecting with the community', 'body' => '[Highlight networking or attendee moments.]', 'image_keywords' => ['networking', 'people']],
|
||||
['title' => 'See you next time', 'body' => 'Follow {{brand_name}} for updates on our next event.', 'image_keywords' => ['celebration']],
|
||||
],
|
||||
'image_count' => 4,
|
||||
'image_keywords' => null,
|
||||
],
|
||||
// --- engagement ---
|
||||
[
|
||||
'name' => 'This or that — X poll',
|
||||
'description' => 'Drive engagement with a simple binary choice question.',
|
||||
'category' => 'engagement',
|
||||
'platform' => 'x_post',
|
||||
'content' => "Quick question for the {{brand_name}} community:\n\n[Option A] or [Option B]?\n\nReply with your pick and why 👇",
|
||||
'slides' => null,
|
||||
'image_count' => 0,
|
||||
'image_keywords' => null,
|
||||
],
|
||||
[
|
||||
'name' => 'Community question — LinkedIn',
|
||||
'description' => 'Spark discussion with an open-ended question to your network.',
|
||||
'category' => 'engagement',
|
||||
'platform' => 'linkedin_post',
|
||||
'content' => "If you could give one piece of advice to someone just starting out in [industry/role], what would it be?\n\nMine: [your answer — be honest and specific]\n\nDrop yours in the comments. Let's build a thread worth bookmarking.",
|
||||
'slides' => null,
|
||||
'image_count' => 0,
|
||||
'image_keywords' => null,
|
||||
],
|
||||
];
|
||||
|
||||
foreach ($templates as $t) {
|
||||
PostTemplate::query()->updateOrCreate(
|
||||
['name' => $t['name'], 'platform' => $t['platform']],
|
||||
$t,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'placeholder' => 'Ask me to write a caption, generate an image or video...',
|
||||
'thinking' => 'Thinking...',
|
||||
'add_to_post' => 'Add to post',
|
||||
'added' => 'Added',
|
||||
'error' => 'Something went wrong. Please try again.',
|
||||
'retry' => 'Try again',
|
||||
'image_generated' => 'Here is the generated image:',
|
||||
'video_generated' => 'Here is the generated video:',
|
||||
'audio_generated' => 'Here is the generated audio:',
|
||||
'empty' => 'Ask me anything. I can write captions, generate images, and produce videos.',
|
||||
'limit_reached_images' => 'You have reached your monthly image generation limit.',
|
||||
'limit_reached_videos' => 'You have reached your monthly video generation limit.',
|
||||
'content_blocked' => "I can't help with that type of content. I'm here to help you create safe, engaging social media content.",
|
||||
];
|
||||
|
|
@ -165,6 +165,33 @@
|
|||
'published' => 'Posts already published',
|
||||
],
|
||||
|
||||
'ai' => [
|
||||
'generate' => [
|
||||
'button_tooltip' => 'Generate with AI',
|
||||
'title' => 'Generate post with AI',
|
||||
'description' => 'Describe what the post should be about. The AI will use your brand context to write it.',
|
||||
'prompt_label' => 'What is this post about?',
|
||||
'prompt_placeholder' => 'e.g. Announce our new image-generation feature for carousels',
|
||||
'preview_label' => 'Preview',
|
||||
'start' => 'Generate',
|
||||
'apply' => 'Use this content',
|
||||
'retry' => 'Try again',
|
||||
'cancel' => 'Cancel',
|
||||
],
|
||||
'review' => [
|
||||
'button_tooltip' => 'Review with AI',
|
||||
'title' => 'Review post with AI',
|
||||
'description' => 'AI scans for grammar, spelling, and clarity issues. Apply suggestions one by one.',
|
||||
'loading' => 'Reviewing your text...',
|
||||
'no_issues' => 'No issues found. Looks good.',
|
||||
'original' => 'Original',
|
||||
'suggestion' => 'Suggestion',
|
||||
'apply' => 'Apply',
|
||||
'applied' => 'Applied',
|
||||
'close' => 'Close',
|
||||
],
|
||||
],
|
||||
|
||||
'show' => [
|
||||
'title' => 'Post Details',
|
||||
'edit' => 'Edit',
|
||||
|
|
@ -247,8 +274,6 @@
|
|||
'schedule' => 'Schedule',
|
||||
'comments' => 'Comments',
|
||||
'comments_empty' => 'No comments yet.',
|
||||
'writing_assistant' => 'AI Assistant',
|
||||
'writing_assistant_empty' => 'AI writing assistant coming soon.',
|
||||
],
|
||||
|
||||
'media_picker' => [
|
||||
|
|
@ -433,4 +458,82 @@
|
|||
'account_disconnected' => 'Social account is disconnected',
|
||||
'account_inactive' => 'Social account is deactivated',
|
||||
],
|
||||
|
||||
'create' => [
|
||||
'title' => 'Create a new post',
|
||||
'description' => 'Choose how you want to start.',
|
||||
'scratch_title' => 'Start from scratch',
|
||||
'scratch_description' => 'Open a blank post and write everything yourself.',
|
||||
'ai_title' => 'Generate with AI',
|
||||
'ai_description' => 'Describe what you want and AI generates the content for you.',
|
||||
'ai_configure_description' => 'Pick a format and describe the post you want to create.',
|
||||
'template_title' => 'Use a template',
|
||||
'template_description' => 'Pick from our curated templates and customize.',
|
||||
'coming_soon' => 'Coming soon',
|
||||
|
||||
'steps' => [
|
||||
'format_title' => 'Choose a format',
|
||||
'format_description' => 'Select the type of post you want to create.',
|
||||
'account_title' => 'Choose an account',
|
||||
'account_description' => 'Select the social account to publish to.',
|
||||
'media_title' => 'Media options',
|
||||
'media_carousel' => 'How many slides?',
|
||||
'media_optional' => 'Include images?',
|
||||
'media_optional_label' => 'How many images?',
|
||||
'media_none' => 'None',
|
||||
'media_count_label' => 'Number of images',
|
||||
'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.',
|
||||
'create' => 'Create post',
|
||||
'back' => 'Back',
|
||||
'next' => 'Continue',
|
||||
'cancel' => 'Cancel',
|
||||
'discard' => 'Discard',
|
||||
'retry' => 'Try again',
|
||||
'no_platforms' => 'No connected accounts',
|
||||
'connect_first' => 'Connect at least one social account to use AI generation.',
|
||||
|
||||
'format' => [
|
||||
'instagram_feed' => 'Instagram Feed Post',
|
||||
'instagram_carousel' => 'Instagram Carousel',
|
||||
'linkedin_post' => 'LinkedIn Post',
|
||||
'linkedin_page_post' => 'LinkedIn Page Post',
|
||||
'x_post' => 'X Post',
|
||||
'bluesky_post' => 'Bluesky Post',
|
||||
'threads_post' => 'Threads Post',
|
||||
'mastodon_post' => 'Mastodon Post',
|
||||
'facebook_post' => 'Facebook Post',
|
||||
'pinterest_pin' => 'Pinterest Pin',
|
||||
'instagram_story' => 'Instagram Story',
|
||||
'facebook_story' => 'Facebook Story',
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
'templates' => [
|
||||
'browser_title' => 'Choose a template',
|
||||
'browser_description' => 'Start from a curated template and adapt it.',
|
||||
'search_placeholder' => 'Search templates…',
|
||||
'no_search_results' => 'No templates match your search',
|
||||
'try_different_search' => 'Try a different keyword or clear the search.',
|
||||
'slides_count' => '{count} slide|{count} slides',
|
||||
'all_platforms' => 'All platforms',
|
||||
'use_this' => 'Use this template',
|
||||
'no_templates' => 'No templates available.',
|
||||
'applying' => 'Applying template…',
|
||||
'category' => [
|
||||
'product_launch' => 'Product launch',
|
||||
'promotion' => 'Promotion',
|
||||
'educational' => 'Educational',
|
||||
'behind_the_scenes' => 'Behind the scenes',
|
||||
'testimonial' => 'Testimonial',
|
||||
'industry_tip' => 'Industry tip',
|
||||
'event' => 'Event',
|
||||
'engagement' => 'Engagement',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -109,6 +109,7 @@
|
|||
'brand_color' => 'Brand color',
|
||||
'background_color' => 'Background color',
|
||||
'text_color' => 'Text color',
|
||||
'font' => 'Font',
|
||||
'content_language' => 'Content language',
|
||||
'content_language_description' => 'Language used for AI-generated captions, hashtags, and any text inside generated images or videos.',
|
||||
],
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
'create' => [
|
||||
'page_title' => 'Create your workspace',
|
||||
'title' => 'Set up your workspace',
|
||||
'description' => 'Tell us about your brand. We\'ll use this to tailor AI-generated posts to your voice.',
|
||||
'description' => 'Tell us a bit about you or your project. We\'ll use it to tailor AI-generated posts to your voice.',
|
||||
'website' => 'Website',
|
||||
'website_placeholder' => 'https://yourbrand.com',
|
||||
'autofill' => 'Autofill from website',
|
||||
|
|
|
|||
|
|
@ -1,19 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'placeholder' => 'Pídeme escribir una descripción, generar una imagen o video...',
|
||||
'thinking' => 'Pensando...',
|
||||
'add_to_post' => 'Añadir al post',
|
||||
'added' => 'Añadido',
|
||||
'error' => 'Algo salió mal. Inténtalo de nuevo.',
|
||||
'retry' => 'Intentar de nuevo',
|
||||
'image_generated' => 'Aquí está la imagen generada:',
|
||||
'video_generated' => 'Aquí está el video generado:',
|
||||
'audio_generated' => 'Aquí está el audio generado:',
|
||||
'empty' => 'Pregúntame lo que quieras. Puedo escribir descripciones, generar imágenes y producir videos.',
|
||||
'limit_reached_images' => 'Has alcanzado el límite mensual de generación de imágenes.',
|
||||
'limit_reached_videos' => 'Has alcanzado el límite mensual de generación de videos.',
|
||||
'content_blocked' => 'No puedo ayudar con ese tipo de contenido. Estoy aquí para ayudarte a crear contenido seguro y atractivo para redes sociales.',
|
||||
];
|
||||
|
|
@ -165,6 +165,33 @@
|
|||
'published' => 'Posts ya publicados',
|
||||
],
|
||||
|
||||
'ai' => [
|
||||
'generate' => [
|
||||
'button_tooltip' => 'Generar con IA',
|
||||
'title' => 'Generar post con IA',
|
||||
'description' => 'Describe sobre qué debe ser el post. La IA usará el contexto de tu marca para escribirlo.',
|
||||
'prompt_label' => '¿De qué trata este post?',
|
||||
'prompt_placeholder' => 'ej: anunciar nuestra nueva función de generación de imágenes para carruseles',
|
||||
'preview_label' => 'Vista previa',
|
||||
'start' => 'Generar',
|
||||
'apply' => 'Usar este contenido',
|
||||
'retry' => 'Intentar de nuevo',
|
||||
'cancel' => 'Cancelar',
|
||||
],
|
||||
'review' => [
|
||||
'button_tooltip' => 'Revisar con IA',
|
||||
'title' => 'Revisar post con IA',
|
||||
'description' => 'La IA busca errores de gramática, ortografía y claridad. Aplica las sugerencias una a una.',
|
||||
'loading' => 'Revisando tu texto...',
|
||||
'no_issues' => 'No se encontraron problemas. Todo bien.',
|
||||
'original' => 'Original',
|
||||
'suggestion' => 'Sugerencia',
|
||||
'apply' => 'Aplicar',
|
||||
'applied' => 'Aplicado',
|
||||
'close' => 'Cerrar',
|
||||
],
|
||||
],
|
||||
|
||||
'show' => [
|
||||
'title' => 'Detalles del post',
|
||||
'edit' => 'Editar',
|
||||
|
|
@ -255,8 +282,6 @@
|
|||
'schedule' => 'Programación',
|
||||
'comments' => 'Comentarios',
|
||||
'comments_empty' => 'Todavía no hay comentarios.',
|
||||
'writing_assistant' => 'Asistente IA',
|
||||
'writing_assistant_empty' => 'Asistente de escritura próximamente.',
|
||||
],
|
||||
|
||||
'media_picker' => [
|
||||
|
|
@ -446,4 +471,82 @@
|
|||
'account_disconnected' => 'Cuenta social desconectada',
|
||||
'account_inactive' => 'Cuenta social desactivada',
|
||||
],
|
||||
|
||||
'create' => [
|
||||
'title' => 'Crear nuevo post',
|
||||
'description' => 'Elige cómo quieres empezar.',
|
||||
'scratch_title' => 'Empezar desde cero',
|
||||
'scratch_description' => 'Abre un post en blanco para escribirlo todo.',
|
||||
'ai_title' => 'Generar con IA',
|
||||
'ai_description' => 'Describe lo que quieres y la IA genera el contenido por ti.',
|
||||
'ai_configure_description' => 'Elige un formato y describe el post que quieres crear.',
|
||||
'template_title' => 'Usar una plantilla',
|
||||
'template_description' => 'Elige una de nuestras plantillas y personalízala.',
|
||||
'coming_soon' => 'Próximamente',
|
||||
|
||||
'steps' => [
|
||||
'format_title' => 'Elige un formato',
|
||||
'format_description' => 'Selecciona el tipo de post que quieres crear.',
|
||||
'account_title' => 'Elige una cuenta',
|
||||
'account_description' => 'Selecciona la cuenta social donde publicar.',
|
||||
'media_title' => 'Opciones de medios',
|
||||
'media_carousel' => '¿Cuántas diapositivas?',
|
||||
'media_optional' => '¿Incluir imágenes?',
|
||||
'media_optional_label' => '¿Cuántas imágenes?',
|
||||
'media_none' => 'Ninguna',
|
||||
'media_count_label' => 'Número de imágenes',
|
||||
'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.',
|
||||
'create' => 'Crear post',
|
||||
'back' => 'Atrás',
|
||||
'next' => 'Continuar',
|
||||
'cancel' => 'Cancelar',
|
||||
'discard' => 'Descartar',
|
||||
'retry' => 'Intentar de nuevo',
|
||||
'no_platforms' => 'Sin cuentas conectadas',
|
||||
'connect_first' => 'Conecta al menos una cuenta social para usar la generación con IA.',
|
||||
|
||||
'format' => [
|
||||
'instagram_feed' => 'Post de Feed de Instagram',
|
||||
'instagram_carousel' => 'Carrusel de Instagram',
|
||||
'linkedin_post' => 'Post de LinkedIn',
|
||||
'linkedin_page_post' => 'Post de Página de LinkedIn',
|
||||
'x_post' => 'Post en X',
|
||||
'bluesky_post' => 'Post en Bluesky',
|
||||
'threads_post' => 'Post en Threads',
|
||||
'mastodon_post' => 'Post en Mastodon',
|
||||
'facebook_post' => 'Post en Facebook',
|
||||
'pinterest_pin' => 'Pin de Pinterest',
|
||||
'instagram_story' => 'Story de Instagram',
|
||||
'facebook_story' => 'Story de Facebook',
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
'templates' => [
|
||||
'browser_title' => 'Elige una plantilla',
|
||||
'browser_description' => 'Comienza con una plantilla curada y adáptala.',
|
||||
'search_placeholder' => 'Buscar plantillas…',
|
||||
'no_search_results' => 'Ninguna plantilla coincide con tu búsqueda',
|
||||
'try_different_search' => 'Prueba otra palabra clave o limpia la búsqueda.',
|
||||
'slides_count' => '{count} slide|{count} slides',
|
||||
'all_platforms' => 'Todas las plataformas',
|
||||
'use_this' => 'Usar esta plantilla',
|
||||
'no_templates' => 'No hay plantillas disponibles.',
|
||||
'applying' => 'Aplicando plantilla…',
|
||||
'category' => [
|
||||
'product_launch' => 'Lanzamiento de producto',
|
||||
'promotion' => 'Promoción',
|
||||
'educational' => 'Educativo',
|
||||
'behind_the_scenes' => 'Detrás de cámaras',
|
||||
'testimonial' => 'Testimonio',
|
||||
'industry_tip' => 'Consejo del sector',
|
||||
'event' => 'Evento',
|
||||
'engagement' => 'Interacción',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -109,6 +109,7 @@
|
|||
'brand_color' => 'Color de marca',
|
||||
'background_color' => 'Color de fondo',
|
||||
'text_color' => 'Color de texto',
|
||||
'font' => 'Fuente',
|
||||
'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.',
|
||||
],
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
'create' => [
|
||||
'page_title' => 'Crea tu workspace',
|
||||
'title' => 'Configura tu workspace',
|
||||
'description' => 'Cuéntanos sobre tu marca. Lo usaremos para personalizar las publicaciones generadas por IA con tu voz.',
|
||||
'description' => 'Cuéntanos un poco sobre ti o tu proyecto. Lo usaremos para personalizar las publicaciones generadas por IA con tu voz.',
|
||||
'website' => 'Sitio web',
|
||||
'website_placeholder' => 'https://tumarca.com',
|
||||
'autofill' => 'Autocompletar desde el sitio',
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -1,19 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'placeholder' => 'Me peça para escrever uma legenda, gerar uma imagem ou vídeo...',
|
||||
'thinking' => 'Pensando...',
|
||||
'add_to_post' => 'Adicionar ao post',
|
||||
'added' => 'Adicionado',
|
||||
'error' => 'Algo deu errado. Tente novamente.',
|
||||
'retry' => 'Tentar de novo',
|
||||
'image_generated' => 'Aqui está a imagem gerada:',
|
||||
'video_generated' => 'Aqui está o vídeo gerado:',
|
||||
'audio_generated' => 'Aqui está o áudio gerado:',
|
||||
'empty' => 'Me pergunte qualquer coisa. Posso escrever legendas, gerar imagens e produzir vídeos.',
|
||||
'limit_reached_images' => 'Você atingiu o limite mensal de geração de imagens.',
|
||||
'limit_reached_videos' => 'Você atingiu o limite mensal de geração de vídeos.',
|
||||
'content_blocked' => 'Não posso ajudar com esse tipo de conteúdo. Estou aqui para ajudar você a criar conteúdo seguro e engajador para redes sociais.',
|
||||
];
|
||||
|
|
@ -165,6 +165,33 @@
|
|||
'published' => 'Posts já publicados',
|
||||
],
|
||||
|
||||
'ai' => [
|
||||
'generate' => [
|
||||
'button_tooltip' => 'Gerar com IA',
|
||||
'title' => 'Gerar post com IA',
|
||||
'description' => 'Descreva sobre o que o post deve ser. A IA vai usar o contexto da sua marca pra escrever.',
|
||||
'prompt_label' => 'Sobre o que é esse post?',
|
||||
'prompt_placeholder' => 'ex: anunciar nossa nova feature de geração de imagens pra carrosséis',
|
||||
'preview_label' => 'Prévia',
|
||||
'start' => 'Gerar',
|
||||
'apply' => 'Usar este conteúdo',
|
||||
'retry' => 'Tentar de novo',
|
||||
'cancel' => 'Cancelar',
|
||||
],
|
||||
'review' => [
|
||||
'button_tooltip' => 'Revisar com IA',
|
||||
'title' => 'Revisar post com IA',
|
||||
'description' => 'IA analisa gramática, ortografia e clareza. Aplique as sugestões uma a uma.',
|
||||
'loading' => 'Revisando seu texto...',
|
||||
'no_issues' => 'Nenhum problema encontrado. Tudo certo.',
|
||||
'original' => 'Original',
|
||||
'suggestion' => 'Sugestão',
|
||||
'apply' => 'Aplicar',
|
||||
'applied' => 'Aplicado',
|
||||
'close' => 'Fechar',
|
||||
],
|
||||
],
|
||||
|
||||
'show' => [
|
||||
'title' => 'Detalhes do Post',
|
||||
'edit' => 'Editar',
|
||||
|
|
@ -255,8 +282,6 @@
|
|||
'schedule' => 'Agendamento',
|
||||
'comments' => 'Comentários',
|
||||
'comments_empty' => 'Nenhum comentário ainda.',
|
||||
'writing_assistant' => 'Assistente IA',
|
||||
'writing_assistant_empty' => 'Assistente de escrita em breve.',
|
||||
],
|
||||
|
||||
'media_picker' => [
|
||||
|
|
@ -446,4 +471,82 @@
|
|||
'account_disconnected' => 'Conta social está desconectada',
|
||||
'account_inactive' => 'Conta social está desativada',
|
||||
],
|
||||
|
||||
'create' => [
|
||||
'title' => 'Criar novo post',
|
||||
'description' => 'Escolha como quer começar.',
|
||||
'scratch_title' => 'Começar do zero',
|
||||
'scratch_description' => 'Abre um post em branco pra você escrever tudo.',
|
||||
'ai_title' => 'Gerar com IA',
|
||||
'ai_description' => 'Descreva o que quer e a IA gera o conteúdo pra você.',
|
||||
'ai_configure_description' => 'Escolha o formato e descreva o post que quer criar.',
|
||||
'template_title' => 'Usar um template',
|
||||
'template_description' => 'Escolha um dos nossos templates e personalize.',
|
||||
'coming_soon' => 'Em breve',
|
||||
|
||||
'steps' => [
|
||||
'format_title' => 'Escolha um formato',
|
||||
'format_description' => 'Selecione o tipo de post que deseja criar.',
|
||||
'account_title' => 'Escolha uma conta',
|
||||
'account_description' => 'Selecione a conta social para publicar.',
|
||||
'media_title' => 'Opções de mídia',
|
||||
'media_carousel' => 'Quantos slides?',
|
||||
'media_optional' => 'Incluir imagens?',
|
||||
'media_optional_label' => 'Quantas imagens?',
|
||||
'media_none' => 'Nenhuma',
|
||||
'media_count_label' => 'Número de imagens',
|
||||
'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.',
|
||||
'create' => 'Criar post',
|
||||
'back' => 'Voltar',
|
||||
'next' => 'Continuar',
|
||||
'cancel' => 'Cancelar',
|
||||
'discard' => 'Descartar',
|
||||
'retry' => 'Tentar novamente',
|
||||
'no_platforms' => 'Nenhuma conta conectada',
|
||||
'connect_first' => 'Conecte pelo menos uma conta social para usar a geração com IA.',
|
||||
|
||||
'format' => [
|
||||
'instagram_feed' => 'Post no Feed do Instagram',
|
||||
'instagram_carousel' => 'Carrossel do Instagram',
|
||||
'linkedin_post' => 'Post no LinkedIn',
|
||||
'linkedin_page_post' => 'Post em Página do LinkedIn',
|
||||
'x_post' => 'Post no X',
|
||||
'bluesky_post' => 'Post no Bluesky',
|
||||
'threads_post' => 'Post no Threads',
|
||||
'mastodon_post' => 'Post no Mastodon',
|
||||
'facebook_post' => 'Post no Facebook',
|
||||
'pinterest_pin' => 'Pin no Pinterest',
|
||||
'instagram_story' => 'Story do Instagram',
|
||||
'facebook_story' => 'Story do Facebook',
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
'templates' => [
|
||||
'browser_title' => 'Escolha um template',
|
||||
'browser_description' => 'Comece com um template pronto e adapte ao seu jeito.',
|
||||
'all_platforms' => 'Todas as plataformas',
|
||||
'use_this' => 'Usar este template',
|
||||
'no_templates' => 'Nenhum template disponível.',
|
||||
'applying' => 'Aplicando template…',
|
||||
'search_placeholder' => 'Buscar templates…',
|
||||
'no_search_results' => 'Nenhum template encontrado',
|
||||
'try_different_search' => 'Tente outra palavra-chave ou limpe a busca.',
|
||||
'slides_count' => '{count} slide|{count} slides',
|
||||
'category' => [
|
||||
'product_launch' => 'Lançamento de produto',
|
||||
'promotion' => 'Promoção',
|
||||
'educational' => 'Educacional',
|
||||
'behind_the_scenes' => 'Bastidores',
|
||||
'testimonial' => 'Depoimento',
|
||||
'industry_tip' => 'Dica do setor',
|
||||
'event' => 'Evento',
|
||||
'engagement' => 'Engajamento',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -109,6 +109,7 @@
|
|||
'brand_color' => 'Cor da marca',
|
||||
'background_color' => 'Cor de fundo',
|
||||
'text_color' => 'Cor do texto',
|
||||
'font' => 'Fonte',
|
||||
'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.',
|
||||
],
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
'create' => [
|
||||
'page_title' => 'Crie seu workspace',
|
||||
'title' => 'Configure seu workspace',
|
||||
'description' => 'Conte sobre sua marca. Vamos usar isso para personalizar posts gerados por IA com a sua voz.',
|
||||
'description' => 'Conte um pouco sobre você ou seu projeto. Vamos usar pra personalizar os posts gerados por IA com a sua voz.',
|
||||
'website' => 'Site',
|
||||
'website_placeholder' => 'https://suamarca.com',
|
||||
'autofill' => 'Preencher do site',
|
||||
|
|
|
|||
BIN
resources/fonts/Inter-Bold.ttf
Normal file
BIN
resources/fonts/Inter-Bold.ttf
Normal file
Binary file not shown.
BIN
resources/fonts/Inter-Light.ttf
Normal file
BIN
resources/fonts/Inter-Light.ttf
Normal file
Binary file not shown.
BIN
resources/fonts/Inter-Medium.ttf
Normal file
BIN
resources/fonts/Inter-Medium.ttf
Normal file
Binary file not shown.
BIN
resources/fonts/Inter-Regular.ttf
Normal file
BIN
resources/fonts/Inter-Regular.ttf
Normal file
Binary file not shown.
BIN
resources/fonts/Inter-SemiBold.ttf
Normal file
BIN
resources/fonts/Inter-SemiBold.ttf
Normal file
Binary file not shown.
|
|
@ -22,8 +22,7 @@ import {
|
|||
import { trans } from 'laravel-vue-i18n';
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { store as storePost } from '@/actions/App/Http/Controllers/App/PostController';
|
||||
import { index as postsIndex } from '@/actions/App/Http/Controllers/App/PostController';
|
||||
import { create as createPost, index as postsIndex } from '@/actions/App/Http/Controllers/App/PostController';
|
||||
import { WorkspaceRole } from '@/enums/workspace-role';
|
||||
import NavMain from '@/components/NavMain.vue';
|
||||
import NavUser from '@/components/NavUser.vue';
|
||||
|
|
@ -229,7 +228,7 @@ const switchWorkspace = (workspaceId: string) => {
|
|||
<SidebarContent>
|
||||
<!-- Create Post Button -->
|
||||
<div v-if="currentWorkspace" class="px-2 py-2">
|
||||
<Link :href="storePost.url()" method="post" class="w-full">
|
||||
<Link :href="createPost.url()" class="block">
|
||||
<Button :size="sidebarState === 'collapsed' ? 'icon' : 'default'" class="w-full">
|
||||
<IconPlus v-if="sidebarState === 'collapsed'" class="size-4" />
|
||||
<span v-if="sidebarState === 'expanded'">{{ $t('sidebar.create_post') }}</span>
|
||||
|
|
|
|||
114
resources/js/components/FontPicker.vue
Normal file
114
resources/js/components/FontPicker.vue
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
<script setup lang="ts">
|
||||
import { IconCheck, IconChevronDown } from '@tabler/icons-vue';
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface Props {
|
||||
/** List of font family names. Each will be loaded via Google Fonts so the
|
||||
* preview text in the dropdown renders in the actual typeface. */
|
||||
fonts: string[];
|
||||
placeholder?: string;
|
||||
name?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
placeholder: 'Select a font…',
|
||||
disabled: false,
|
||||
});
|
||||
|
||||
const value = defineModel<string>({ required: true });
|
||||
|
||||
const open = ref(false);
|
||||
|
||||
// Build a single Google Fonts URL that loads ALL options at once. This way the
|
||||
// dropdown preview shows each family in its own typeface without firing a
|
||||
// request per item. We only request the regular weight to keep payload small.
|
||||
const googleFontsUrl = computed(() => {
|
||||
const families = props.fonts
|
||||
.map((f) => `family=${encodeURIComponent(f)}:wght@400`)
|
||||
.join('&');
|
||||
return `https://fonts.googleapis.com/css2?${families}&display=swap`;
|
||||
});
|
||||
|
||||
let injectedLink: HTMLLinkElement | null = null;
|
||||
|
||||
const ensureFontsLoaded = () => {
|
||||
if (injectedLink || typeof document === 'undefined') return;
|
||||
|
||||
injectedLink = document.createElement('link');
|
||||
injectedLink.rel = 'stylesheet';
|
||||
injectedLink.href = googleFontsUrl.value;
|
||||
injectedLink.dataset.fontPicker = 'true';
|
||||
document.head.appendChild(injectedLink);
|
||||
};
|
||||
|
||||
onMounted(ensureFontsLoaded);
|
||||
|
||||
// Reload the stylesheet if the list of fonts changes (rare).
|
||||
watch(googleFontsUrl, (next) => {
|
||||
if (injectedLink) {
|
||||
injectedLink.href = next;
|
||||
}
|
||||
});
|
||||
|
||||
const select = (font: string) => {
|
||||
value.value = font;
|
||||
open.value = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Popover v-model:open="open">
|
||||
<PopoverTrigger as-child>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
:aria-expanded="open"
|
||||
:disabled="disabled"
|
||||
class="w-full justify-between font-normal"
|
||||
>
|
||||
<span :style="{ fontFamily: `'${value}', sans-serif` }">
|
||||
{{ value || placeholder }}
|
||||
</span>
|
||||
<IconChevronDown class="ml-2 size-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent class="w-[--reka-popover-trigger-width] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search font…" />
|
||||
<CommandList>
|
||||
<CommandEmpty>No fonts match.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
v-for="font in fonts"
|
||||
:key="font"
|
||||
:value="font"
|
||||
@select="select(font)"
|
||||
>
|
||||
<span :style="{ fontFamily: `'${font}', sans-serif` }">{{ font }}</span>
|
||||
<IconCheck
|
||||
:class="cn('ml-auto size-4', value === font ? 'opacity-100' : 'opacity-0')"
|
||||
/>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<input v-if="name" type="hidden" :name="name" :value="value" />
|
||||
</template>
|
||||
|
|
@ -248,7 +248,7 @@ const huePointerStyle = computed(() => ({
|
|||
|
||||
<!-- Hex input -->
|
||||
<Input
|
||||
:value="text"
|
||||
:model-value="text"
|
||||
:placeholder="placeholder"
|
||||
class="font-mono"
|
||||
:class="!isValid ? 'border-destructive focus-visible:ring-destructive' : ''"
|
||||
|
|
@ -272,7 +272,7 @@ const huePointerStyle = computed(() => ({
|
|||
</Popover>
|
||||
|
||||
<Input
|
||||
:value="text"
|
||||
:model-value="text"
|
||||
:name="name"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
|
|
|
|||
|
|
@ -1,33 +1,125 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { IconChevronLeft, IconChevronRight } from '@tabler/icons-vue';
|
||||
import { computed, onUnmounted, watch } from 'vue';
|
||||
|
||||
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
|
||||
|
||||
const props = defineProps<{
|
||||
src: string | null;
|
||||
}>();
|
||||
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[];
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
src: null,
|
||||
images: () => [],
|
||||
});
|
||||
|
||||
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: () => props.src !== null,
|
||||
set: (val) => { if (!val) emit('close'); },
|
||||
get: () => allImages.value.length > 0 && (props.images.length === 0 || index.value !== null),
|
||||
set: (val) => {
|
||||
if (!val) emit('close');
|
||||
},
|
||||
});
|
||||
|
||||
const safeIndex = computed(() =>
|
||||
Math.max(0, Math.min(index.value ?? 0, allImages.value.length - 1)),
|
||||
);
|
||||
const currentImage = computed(() => allImages.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 goPrev = () => {
|
||||
if (hasPrev.value) index.value = safeIndex.value - 1;
|
||||
};
|
||||
const goNext = () => {
|
||||
if (hasNext.value) index.value = safeIndex.value + 1;
|
||||
};
|
||||
|
||||
const onKeydown = (e: KeyboardEvent) => {
|
||||
if (!isOpen.value) return;
|
||||
if (e.key === 'ArrowLeft') {
|
||||
e.preventDefault();
|
||||
goPrev();
|
||||
} else if (e.key === 'ArrowRight') {
|
||||
e.preventDefault();
|
||||
goNext();
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
isOpen,
|
||||
(open) => {
|
||||
if (open) {
|
||||
window.addEventListener('keydown', onKeydown);
|
||||
} else {
|
||||
window.removeEventListener('keydown', onKeydown);
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
onUnmounted(() => window.removeEventListener('keydown', onKeydown));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog v-model:open="isOpen">
|
||||
<DialogContent class="max-w-4xl gap-0 border-0 bg-transparent p-0 shadow-none sm:max-w-4xl" :show-close-button="false">
|
||||
<DialogContent
|
||||
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>
|
||||
<img
|
||||
v-if="src"
|
||||
:src="src"
|
||||
alt="Preview"
|
||||
class="max-h-[85vh] w-full cursor-pointer rounded-lg object-contain"
|
||||
@click="emit('close')"
|
||||
/>
|
||||
<div class="relative flex justify-center">
|
||||
<img
|
||||
v-if="currentImage"
|
||||
:src="currentImage"
|
||||
alt="Preview"
|
||||
class="max-h-[85vh] max-w-full cursor-pointer rounded-2xl object-contain"
|
||||
@click="emit('close')"
|
||||
/>
|
||||
|
||||
<button
|
||||
v-if="showNav && hasPrev"
|
||||
type="button"
|
||||
aria-label="Previous image"
|
||||
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"
|
||||
>
|
||||
<IconChevronLeft class="size-6" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="showNav && hasNext"
|
||||
type="button"
|
||||
aria-label="Next image"
|
||||
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"
|
||||
>
|
||||
<IconChevronRight class="size-6" />
|
||||
</button>
|
||||
|
||||
<div
|
||||
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 }}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ onMounted(async () => {
|
|||
try {
|
||||
const response = await useHttp().get(
|
||||
metricsRoute.url({ post: props.postId, postPlatform: props.postPlatformId }),
|
||||
);
|
||||
) as { data: unknown };
|
||||
const data = response.data;
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
|
|
|
|||
140
resources/js/components/posts/ai/AiGenerateDialog.vue
Normal file
140
resources/js/components/posts/ai/AiGenerateDialog.vue
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
<script setup lang="ts">
|
||||
import { IconLoader2, IconRefresh, IconSparkles, IconWriting } from '@tabler/icons-vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useAiStream } from '@/composables/useAiStream';
|
||||
import { generate as generatePostAi } from '@/routes/app/posts/ai';
|
||||
|
||||
const props = defineProps<{
|
||||
postId: string;
|
||||
currentContent: string;
|
||||
}>();
|
||||
|
||||
const open = defineModel<boolean>('open', { required: true });
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'apply', content: string): void;
|
||||
}>();
|
||||
|
||||
const prompt = ref('');
|
||||
const dispatching = ref(false);
|
||||
const { text, status, errorMessage, subscribe, unsubscribe, reset } = useAiStream();
|
||||
|
||||
const csrfToken = document.querySelector<HTMLMetaElement>('meta[name="csrf-token"]')?.content ?? '';
|
||||
|
||||
const startGeneration = async () => {
|
||||
if (! prompt.value.trim()) return;
|
||||
dispatching.value = true;
|
||||
try {
|
||||
const response = await fetch(generatePostAi.url(props.postId), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
'X-CSRF-TOKEN': csrfToken,
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
prompt: prompt.value,
|
||||
current_content: props.currentContent || null,
|
||||
}),
|
||||
});
|
||||
if (! response.ok) {
|
||||
status.value = 'failed';
|
||||
errorMessage.value = 'Could not start generation';
|
||||
return;
|
||||
}
|
||||
const data = await response.json();
|
||||
subscribe(data.channel);
|
||||
} finally {
|
||||
dispatching.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const apply = () => {
|
||||
emit('apply', text.value);
|
||||
open.value = false;
|
||||
};
|
||||
|
||||
const retry = () => {
|
||||
unsubscribe();
|
||||
reset();
|
||||
startGeneration();
|
||||
};
|
||||
|
||||
const canApply = computed(() => status.value === 'completed' && text.value.trim().length > 0);
|
||||
const canRetry = computed(() => status.value === 'completed' || status.value === 'failed');
|
||||
|
||||
watch(open, (isOpen) => {
|
||||
if (! isOpen) {
|
||||
unsubscribe();
|
||||
reset();
|
||||
prompt.value = '';
|
||||
} else {
|
||||
prompt.value = props.currentContent || '';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog v-model:open="open">
|
||||
<DialogContent class="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle class="flex items-center gap-2">
|
||||
<IconSparkles class="size-5 text-primary" />
|
||||
{{ $t('posts.ai.generate.title') }}
|
||||
</DialogTitle>
|
||||
<DialogDescription>{{ $t('posts.ai.generate.description') }}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium">{{ $t('posts.ai.generate.prompt_label') }}</label>
|
||||
<Textarea
|
||||
v-model="prompt"
|
||||
:placeholder="$t('posts.ai.generate.prompt_placeholder')"
|
||||
:disabled="status === 'streaming'"
|
||||
rows="3"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="status !== 'idle'" class="space-y-2">
|
||||
<label class="flex items-center gap-1.5 text-sm font-medium">
|
||||
<IconWriting class="size-4 text-muted-foreground" />
|
||||
{{ $t('posts.ai.generate.preview_label') }}
|
||||
<IconLoader2 v-if="status === 'streaming'" class="size-3.5 animate-spin text-muted-foreground" />
|
||||
</label>
|
||||
<div class="min-h-[120px] whitespace-pre-wrap rounded-md border bg-muted/30 px-3 py-2 text-sm">{{ text || '...' }}</div>
|
||||
<p v-if="status === 'failed'" class="text-xs text-destructive">{{ errorMessage }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter class="gap-2 sm:gap-2">
|
||||
<Button v-if="canRetry" variant="outline" @click="retry">
|
||||
<IconRefresh class="size-4" />
|
||||
{{ $t('posts.ai.generate.retry') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="status === 'idle'"
|
||||
:disabled="! prompt.trim() || dispatching"
|
||||
@click="startGeneration"
|
||||
>
|
||||
<IconSparkles class="size-4" />
|
||||
{{ $t('posts.ai.generate.start') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="canApply"
|
||||
@click="apply"
|
||||
>
|
||||
{{ $t('posts.ai.generate.apply') }}
|
||||
</Button>
|
||||
<Button variant="outline" @click="open = false">
|
||||
{{ $t('posts.ai.generate.cancel') }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
132
resources/js/components/posts/ai/AiReviewDialog.vue
Normal file
132
resources/js/components/posts/ai/AiReviewDialog.vue
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
<script setup lang="ts">
|
||||
import { IconCheck, IconLoader2, IconWriting } from '@tabler/icons-vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { review as reviewPostAi } from '@/routes/app/posts/ai';
|
||||
|
||||
interface Suggestion {
|
||||
original: string;
|
||||
suggestion: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
postId: string;
|
||||
content: string;
|
||||
}>();
|
||||
|
||||
const open = defineModel<boolean>('open', { required: true });
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'apply', original: string, suggestion: string): void;
|
||||
}>();
|
||||
|
||||
const status = ref<'idle' | 'loading' | 'completed' | 'failed'>('idle');
|
||||
const suggestions = ref<Suggestion[]>([]);
|
||||
const appliedSet = ref<Set<number>>(new Set());
|
||||
const errorMessage = ref<string | null>(null);
|
||||
|
||||
const csrfToken = document.querySelector<HTMLMetaElement>('meta[name="csrf-token"]')?.content ?? '';
|
||||
|
||||
const startReview = async () => {
|
||||
status.value = 'loading';
|
||||
suggestions.value = [];
|
||||
appliedSet.value = new Set();
|
||||
errorMessage.value = null;
|
||||
|
||||
try {
|
||||
const response = await fetch(reviewPostAi.url(props.postId), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
'X-CSRF-TOKEN': csrfToken,
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
body: JSON.stringify({ content: props.content }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
status.value = 'failed';
|
||||
errorMessage.value = 'Could not review';
|
||||
return;
|
||||
}
|
||||
const data = await response.json();
|
||||
suggestions.value = data.suggestions ?? [];
|
||||
status.value = 'completed';
|
||||
} catch {
|
||||
status.value = 'failed';
|
||||
errorMessage.value = 'Network error';
|
||||
}
|
||||
};
|
||||
|
||||
const applySuggestion = (index: number, s: Suggestion) => {
|
||||
if (appliedSet.value.has(index)) return;
|
||||
emit('apply', s.original, s.suggestion);
|
||||
const next = new Set(appliedSet.value);
|
||||
next.add(index);
|
||||
appliedSet.value = next;
|
||||
};
|
||||
|
||||
const noIssues = computed(() => status.value === 'completed' && suggestions.value.length === 0);
|
||||
|
||||
watch(open, (isOpen) => {
|
||||
if (isOpen) startReview();
|
||||
else {
|
||||
status.value = 'idle';
|
||||
suggestions.value = [];
|
||||
appliedSet.value = new Set();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog v-model:open="open">
|
||||
<DialogContent class="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle class="flex items-center gap-2">
|
||||
<IconWriting class="size-5 text-primary" />
|
||||
{{ $t('posts.ai.review.title') }}
|
||||
</DialogTitle>
|
||||
<DialogDescription>{{ $t('posts.ai.review.description') }}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div v-if="status === 'loading'" class="flex items-center gap-2 py-8 text-sm text-muted-foreground">
|
||||
<IconLoader2 class="size-4 animate-spin" />
|
||||
{{ $t('posts.ai.review.loading') }}
|
||||
</div>
|
||||
|
||||
<p v-else-if="status === 'failed'" class="py-4 text-sm text-destructive">{{ errorMessage }}</p>
|
||||
|
||||
<p v-else-if="noIssues" class="py-4 text-sm text-muted-foreground">{{ $t('posts.ai.review.no_issues') }}</p>
|
||||
|
||||
<ul v-else-if="suggestions.length > 0" class="max-h-[400px] space-y-3 overflow-y-auto">
|
||||
<li
|
||||
v-for="(s, idx) in suggestions"
|
||||
:key="idx"
|
||||
class="rounded-md border bg-card p-3"
|
||||
:class="appliedSet.has(idx) ? 'opacity-60' : ''"
|
||||
>
|
||||
<p class="text-xs uppercase tracking-wide text-muted-foreground">{{ $t('posts.ai.review.original') }}</p>
|
||||
<p class="mb-2 text-sm line-through opacity-75">{{ s.original }}</p>
|
||||
<p class="text-xs uppercase tracking-wide text-muted-foreground">{{ $t('posts.ai.review.suggestion') }}</p>
|
||||
<p class="mb-2 text-sm font-medium">{{ s.suggestion }}</p>
|
||||
<p class="mb-3 text-xs text-muted-foreground">{{ s.reason }}</p>
|
||||
<Button
|
||||
size="sm"
|
||||
:disabled="appliedSet.has(idx)"
|
||||
@click="applySuggestion(idx, s)"
|
||||
>
|
||||
<IconCheck class="size-4" />
|
||||
{{ appliedSet.has(idx) ? $t('posts.ai.review.applied') : $t('posts.ai.review.apply') }}
|
||||
</Button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="open = false">{{ $t('posts.ai.review.close') }}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
461
resources/js/components/posts/create/AiPostWizard.vue
Normal file
461
resources/js/components/posts/create/AiPostWizard.vue
Normal file
|
|
@ -0,0 +1,461 @@
|
|||
<script setup lang="ts">
|
||||
import { router } 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 { finalize as finalizeRoute, start as startRoute } from '@/actions/App/Http/Controllers/App/PostAiCreateController';
|
||||
import { Button } from '@/components/ui/button';
|
||||
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';
|
||||
|
||||
interface SocialAccount {
|
||||
id: string;
|
||||
platform: string;
|
||||
display_name: string;
|
||||
username: string;
|
||||
avatar_url: string | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
socialAccounts: SocialAccount[];
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
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. */
|
||||
cancel: [];
|
||||
}>();
|
||||
|
||||
const step = ref<WizardStep>('configure');
|
||||
|
||||
// Selections
|
||||
const selectedFormat = ref<ContentTypeValue | null>(null);
|
||||
const selectedAccountId = ref<string | null>(null);
|
||||
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 previewError = ref('');
|
||||
const previewCreationId = ref<string | null>(null);
|
||||
let echoChannel: any = null;
|
||||
let subscribedChannelName: string | null = null;
|
||||
|
||||
const AI_FORMATS: Array<{ value: ContentTypeValue; platforms: string[] }> = [
|
||||
{ value: ContentType.InstagramFeed, platforms: ['instagram', 'instagram-facebook'] },
|
||||
{ value: ContentType.InstagramCarousel, platforms: ['instagram', 'instagram-facebook'] },
|
||||
{ value: ContentType.InstagramStory, platforms: ['instagram', 'instagram-facebook'] },
|
||||
{ value: ContentType.LinkedInPost, platforms: ['linkedin'] },
|
||||
{ value: ContentType.LinkedInPagePost, platforms: ['linkedin-page'] },
|
||||
{ value: ContentType.XPost, platforms: ['x'] },
|
||||
{ value: ContentType.BlueskyPost, platforms: ['bluesky'] },
|
||||
{ value: ContentType.ThreadsPost, platforms: ['threads'] },
|
||||
{ value: ContentType.MastodonPost, platforms: ['mastodon'] },
|
||||
{ value: ContentType.FacebookPost, platforms: ['facebook'] },
|
||||
{ value: ContentType.FacebookStory, platforms: ['facebook'] },
|
||||
{ value: ContentType.PinterestPin, platforms: ['pinterest'] },
|
||||
];
|
||||
|
||||
const connectedPlatforms = computed(() => {
|
||||
const platforms = new Set<string>();
|
||||
for (const account of props.socialAccounts) {
|
||||
platforms.add(account.platform);
|
||||
}
|
||||
return Array.from(platforms);
|
||||
});
|
||||
|
||||
// Show ALL formats — disabled when the workspace has no connected account
|
||||
// for that platform. Filtering them out hides the catalog from the user.
|
||||
const availableFormats = computed(() => AI_FORMATS);
|
||||
|
||||
const isFormatConnected = (format: typeof AI_FORMATS[number]): boolean =>
|
||||
format.platforms.some((p) => connectedPlatforms.value.includes(p));
|
||||
|
||||
const accountsForFormat = computed(() => {
|
||||
if (!selectedFormat.value) return [];
|
||||
const format = AI_FORMATS.find((f) => f.value === selectedFormat.value);
|
||||
if (!format) return [];
|
||||
return props.socialAccounts.filter((a) => format.platforms.includes(a.platform));
|
||||
});
|
||||
|
||||
const isCarousel = computed(() => selectedFormat.value === ContentType.InstagramCarousel);
|
||||
const requiresImage = computed(() =>
|
||||
selectedFormat.value === ContentType.FacebookPost ||
|
||||
selectedFormat.value === ContentType.PinterestPin ||
|
||||
selectedFormat.value === ContentType.InstagramStory ||
|
||||
selectedFormat.value === ContentType.FacebookStory,
|
||||
);
|
||||
const supportsOptionalImages = computed(() =>
|
||||
selectedFormat.value === ContentType.InstagramFeed ||
|
||||
selectedFormat.value === ContentType.LinkedInPost ||
|
||||
selectedFormat.value === ContentType.LinkedInPagePost ||
|
||||
selectedFormat.value === ContentType.XPost ||
|
||||
selectedFormat.value === ContentType.BlueskyPost ||
|
||||
selectedFormat.value === ContentType.ThreadsPost ||
|
||||
selectedFormat.value === ContentType.MastodonPost,
|
||||
);
|
||||
// Instagram Feed accepts only 1 image (single-image post). Others accept up to 4.
|
||||
const maxOptionalImages = computed(() =>
|
||||
selectedFormat.value === ContentType.InstagramFeed ? 1 : 4,
|
||||
);
|
||||
const showsAccountPicker = computed(() => accountsForFormat.value.length > 1);
|
||||
|
||||
const submittedImageCount = computed(() => {
|
||||
if (isCarousel.value) return imageCount.value;
|
||||
if (requiresImage.value) return 1;
|
||||
if (supportsOptionalImages.value && includeImages.value) return imageCount.value;
|
||||
return 0;
|
||||
});
|
||||
|
||||
const canSubmit = computed(() =>
|
||||
selectedFormat.value !== null &&
|
||||
selectedAccountId.value !== null &&
|
||||
promptText.value.trim().length >= 3,
|
||||
);
|
||||
|
||||
// Auto-pick the only account when format has exactly one match.
|
||||
watch(accountsForFormat, (accounts) => {
|
||||
if (accounts.length === 1) {
|
||||
selectedAccountId.value = accounts[0].id;
|
||||
} else if (accounts.length === 0) {
|
||||
selectedAccountId.value = null;
|
||||
} else if (accounts.length > 1 && !accounts.some((a) => a.id === selectedAccountId.value)) {
|
||||
selectedAccountId.value = null;
|
||||
}
|
||||
});
|
||||
|
||||
const selectFormat = (format: ContentTypeValue) => {
|
||||
selectedFormat.value = format;
|
||||
// Sensible default per format. Picking a format always pre-selects an
|
||||
// image option so the user sees a chip highlighted on arrival.
|
||||
if (format === ContentType.InstagramCarousel) {
|
||||
imageCount.value = 5;
|
||||
} else if (format === ContentType.InstagramFeed) {
|
||||
imageCount.value = 1;
|
||||
includeImages.value = true;
|
||||
} else {
|
||||
imageCount.value = 2;
|
||||
includeImages.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
// 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: '',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
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 ?? '';
|
||||
previewStatus.value = 'done';
|
||||
}
|
||||
unsubscribeEcho();
|
||||
});
|
||||
};
|
||||
|
||||
const startGeneration = async () => {
|
||||
if (!canSubmit.value || submitting.value) return;
|
||||
|
||||
submitting.value = true;
|
||||
previewStatus.value = 'loading';
|
||||
previewContent.value = '';
|
||||
previewError.value = '';
|
||||
goToStep('preview');
|
||||
|
||||
try {
|
||||
const csrfToken = document.querySelector<HTMLMetaElement>('meta[name="csrf-token"]')?.content ?? '';
|
||||
const response = await fetch(startRoute.url(), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': csrfToken,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
format: selectedFormat.value,
|
||||
social_account_id: selectedAccountId.value,
|
||||
image_count: submittedImageCount.value,
|
||||
prompt: promptText.value.trim(),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.json().catch(() => ({}));
|
||||
throw new Error(err?.message ?? `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const creationId: string = data.creation_id;
|
||||
const channel: string = data.channel;
|
||||
const parts = channel.split('.');
|
||||
const userId = parts[1] ?? '';
|
||||
|
||||
subscribeToCreation(userId, creationId);
|
||||
} catch (err: any) {
|
||||
previewStatus.value = 'error';
|
||||
previewError.value = err?.message ?? trans('posts.create.steps.preview_error');
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const retryGeneration = () => startGeneration();
|
||||
|
||||
const createPost = async () => {
|
||||
if (!previewCreationId.value || finalizing.value) return;
|
||||
finalizing.value = true;
|
||||
|
||||
try {
|
||||
const csrfToken = document.querySelector<HTMLMetaElement>('meta[name="csrf-token"]')?.content ?? '';
|
||||
const response = await fetch(finalizeRoute.url(previewCreationId.value), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': csrfToken,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
const data = await response.json();
|
||||
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>
|
||||
<div class="space-y-6">
|
||||
<!-- Back button — consistent across both steps -->
|
||||
<div class="flex items-center">
|
||||
<Button variant="ghost" size="sm" class="-ml-2 text-muted-foreground" @click="goBack">
|
||||
<IconArrowLeft class="mr-1 size-4" />
|
||||
{{ $t('posts.create.steps.back') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- ====== Step 1: Configure (everything in one screen) ====== -->
|
||||
<template v-if="step === 'configure'">
|
||||
<!-- Format -->
|
||||
<div class="space-y-2">
|
||||
<Label class="text-sm font-medium">{{ $t('posts.create.steps.format_title') }}</Label>
|
||||
<div class="grid gap-2 sm:grid-cols-2">
|
||||
<button
|
||||
v-for="format in availableFormats"
|
||||
:key="format.value"
|
||||
type="button"
|
||||
class="flex items-center gap-3 rounded-xl border bg-card p-3.5 text-left text-sm transition-all hover:border-primary/50 hover:bg-primary/5 disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:border-border disabled:hover:bg-card"
|
||||
:class="{ 'border-primary bg-primary/5 ring-1 ring-primary/30': selectedFormat === format.value }"
|
||||
:disabled="!isFormatConnected(format)"
|
||||
:title="!isFormatConnected(format) ? $t('posts.create.steps.connect_first') : ''"
|
||||
@click="selectFormat(format.value)"
|
||||
>
|
||||
<img
|
||||
:src="getPlatformLogo(format.platforms[0])"
|
||||
:alt="format.platforms[0]"
|
||||
class="size-6 rounded-full ring-1 ring-background"
|
||||
/>
|
||||
<span class="flex-1 font-medium">{{ $t(`posts.create.steps.format.${format.value}`) }}</span>
|
||||
<IconCheck v-if="selectedFormat === format.value" class="size-4 text-primary" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Account (only when there's a choice to make) -->
|
||||
<div v-if="selectedFormat && showsAccountPicker" class="space-y-2">
|
||||
<Label class="text-sm font-medium">{{ $t('posts.create.steps.account_title') }}</Label>
|
||||
<div class="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||
<button
|
||||
v-for="account in accountsForFormat"
|
||||
:key="account.id"
|
||||
type="button"
|
||||
class="relative flex items-center gap-2 rounded-xl border bg-card p-2.5 text-left text-sm transition-all hover:border-primary/50 hover:bg-primary/5"
|
||||
:class="{ 'border-primary bg-primary/5 ring-1 ring-primary/30': selectedAccountId === account.id }"
|
||||
@click="selectedAccountId = account.id"
|
||||
>
|
||||
<img
|
||||
v-if="account.avatar_url"
|
||||
:src="account.avatar_url"
|
||||
:alt="account.display_name"
|
||||
class="size-8 shrink-0 rounded-full"
|
||||
/>
|
||||
<div v-else class="flex size-8 shrink-0 items-center justify-center rounded-full bg-muted">
|
||||
<img :src="getPlatformLogo(account.platform)" :alt="account.platform" class="size-4" />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-xs font-medium leading-tight">{{ account.display_name }}</p>
|
||||
<p v-if="account.username" class="truncate text-xs text-muted-foreground">@{{ account.username }}</p>
|
||||
</div>
|
||||
<IconCheck v-if="selectedAccountId === account.id" class="absolute right-2 top-2 size-3.5 text-primary" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Media — inline, only when format actually has options -->
|
||||
<div v-if="selectedFormat && isCarousel" class="space-y-2">
|
||||
<Label class="text-sm font-medium">{{ $t('posts.create.steps.media_carousel') }}</Label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button
|
||||
v-for="n in [2, 3, 4, 5, 6, 7, 8, 9, 10]"
|
||||
:key="n"
|
||||
type="button"
|
||||
size="icon"
|
||||
:variant="imageCount === n ? 'default' : 'outline'"
|
||||
@click="imageCount = n"
|
||||
>
|
||||
{{ n }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedFormat && supportsOptionalImages" class="space-y-2">
|
||||
<Label class="text-sm font-medium">{{ $t('posts.create.steps.media_optional_label') }}</Label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
:variant="!includeImages ? 'default' : 'outline'"
|
||||
@click="includeImages = false"
|
||||
>
|
||||
{{ $t('posts.create.steps.media_none') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-for="n in maxOptionalImages"
|
||||
:key="n"
|
||||
type="button"
|
||||
size="icon"
|
||||
:variant="includeImages && imageCount === n ? 'default' : 'outline'"
|
||||
@click="includeImages = true; imageCount = n"
|
||||
>
|
||||
{{ n }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Prompt -->
|
||||
<div v-if="selectedFormat" class="space-y-2">
|
||||
<Label for="ai-prompt" class="text-sm font-medium">{{ $t('posts.create.steps.prompt_label') }}</Label>
|
||||
<Textarea
|
||||
id="ai-prompt"
|
||||
v-model="promptText"
|
||||
:placeholder="$t('posts.create.steps.prompt_placeholder')"
|
||||
class="min-h-[140px] resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Generate -->
|
||||
<div v-if="selectedFormat" class="flex justify-end pt-1">
|
||||
<Button :disabled="!canSubmit" @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-xl border bg-muted/20 py-16 text-center">
|
||||
<IconLoader2 class="size-10 animate-spin text-primary" />
|
||||
<p class="text-sm text-muted-foreground">{{ $t('posts.create.steps.preview_loading') }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="previewStatus === 'error'" class="space-y-4">
|
||||
<div class="rounded-xl border border-destructive/30 bg-destructive/5 p-4">
|
||||
<p class="text-sm text-destructive">{{ previewError || $t('posts.create.steps.preview_error') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<Button variant="outline" @click="retryGeneration">
|
||||
<IconRefresh class="mr-1 size-4" />
|
||||
{{ $t('posts.create.steps.retry') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="previewStatus === 'done'" class="space-y-4">
|
||||
<div class="rounded-xl border bg-muted/20 p-5">
|
||||
<p class="whitespace-pre-wrap text-sm leading-relaxed">{{ previewContent }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button variant="outline" size="sm" @click="retryGeneration">
|
||||
<IconRefresh class="mr-1 size-4" />
|
||||
{{ $t('posts.create.steps.retry') }}
|
||||
</Button>
|
||||
<Button :disabled="finalizing" @click="createPost">
|
||||
<IconLoader2 v-if="finalizing" class="mr-1 size-4 animate-spin" />
|
||||
{{ $t('posts.create.steps.create') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -3,7 +3,7 @@ import { IconAlertTriangle, IconBrandFacebook, IconChevronDown, IconChevronUp }
|
|||
import { computed, ref } from 'vue';
|
||||
|
||||
import { Avatar } from '@/components/ui/avatar';
|
||||
import { getMediaValidationWarning } from '@/composables/useMedia';
|
||||
import { getMediaValidationWarning, type MediaItem } from '@/composables/useMedia';
|
||||
|
||||
interface SocialAccount {
|
||||
id: string;
|
||||
|
|
@ -13,12 +13,6 @@ interface SocialAccount {
|
|||
avatar_url: string | null;
|
||||
}
|
||||
|
||||
interface MediaItem {
|
||||
id: string;
|
||||
type?: string;
|
||||
mime_type?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
socialAccount: SocialAccount | null;
|
||||
contentType: string;
|
||||
|
|
|
|||
|
|
@ -10,10 +10,12 @@ import {
|
|||
IconSparkles,
|
||||
IconTrash,
|
||||
IconVideo,
|
||||
IconWriting,
|
||||
} from '@tabler/icons-vue';
|
||||
import { trans } from 'laravel-vue-i18n';
|
||||
import { computed, nextTick, ref } from 'vue';
|
||||
|
||||
import ImagePreviewDialog from '@/components/ImagePreviewDialog.vue';
|
||||
import EmojiPicker from '@/components/posts/EmojiPicker.vue';
|
||||
import HashtagsModal from '@/components/posts/HashtagsModal.vue';
|
||||
import MediaPickerDialog from '@/components/posts/MediaPickerDialog.vue';
|
||||
|
|
@ -61,7 +63,8 @@ const content = defineModel<string>('content', { required: true });
|
|||
const media = defineModel<MediaItem[]>('media', { required: true });
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'focus-assistant'): void;
|
||||
(e: 'open-ai-generate'): void;
|
||||
(e: 'open-ai-review'): void;
|
||||
}>();
|
||||
|
||||
const isDragging = ref(false);
|
||||
|
|
@ -73,6 +76,19 @@ const hashtagsModal = ref<InstanceType<typeof HashtagsModal> | 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 openPreview = (item: MediaItem) => {
|
||||
if (isVideo(item)) return;
|
||||
const idx = previewImages.value.indexOf(item.url);
|
||||
previewIndex.value = idx >= 0 ? idx : 0;
|
||||
};
|
||||
|
||||
const csrfToken = document.querySelector<HTMLMetaElement>('meta[name="csrf-token"]')?.content ?? '';
|
||||
|
||||
|
|
@ -273,7 +289,7 @@ const issueLabel = (reason: string): string => trans(`posts.form.warnings.${reas
|
|||
v-for="(item, index) in media"
|
||||
:key="item.id"
|
||||
:ref="(el) => { if (el) mediaThumbRefs[index] = el as HTMLElement; }"
|
||||
class="group relative aspect-square overflow-hidden rounded-xl bg-muted transition-all focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
|
||||
class="group relative aspect-square cursor-zoom-in overflow-hidden rounded-xl bg-muted transition-all focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
|
||||
:class="[
|
||||
dragMediaIndex === index ? 'opacity-40' : '',
|
||||
dragOverIndex === index && dragMediaIndex !== index ? 'ring-2 ring-primary ring-offset-2' : '',
|
||||
|
|
@ -281,6 +297,7 @@ const issueLabel = (reason: string): string => trans(`posts.form.warnings.${reas
|
|||
]"
|
||||
tabindex="0"
|
||||
:draggable="media.length > 1"
|
||||
@click="openPreview(item)"
|
||||
@dragstart="onMediaDragStart($event, index)"
|
||||
@dragover="onMediaDragOver($event, index)"
|
||||
@drop="onMediaDrop($event, index)"
|
||||
|
|
@ -349,7 +366,7 @@ const issueLabel = (reason: string): string => trans(`posts.form.warnings.${reas
|
|||
<button
|
||||
type="button"
|
||||
class="absolute right-1.5 top-1.5 flex h-6 w-6 items-center justify-center rounded-md bg-black/55 text-white opacity-0 backdrop-blur-sm transition-all hover:bg-destructive group-hover:opacity-100 group-focus:opacity-100"
|
||||
@click="removeMedia(item.id)"
|
||||
@click.stop="removeMedia(item.id)"
|
||||
>
|
||||
<IconTrash class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
|
|
@ -417,12 +434,29 @@ const issueLabel = (reason: string): string => trans(`posts.form.warnings.${reas
|
|||
variant="ghost"
|
||||
size="icon-sm"
|
||||
class="size-8 rounded-lg text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
@click="emit('focus-assistant')"
|
||||
@click="emit('open-ai-generate')"
|
||||
>
|
||||
<IconSparkles class="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ $t('posts.edit.tabs.writing_assistant') }}</TooltipContent>
|
||||
<TooltipContent>{{ $t('posts.ai.generate.button_tooltip') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
class="size-8 rounded-lg text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
@click="emit('open-ai-review')"
|
||||
>
|
||||
<IconWriting class="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ $t('posts.ai.review.button_tooltip') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
|
|
@ -483,5 +517,11 @@ const issueLabel = (reason: string): string => trans(`posts.form.warnings.${reas
|
|||
|
||||
<HashtagsModal ref="hashtagsModal" :hashtags="hashtags" @select="appendHashtags" />
|
||||
<MediaPickerDialog ref="mediaPickerDialog" @select="addMediaFromGallery" />
|
||||
<ImagePreviewDialog
|
||||
:images="previewImages"
|
||||
:index="previewIndex"
|
||||
@update:index="previewIndex = $event"
|
||||
@close="previewIndex = null"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import { computed, ref } from 'vue';
|
|||
import CommentsTab from '@/components/posts/editor/CommentsTab.vue';
|
||||
import PreviewTab from '@/components/posts/editor/PreviewTab.vue';
|
||||
import ScheduleTab from '@/components/posts/editor/ScheduleTab.vue';
|
||||
import WritingAssistantTab from '@/components/posts/editor/WritingAssistantTab.vue';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
|
||||
interface MediaItem {
|
||||
|
|
@ -84,11 +83,6 @@ const emit = defineEmits<{
|
|||
(e: 'toggle-label', labelId: string): void;
|
||||
(e: 'update:platformMeta', platformId: string, meta: Record<string, any>): void;
|
||||
(e: 'update:platformContentType', platformId: string, contentType: string): void;
|
||||
(e: 'add-media-from-assistant', payload: {
|
||||
messageId: string;
|
||||
messageContent: string;
|
||||
media: { id: string; path: string; url: string; type: string; mime_type: string };
|
||||
}): void;
|
||||
}>();
|
||||
|
||||
const commentsTabRef = ref<InstanceType<typeof CommentsTab> | null>(null);
|
||||
|
|
@ -109,7 +103,6 @@ defineExpose({
|
|||
<TabsTrigger value="preview">{{ $t('posts.edit.tabs.preview') }}</TabsTrigger>
|
||||
<TabsTrigger value="schedule">{{ $t('posts.edit.tabs.schedule') }}</TabsTrigger>
|
||||
<TabsTrigger value="comments">{{ $t('posts.edit.tabs.comments') }}</TabsTrigger>
|
||||
<TabsTrigger value="assistant">{{ $t('posts.edit.tabs.writing_assistant') }}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="preview" class="flex-1 overflow-y-auto">
|
||||
|
|
@ -151,12 +144,5 @@ defineExpose({
|
|||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="assistant" class="flex-1 overflow-hidden">
|
||||
<WritingAssistantTab
|
||||
:post-id="post.id"
|
||||
:workspace-id="workspaceId"
|
||||
@add-media="(payload) => emit('add-media-from-assistant', payload)"
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -6,17 +6,9 @@ import PhoneMockup from '@/components/PhoneMockup.vue';
|
|||
import { PlatformPreview } from '@/components/posts/previews';
|
||||
import { Avatar } from '@/components/ui/avatar';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import type { MediaItem } from '@/composables/useMedia';
|
||||
import { getPlatformLabel, getPlatformLogo } from '@/composables/usePlatformLogo';
|
||||
|
||||
interface MediaItem {
|
||||
id: string;
|
||||
path: string;
|
||||
url: string;
|
||||
type?: string;
|
||||
mime_type?: string;
|
||||
original_filename?: string;
|
||||
}
|
||||
|
||||
interface SocialAccount {
|
||||
id: string;
|
||||
platform: string;
|
||||
|
|
@ -57,9 +49,9 @@ watch(
|
|||
);
|
||||
|
||||
const activePlatform = computed(() => props.platforms.find((pp) => pp.id === activeId.value) ?? null);
|
||||
const activeContentType = computed(() => {
|
||||
if (!activePlatform.value) return null;
|
||||
return props.platformContentTypes[activePlatform.value.id] ?? activePlatform.value.content_type;
|
||||
const activeContentType = computed((): string | undefined => {
|
||||
if (!activePlatform.value) return undefined;
|
||||
return props.platformContentTypes[activePlatform.value.id] ?? activePlatform.value.content_type ?? undefined;
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ interface CreatorInfo {
|
|||
|
||||
interface Props {
|
||||
socialAccount: SocialAccount | null;
|
||||
publishConfig: PublishConfig | null;
|
||||
publishConfig: Record<string, any> | null;
|
||||
creatorInfo?: CreatorInfo | null;
|
||||
creatorInfoLoading?: boolean;
|
||||
videoDurationSec?: number | null;
|
||||
|
|
@ -126,7 +126,7 @@ const allPrivacyOptions = computed(() => {
|
|||
// Branded content cannot be private (TikTok compliance).
|
||||
const privacyOptions = computed(() =>
|
||||
brandContentToggle.value
|
||||
? allPrivacyOptions.value.filter((o) => o !== 'SELF_ONLY')
|
||||
? allPrivacyOptions.value.filter((o: string) => o !== 'SELF_ONLY')
|
||||
: allPrivacyOptions.value,
|
||||
);
|
||||
|
||||
|
|
@ -234,7 +234,7 @@ watch(
|
|||
<!-- Max duration warning -->
|
||||
<p v-if="exceedsMaxDuration" class="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/5 p-2 text-xs text-destructive">
|
||||
<IconAlertTriangle class="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
{{ $t('posts.form.tiktok.max_duration_exceeded', { duration: videoDurationSec ?? 0, max: maxDurationSec ?? 0 }) }}
|
||||
{{ $t('posts.form.tiktok.max_duration_exceeded', { duration: String(videoDurationSec ?? 0), max: String(maxDurationSec ?? 0) }) }}
|
||||
</p>
|
||||
|
||||
<!-- Auto Add Music (photos only) -->
|
||||
|
|
|
|||
|
|
@ -1,555 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { useEcho } from '@laravel/echo-vue';
|
||||
import {
|
||||
IconBrandBluesky,
|
||||
IconBrandFacebook,
|
||||
IconBrandInstagram,
|
||||
IconBrandLinkedin,
|
||||
IconBrandMastodon,
|
||||
IconBrandPinterest,
|
||||
IconBrandThreads,
|
||||
IconBrandTiktok,
|
||||
IconBrandX,
|
||||
IconBrandYoutube,
|
||||
IconCheck,
|
||||
IconLoader2,
|
||||
IconPaperclip,
|
||||
IconPlus,
|
||||
IconRefresh,
|
||||
IconSend,
|
||||
IconSparkles,
|
||||
IconX,
|
||||
} from '@tabler/icons-vue';
|
||||
import { type Component, nextTick, onMounted, ref } from 'vue';
|
||||
|
||||
import ImagePreviewDialog from '@/components/ImagePreviewDialog.vue';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import date from '@/date';
|
||||
import { index as fetchMessages, store as storeMessage } from '@/routes/app/posts/assistant';
|
||||
|
||||
interface Attachment {
|
||||
id: string;
|
||||
path: string;
|
||||
url: string;
|
||||
type: string;
|
||||
mime_type: string;
|
||||
}
|
||||
|
||||
type AiMessageStatus = 'pending' | 'generating' | 'completed' | 'failed';
|
||||
|
||||
interface AiMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
content_html?: string | null;
|
||||
attachments?: Attachment[];
|
||||
status?: AiMessageStatus;
|
||||
error_message?: string | null;
|
||||
metadata?: {
|
||||
intent?: string;
|
||||
error?: boolean;
|
||||
limit_reached?: boolean;
|
||||
quick_actions?: { label: string; value: string }[];
|
||||
};
|
||||
created_at: string;
|
||||
user?: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
postId: string;
|
||||
workspaceId: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'add-media': [payload: {
|
||||
messageId: string;
|
||||
messageContent: string;
|
||||
media: { id: string; path: string; url: string; type: string; mime_type: string };
|
||||
}];
|
||||
}>();
|
||||
|
||||
const platformIconMap: Record<string, Component> = {
|
||||
instagram: IconBrandInstagram,
|
||||
'instagram-facebook': IconBrandInstagram,
|
||||
linkedin: IconBrandLinkedin,
|
||||
'linkedin-page': IconBrandLinkedin,
|
||||
x: IconBrandX,
|
||||
facebook: IconBrandFacebook,
|
||||
tiktok: IconBrandTiktok,
|
||||
youtube: IconBrandYoutube,
|
||||
threads: IconBrandThreads,
|
||||
pinterest: IconBrandPinterest,
|
||||
bluesky: IconBrandBluesky,
|
||||
mastodon: IconBrandMastodon,
|
||||
};
|
||||
|
||||
const getQuickActionIcon = (value: string): Component | null => platformIconMap[value] ?? null;
|
||||
|
||||
const csrfToken = document.querySelector<HTMLMetaElement>('meta[name="csrf-token"]')?.content ?? '';
|
||||
|
||||
const messages = ref<AiMessage[]>([]);
|
||||
const loading = ref(false);
|
||||
const sending = ref(false);
|
||||
const body = ref('');
|
||||
const addedAttachmentIds = ref<Set<string>>(new Set());
|
||||
const clickedMessageIds = ref<Set<string>>(new Set());
|
||||
const previewImage = ref<string | null>(null);
|
||||
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
const selectedImage = ref<File | null>(null);
|
||||
const imagePreview = ref<string | null>(null);
|
||||
|
||||
const scrollContainer = ref<HTMLDivElement | null>(null);
|
||||
|
||||
const scrollToBottom = () => {
|
||||
if (scrollContainer.value) {
|
||||
scrollContainer.value.scrollTop = scrollContainer.value.scrollHeight;
|
||||
}
|
||||
};
|
||||
|
||||
const triggerFileInput = () => fileInput.value?.click();
|
||||
|
||||
const handleFileSelect = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement;
|
||||
const file = target.files?.[0];
|
||||
if (file) {
|
||||
selectedImage.value = file;
|
||||
imagePreview.value = URL.createObjectURL(file);
|
||||
}
|
||||
target.value = '';
|
||||
};
|
||||
|
||||
const clearImage = () => {
|
||||
selectedImage.value = null;
|
||||
if (imagePreview.value) {
|
||||
URL.revokeObjectURL(imagePreview.value);
|
||||
imagePreview.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const loadMessages = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const response = await fetch(fetchMessages.url(props.postId), {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
});
|
||||
if (!response.ok) return;
|
||||
const data = await response.json();
|
||||
messages.value = data.messages ?? [];
|
||||
await nextTick();
|
||||
scrollToBottom();
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const submitPrompt = async (text: string, imageFile: File | null) => {
|
||||
let fetchOptions: RequestInit;
|
||||
|
||||
if (imageFile) {
|
||||
const formData = new FormData();
|
||||
formData.append('body', text);
|
||||
formData.append('image', imageFile);
|
||||
fetchOptions = {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'X-CSRF-TOKEN': csrfToken,
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
body: formData,
|
||||
};
|
||||
} else {
|
||||
fetchOptions = {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': csrfToken,
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
body: JSON.stringify({ body: text }),
|
||||
};
|
||||
}
|
||||
|
||||
return fetch(storeMessage.url(props.postId), fetchOptions);
|
||||
};
|
||||
|
||||
const sendMessage = async () => {
|
||||
const text = body.value.trim();
|
||||
if (!text || sending.value) return;
|
||||
|
||||
body.value = '';
|
||||
sending.value = true;
|
||||
|
||||
// Optimistic: show user message immediately
|
||||
const tempUserMessage: AiMessage = {
|
||||
id: `temp-${Date.now()}`,
|
||||
role: 'user',
|
||||
content: text,
|
||||
attachments: imagePreview.value
|
||||
? [{ id: 'temp', path: '', url: imagePreview.value, type: 'image', mime_type: 'image/jpeg' }]
|
||||
: undefined,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
messages.value.push(tempUserMessage);
|
||||
|
||||
const imageFile = selectedImage.value;
|
||||
clearImage();
|
||||
|
||||
await nextTick();
|
||||
scrollToBottom();
|
||||
|
||||
try {
|
||||
const response = await submitPrompt(text, imageFile);
|
||||
|
||||
if (!response.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Replace temp user message with real one
|
||||
const tempIdx = messages.value.findIndex((m) => m.id === tempUserMessage.id);
|
||||
if (tempIdx !== -1) {
|
||||
messages.value[tempIdx] = data.user_message;
|
||||
}
|
||||
|
||||
// Add assistant placeholder (status: pending) — will be updated via Echo broadcast
|
||||
messages.value.push(data.assistant_message);
|
||||
|
||||
await nextTick();
|
||||
scrollToBottom();
|
||||
} finally {
|
||||
sending.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const retryMessage = async (failedMessage: AiMessage) => {
|
||||
if (sending.value) return;
|
||||
|
||||
const failedIdx = messages.value.findIndex((m) => m.id === failedMessage.id);
|
||||
const previousUserIdx = failedIdx > 0 ? failedIdx - 1 : -1;
|
||||
const previousUser = previousUserIdx !== -1 ? messages.value[previousUserIdx] : null;
|
||||
|
||||
if (! previousUser || previousUser.role !== 'user') return;
|
||||
|
||||
sending.value = true;
|
||||
|
||||
// Remove the failed assistant message
|
||||
messages.value.splice(failedIdx, 1);
|
||||
|
||||
try {
|
||||
const response = await submitPrompt(previousUser.content, null);
|
||||
|
||||
if (!response.ok) return;
|
||||
|
||||
const data = await response.json();
|
||||
messages.value.push(data.assistant_message);
|
||||
|
||||
await nextTick();
|
||||
scrollToBottom();
|
||||
} finally {
|
||||
sending.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Echo: listen for assistant message updates (broadcast when job completes/fails)
|
||||
useEcho(`post.${props.postId}`, '.AssistantMessageUpdated', async (e: { message: AiMessage }) => {
|
||||
const idx = messages.value.findIndex((m) => m.id === e.message.id);
|
||||
if (idx === -1) return;
|
||||
|
||||
messages.value[idx] = e.message;
|
||||
await nextTick();
|
||||
scrollToBottom();
|
||||
});
|
||||
|
||||
const isPendingAssistant = (message: AiMessage): boolean => {
|
||||
return message.role === 'assistant' && (message.status === 'pending' || message.status === 'generating');
|
||||
};
|
||||
|
||||
const isFailedAssistant = (message: AiMessage): boolean => {
|
||||
return message.role === 'assistant' && message.status === 'failed';
|
||||
};
|
||||
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
sendMessage();
|
||||
}
|
||||
};
|
||||
|
||||
const addToPost = (message: AiMessage, attachment: Attachment) => {
|
||||
emit('add-media', {
|
||||
messageId: message.id,
|
||||
messageContent: message.content ?? '',
|
||||
media: {
|
||||
id: attachment.id,
|
||||
path: attachment.path,
|
||||
url: attachment.url,
|
||||
type: attachment.type,
|
||||
mime_type: attachment.mime_type,
|
||||
},
|
||||
});
|
||||
addedAttachmentIds.value.add(attachment.id);
|
||||
};
|
||||
|
||||
const isAdded = (attachmentId: string): boolean => {
|
||||
return addedAttachmentIds.value.has(attachmentId);
|
||||
};
|
||||
|
||||
const isMedia = (attachment: Attachment): boolean => {
|
||||
return attachment.mime_type?.startsWith('audio/') || attachment.type === 'audio' || attachment.type === 'video' || attachment.type === 'image';
|
||||
};
|
||||
|
||||
const isAudio = (attachment: Attachment): boolean => {
|
||||
return attachment.mime_type?.startsWith('audio/') || attachment.type === 'audio';
|
||||
};
|
||||
|
||||
const isVideo = (attachment: Attachment): boolean => {
|
||||
return (attachment.mime_type?.startsWith('video/') || attachment.type === 'video') && !isAudio(attachment);
|
||||
};
|
||||
|
||||
const isImage = (attachment: Attachment): boolean => {
|
||||
return attachment.mime_type?.startsWith('image/') || attachment.type === 'image';
|
||||
};
|
||||
|
||||
const clickQuickAction = (message: AiMessage, action: { label: string; value: string }) => {
|
||||
if (clickedMessageIds.value.has(message.id) || sending.value) return;
|
||||
clickedMessageIds.value.add(message.id);
|
||||
body.value = action.label;
|
||||
sendMessage();
|
||||
};
|
||||
|
||||
|
||||
onMounted(() => {
|
||||
loadMessages();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full flex-col">
|
||||
<div ref="scrollContainer" class="flex-1 overflow-y-auto">
|
||||
<!-- Loading skeleton -->
|
||||
<div v-if="loading && messages.length === 0" class="space-y-4 px-3 py-4">
|
||||
<div class="flex justify-end gap-2">
|
||||
<div class="max-w-[70%] space-y-1.5">
|
||||
<Skeleton class="ml-auto h-10 w-48 rounded-lg" />
|
||||
<Skeleton class="ml-auto h-3 w-16" />
|
||||
</div>
|
||||
<Skeleton class="h-6 w-6 shrink-0 rounded-full" />
|
||||
</div>
|
||||
<div class="flex justify-start gap-2">
|
||||
<Skeleton class="h-6 w-6 shrink-0 rounded-full" />
|
||||
<div class="max-w-[70%] space-y-1.5">
|
||||
<Skeleton class="h-16 w-56 rounded-lg" />
|
||||
<Skeleton class="h-3 w-16" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2">
|
||||
<div class="max-w-[70%] space-y-1.5">
|
||||
<Skeleton class="ml-auto h-8 w-36 rounded-lg" />
|
||||
<Skeleton class="ml-auto h-3 w-16" />
|
||||
</div>
|
||||
<Skeleton class="h-6 w-6 shrink-0 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div v-else-if="messages.length === 0" class="flex flex-col items-center justify-center py-16 text-center px-6">
|
||||
<div class="mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-muted">
|
||||
<IconSparkles class="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground">{{ $t('assistant.empty') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Messages -->
|
||||
<div v-else class="space-y-3 px-3 py-3">
|
||||
<template v-for="message in messages" :key="message.id">
|
||||
<!-- User message -->
|
||||
<div v-if="message.role === 'user'" class="flex justify-end gap-2">
|
||||
<div class="max-w-[80%]">
|
||||
<div class="rounded-2xl rounded-br-sm bg-primary px-3 py-2 text-primary-foreground">
|
||||
<p class="whitespace-pre-wrap text-sm">{{ message.content }}</p>
|
||||
<template v-if="message.attachments && message.attachments.length > 0">
|
||||
<img
|
||||
v-for="att in message.attachments"
|
||||
:key="att.id"
|
||||
:src="att.url"
|
||||
class="mt-1.5 w-full rounded-lg"
|
||||
loading="lazy"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
<p class="mt-0.5 text-right text-[10px] text-muted-foreground">{{ date.diffForHumans(message.created_at) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Assistant message -->
|
||||
<div v-else class="flex justify-start gap-2">
|
||||
<div class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary/10">
|
||||
<IconSparkles
|
||||
:class="['h-3 w-3 text-primary', isPendingAssistant(message) && 'animate-pulse']"
|
||||
/>
|
||||
</div>
|
||||
<div class="max-w-[80%]">
|
||||
<!-- Pending / generating: thinking dots -->
|
||||
<div v-if="isPendingAssistant(message)" class="rounded-2xl rounded-bl-sm bg-muted px-4 py-2.5">
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground/60 [animation-delay:0ms]" />
|
||||
<span class="h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground/60 [animation-delay:150ms]" />
|
||||
<span class="h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground/60 [animation-delay:300ms]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Failed: error + retry -->
|
||||
<div v-else-if="isFailedAssistant(message)" class="rounded-2xl rounded-bl-sm bg-destructive/10 px-3 py-2 text-destructive">
|
||||
<p class="whitespace-pre-wrap text-sm">{{ message.content }}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="mt-2"
|
||||
:disabled="sending"
|
||||
@click="retryMessage(message)"
|
||||
>
|
||||
<IconRefresh class="mr-1.5 h-3.5 w-3.5" />
|
||||
{{ $t('assistant.retry') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Completed -->
|
||||
<div
|
||||
v-else
|
||||
:class="[
|
||||
'rounded-2xl rounded-bl-sm px-3 py-2',
|
||||
message.metadata?.error ? 'bg-destructive/10 text-destructive' : 'bg-muted',
|
||||
]"
|
||||
>
|
||||
<div
|
||||
v-if="message.content_html"
|
||||
class="prose prose-sm dark:prose-invert max-w-none text-sm [&>p:last-child]:mb-0 [&>p:first-child]:mt-0"
|
||||
v-html="message.content_html"
|
||||
/>
|
||||
<p v-else class="whitespace-pre-wrap text-sm">{{ message.content }}</p>
|
||||
|
||||
<template v-if="message.attachments && message.attachments.length > 0">
|
||||
<div v-for="attachment in message.attachments" :key="attachment.id" class="mt-2.5 space-y-2">
|
||||
<img
|
||||
v-if="isImage(attachment)"
|
||||
:src="attachment.url"
|
||||
:alt="'AI generated image'"
|
||||
class="w-full cursor-pointer rounded-lg transition-opacity hover:opacity-90"
|
||||
loading="lazy"
|
||||
@click="previewImage = attachment.url"
|
||||
/>
|
||||
|
||||
<audio
|
||||
v-else-if="isAudio(attachment)"
|
||||
:src="attachment.url"
|
||||
controls
|
||||
class="w-full"
|
||||
/>
|
||||
|
||||
<video
|
||||
v-else-if="isVideo(attachment)"
|
||||
:src="attachment.url"
|
||||
controls
|
||||
class="w-full rounded-lg"
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="w-full"
|
||||
:disabled="isAdded(attachment.id)"
|
||||
@click="addToPost(message, attachment)"
|
||||
>
|
||||
<IconCheck v-if="isAdded(attachment.id)" class="mr-1.5 h-3.5 w-3.5" />
|
||||
<IconPlus v-else class="mr-1.5 h-3.5 w-3.5" />
|
||||
{{ isAdded(attachment.id) ? $t('assistant.added') : $t('assistant.add_to_post') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="message.metadata?.quick_actions && message.metadata.quick_actions.length > 0">
|
||||
<div class="mt-2 flex flex-wrap gap-1.5">
|
||||
<Button
|
||||
v-for="action in message.metadata.quick_actions"
|
||||
:key="action.value"
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-auto rounded-full px-3 py-1 text-xs"
|
||||
:disabled="clickedMessageIds.has(message.id) || sending"
|
||||
@click="clickQuickAction(message, action)"
|
||||
>
|
||||
<component
|
||||
:is="getQuickActionIcon(action.value)"
|
||||
v-if="getQuickActionIcon(action.value)"
|
||||
class="mr-1 h-3.5 w-3.5"
|
||||
/>
|
||||
{{ action.label }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<p class="mt-0.5 text-[10px] text-muted-foreground">{{ date.diffForHumans(message.created_at) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Input -->
|
||||
<div class="shrink-0 border-t p-3">
|
||||
<!-- Image preview -->
|
||||
<div v-if="imagePreview" class="mb-2 flex items-center gap-2">
|
||||
<img :src="imagePreview" class="h-16 w-16 rounded-lg object-cover" />
|
||||
<button type="button" class="text-xs text-muted-foreground hover:text-destructive" @click="clearImage">
|
||||
<IconX class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-end gap-2">
|
||||
<button type="button" class="mb-1 text-muted-foreground hover:text-foreground" @click="triggerFileInput">
|
||||
<IconPaperclip class="h-5 w-5" />
|
||||
</button>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
class="hidden"
|
||||
accept="image/jpeg,image/png,image/gif,image/webp"
|
||||
@change="handleFileSelect"
|
||||
/>
|
||||
<Textarea
|
||||
v-model="body"
|
||||
:placeholder="$t('assistant.placeholder')"
|
||||
class="min-h-[40px] max-h-[120px] flex-1 resize-none text-sm"
|
||||
rows="1"
|
||||
:disabled="sending"
|
||||
@keydown="handleKeydown"
|
||||
/>
|
||||
<Button
|
||||
size="icon"
|
||||
class="h-10 w-10 shrink-0"
|
||||
:disabled="!body.trim() || sending"
|
||||
@click="sendMessage"
|
||||
>
|
||||
<IconLoader2 v-if="sending" class="h-4 w-4 animate-spin" />
|
||||
<IconSend v-else class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ImagePreviewDialog :src="previewImage" @close="previewImage = null" />
|
||||
</template>
|
||||
|
|
@ -51,11 +51,19 @@ const isReel = computed(() => props.contentType === ContentType.InstagramReel);
|
|||
const isStory = computed(() => props.contentType === ContentType.InstagramStory);
|
||||
const isFeed = computed(() => !isReel.value && !isStory.value);
|
||||
|
||||
// Aspect ratio for the feed media frame. Instagram defaults to 1:1.
|
||||
// Padding-bottom percentage = height/width. Used instead of CSS `aspect-ratio`
|
||||
// because inside this flex column some rendering paths ignored `aspect-ratio`
|
||||
// and the frame stuck to a stale height. `null` = use original media height.
|
||||
const ASPECT_PADDING: Record<string, number | null> = {
|
||||
'1:1': 100,
|
||||
'4:5': 125,
|
||||
'16:9': 56.25,
|
||||
'original': null,
|
||||
};
|
||||
|
||||
const feedAspectStyle = computed(() => {
|
||||
const ratio = props.meta?.aspect_ratio ?? '1:1';
|
||||
const value = ratio === '4:5' ? '4 / 5' : ratio === '16:9' ? '16 / 9' : ratio === 'original' ? 'auto' : '1 / 1';
|
||||
return { aspectRatio: value };
|
||||
const fraction = ASPECT_PADDING[props.meta?.aspect_ratio ?? '1:1'] ?? 100;
|
||||
return fraction === null ? { aspectRatio: 'auto' } : { paddingBottom: `${fraction}%` };
|
||||
});
|
||||
|
||||
// Format numbers like Instagram
|
||||
|
|
@ -118,12 +126,14 @@ const username = computed(() => props.socialAccount.username || props.socialAcco
|
|||
|
||||
<!-- Post Media - Aspect ratio matches user's chosen crop -->
|
||||
<div class="relative w-full shrink-0 bg-black" :style="feedAspectStyle">
|
||||
<PostMediaPreview
|
||||
:media="media"
|
||||
:placeholder-icon="IconPhoto"
|
||||
dot-active-class="bg-[#0095f6]"
|
||||
placeholder-class="w-full h-full flex items-center justify-center bg-[#fafafa] dark:bg-[#121212]"
|
||||
/>
|
||||
<div class="absolute inset-0">
|
||||
<PostMediaPreview
|
||||
:media="media"
|
||||
:placeholder-icon="IconPhoto"
|
||||
dot-active-class="bg-[#0095f6]"
|
||||
placeholder-class="w-full h-full flex items-center justify-center bg-[#fafafa] dark:bg-[#121212]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
|
|
|
|||
|
|
@ -23,13 +23,14 @@ interface SocialAccount {
|
|||
interface MediaItem {
|
||||
id: string;
|
||||
url: string;
|
||||
type: string;
|
||||
original_filename: string;
|
||||
type?: string;
|
||||
mime_type?: string;
|
||||
original_filename?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
platform: string;
|
||||
socialAccount: SocialAccount;
|
||||
socialAccount: SocialAccount | null | undefined;
|
||||
content: string;
|
||||
media: MediaItem[];
|
||||
contentType?: string;
|
||||
|
|
@ -38,6 +39,14 @@ interface Props {
|
|||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const resolvedSocialAccount = computed((): SocialAccount => props.socialAccount ?? {
|
||||
id: '',
|
||||
platform: props.platform,
|
||||
display_name: '',
|
||||
username: '',
|
||||
avatar_url: null,
|
||||
});
|
||||
|
||||
const previewComponent = computed(() => {
|
||||
switch (props.platform) {
|
||||
case 'linkedin':
|
||||
|
|
@ -71,7 +80,7 @@ const previewComponent = computed(() => {
|
|||
<template>
|
||||
<component
|
||||
:is="previewComponent"
|
||||
:social-account="socialAccount"
|
||||
:social-account="resolvedSocialAccount"
|
||||
:content="content"
|
||||
:media="media"
|
||||
:content-type="contentType"
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { trans } from 'laravel-vue-i18n';
|
|||
import { computed, ref } from 'vue';
|
||||
|
||||
import WorkspaceController from '@/actions/App/Http/Controllers/App/WorkspaceController';
|
||||
import FontPicker from '@/components/FontPicker.vue';
|
||||
import HeadingSmall from '@/components/HeadingSmall.vue';
|
||||
import HexColorInput from '@/components/HexColorInput.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
|
|
@ -29,11 +30,13 @@ interface Workspace {
|
|||
brand_color: string | null;
|
||||
background_color: string | null;
|
||||
text_color: string | null;
|
||||
brand_font: string;
|
||||
content_language: string;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
workspace: Workspace;
|
||||
availableFonts: string[];
|
||||
}>();
|
||||
|
||||
const brandTone = ref(props.workspace.brand_tone ?? 'professional');
|
||||
|
|
@ -41,6 +44,7 @@ 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}`) : '',
|
||||
|
|
@ -158,6 +162,12 @@ const languageLabel = computed(() => {
|
|||
</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
|
||||
|
|
|
|||
60
resources/js/composables/useAiStream.ts
Normal file
60
resources/js/composables/useAiStream.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { echo } from '@laravel/echo-vue';
|
||||
import { onUnmounted, ref } from 'vue';
|
||||
|
||||
interface TextDeltaEvent {
|
||||
delta: string;
|
||||
}
|
||||
|
||||
interface ErrorEvent {
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export type AiStreamStatus = 'idle' | 'streaming' | 'completed' | 'failed';
|
||||
|
||||
/**
|
||||
* Subscribe to a private channel for an in-flight AI generation.
|
||||
* Reactive state accumulates `.TextDelta` event deltas and transitions to
|
||||
* `completed` on `.StreamEnd` or `failed` on `.Error`.
|
||||
*/
|
||||
export const useAiStream = () => {
|
||||
const text = ref('');
|
||||
const status = ref<AiStreamStatus>('idle');
|
||||
const errorMessage = ref<string | null>(null);
|
||||
let subscribedName: string | null = null;
|
||||
|
||||
const reset = () => {
|
||||
text.value = '';
|
||||
status.value = 'idle';
|
||||
errorMessage.value = null;
|
||||
};
|
||||
|
||||
const unsubscribe = () => {
|
||||
if (subscribedName) {
|
||||
echo().leave(`private-${subscribedName}`);
|
||||
}
|
||||
subscribedName = null;
|
||||
};
|
||||
|
||||
const subscribe = (channelName: string) => {
|
||||
unsubscribe();
|
||||
reset();
|
||||
status.value = 'streaming';
|
||||
subscribedName = channelName;
|
||||
|
||||
echo().private(channelName)
|
||||
.listen('.TextDelta', (e: TextDeltaEvent) => {
|
||||
text.value += e.delta ?? '';
|
||||
})
|
||||
.listen('.StreamEnd', () => {
|
||||
status.value = 'completed';
|
||||
})
|
||||
.listen('.Error', (e: ErrorEvent) => {
|
||||
status.value = 'failed';
|
||||
errorMessage.value = e?.message ?? 'AI generation failed';
|
||||
});
|
||||
};
|
||||
|
||||
onUnmounted(() => unsubscribe());
|
||||
|
||||
return { text, status, errorMessage, subscribe, unsubscribe, reset };
|
||||
};
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
export const ContentType = {
|
||||
InstagramFeed: 'instagram_feed',
|
||||
InstagramReel: 'instagram_reel',
|
||||
InstagramCarousel: 'instagram_carousel',
|
||||
InstagramStory: 'instagram_story',
|
||||
InstagramReel: 'instagram_reel',
|
||||
LinkedInPost: 'linkedin_post',
|
||||
LinkedInCarousel: 'linkedin_carousel',
|
||||
LinkedInPagePost: 'linkedin_page_post',
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import date from '@/date';
|
|||
import dayjs from '@/dayjs';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import { calendar } from '@/routes/app';
|
||||
import { edit as editPost, show as showPost, store as storePost } from '@/routes/app/posts';
|
||||
import { create as createPost, edit as editPost, show as showPost } from '@/routes/app/posts';
|
||||
|
||||
interface PostPlatform {
|
||||
id: string;
|
||||
|
|
@ -52,6 +52,8 @@ const props = defineProps<Props>();
|
|||
|
||||
// Mobile detection
|
||||
const isMobile = ref(false);
|
||||
const createPostUrl = (isoDate: string | null = null) =>
|
||||
isoDate ? createPost.url({ query: { date: isoDate } }) : createPost.url();
|
||||
const checkMobile = () => {
|
||||
isMobile.value = window.innerWidth < 1024;
|
||||
};
|
||||
|
|
@ -270,10 +272,8 @@ const formatTime = (scheduledAt: string): string => {
|
|||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
<Link :href="storePost.url()" method="post">
|
||||
<Button>
|
||||
{{ $t('calendar.new_post') }}
|
||||
</Button>
|
||||
<Link :href="createPost.url()">
|
||||
<Button>{{ $t('calendar.new_post') }}</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -360,8 +360,8 @@ const formatTime = (scheduledAt: string): string => {
|
|||
<!-- Day Content -->
|
||||
<div class="flex-1 overflow-y-auto p-2 space-y-2">
|
||||
<!-- Add Post Button -->
|
||||
<Link :href="storePost.url({ query: { date: day.format('YYYY-MM-DD') } })" method="post"
|
||||
class="flex items-center justify-center p-2 rounded border border-dashed border-muted-foreground/30 text-muted-foreground hover:border-primary hover:text-primary hover:bg-primary/5 transition-colors">
|
||||
<Link :href="createPostUrl(day.format('YYYY-MM-DD'))"
|
||||
class="w-full flex items-center justify-center p-2 rounded border border-dashed border-muted-foreground/30 text-muted-foreground hover:border-primary hover:text-primary hover:bg-primary/5 transition-colors">
|
||||
<IconPlus class="h-4 w-4" />
|
||||
</Link>
|
||||
|
||||
|
|
@ -435,7 +435,7 @@ const formatTime = (scheduledAt: string): string => {
|
|||
}">
|
||||
{{ day.format('D') }}
|
||||
</span>
|
||||
<Link :href="storePost.url({ query: { date: day.format('YYYY-MM-DD') } })" method="post"
|
||||
<Link :href="createPostUrl(day.format('YYYY-MM-DD'))"
|
||||
class="opacity-0 group-hover:opacity-100 focus:opacity-100 p-1 rounded text-muted-foreground hover:text-primary hover:bg-primary/10 transition-all">
|
||||
<IconPlus class="h-4 w-4" />
|
||||
</Link>
|
||||
|
|
@ -484,4 +484,5 @@ const formatTime = (scheduledAt: string): string => {
|
|||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
|
||||
</template>
|
||||
146
resources/js/pages/posts/Create.vue
Normal file
146
resources/js/pages/posts/Create.vue
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
<script setup lang="ts">
|
||||
import { Head, Link, router } from '@inertiajs/vue3';
|
||||
import { IconBookmarks, IconPencil, IconSparkles } from '@tabler/icons-vue';
|
||||
import { trans } from 'laravel-vue-i18n';
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { index as templatesIndex } from '@/actions/App/Http/Controllers/App/PostTemplateController';
|
||||
import PageHeader from '@/components/PageHeader.vue';
|
||||
import AiPostWizard from '@/components/posts/create/AiPostWizard.vue';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import { calendar } from '@/routes/app';
|
||||
import { store as storePost } from '@/routes/app/posts';
|
||||
|
||||
interface SocialAccount {
|
||||
id: string;
|
||||
platform: string;
|
||||
display_name: string;
|
||||
username: string;
|
||||
avatar_url: string | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
/** ISO date (YYYY-MM-DD). When set, the manual "start from scratch" path
|
||||
* pre-schedules the new post on this date. */
|
||||
date?: string | null;
|
||||
socialAccounts: SocialAccount[];
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
date: null,
|
||||
});
|
||||
|
||||
type View = 'choice' | 'ai';
|
||||
|
||||
const view = ref<View>('choice');
|
||||
const submitting = ref(false);
|
||||
|
||||
const aiHeader = ref<{ title: string; description: string } | null>(null);
|
||||
|
||||
const hasConnectedAccounts = computed(() => props.socialAccounts.length > 0);
|
||||
|
||||
const startFromScratch = () => {
|
||||
if (submitting.value) return;
|
||||
submitting.value = true;
|
||||
const url = props.date ? storePost.url({ query: { date: props.date } }) : storePost.url();
|
||||
router.post(url, {}, {
|
||||
onFinish: () => {
|
||||
submitting.value = false;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const breadcrumbs = computed(() => [
|
||||
{ title: trans('sidebar.posts.calendar'), href: calendar.url() },
|
||||
{ title: trans('posts.create.title'), href: '' },
|
||||
]);
|
||||
|
||||
const pageTitle = computed(() => trans('posts.create.title'));
|
||||
|
||||
const stepHeader = computed(() => {
|
||||
if (view.value === 'ai' && aiHeader.value) return aiHeader.value;
|
||||
return {
|
||||
title: trans('posts.create.title'),
|
||||
description: trans('posts.create.description'),
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Head :title="pageTitle" />
|
||||
|
||||
<AppLayout :breadcrumbs="breadcrumbs">
|
||||
<div class="flex h-full flex-1 flex-col p-4">
|
||||
<div class="mx-auto flex w-full max-w-2xl flex-col gap-6">
|
||||
<PageHeader :title="stepHeader.title" :description="stepHeader.description" />
|
||||
|
||||
<!-- Choice screen -->
|
||||
<template v-if="view === 'choice'">
|
||||
<div class="grid gap-3 sm:grid-cols-3">
|
||||
<button
|
||||
type="button"
|
||||
class="group flex flex-col items-start gap-3 rounded-xl border bg-card p-5 text-left transition-all hover:-translate-y-0.5 hover:border-primary/50 hover:shadow-md disabled:opacity-50 disabled:hover:translate-y-0 disabled:hover:border-border disabled:hover:shadow-none"
|
||||
:disabled="submitting"
|
||||
@click="startFromScratch"
|
||||
>
|
||||
<div class="flex size-11 items-center justify-center rounded-lg bg-muted text-muted-foreground transition-colors group-hover:bg-foreground group-hover:text-background">
|
||||
<IconPencil class="size-5" />
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm font-semibold">{{ $t('posts.create.scratch_title') }}</p>
|
||||
<p class="text-xs leading-relaxed text-muted-foreground">
|
||||
{{ $t('posts.create.scratch_description') }}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="group flex flex-col items-start gap-3 rounded-xl border bg-card p-5 text-left transition-all hover:-translate-y-0.5 hover:border-primary/50 hover:shadow-md disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:translate-y-0 disabled:hover:border-border disabled:hover:shadow-none"
|
||||
:disabled="!hasConnectedAccounts"
|
||||
@click="view = 'ai'"
|
||||
>
|
||||
<div class="flex size-11 items-center justify-center rounded-lg bg-muted text-muted-foreground transition-colors group-hover:bg-foreground group-hover:text-background">
|
||||
<IconSparkles class="size-5" />
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm font-semibold">{{ $t('posts.create.ai_title') }}</p>
|
||||
<p class="text-xs leading-relaxed text-muted-foreground">
|
||||
<template v-if="!hasConnectedAccounts">
|
||||
{{ $t('posts.create.steps.connect_first') }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ $t('posts.create.ai_description') }}
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<Link
|
||||
:href="templatesIndex.url()"
|
||||
class="group flex flex-col items-start gap-3 rounded-xl border bg-card p-5 text-left transition-all hover:-translate-y-0.5 hover:border-primary/50 hover:shadow-md"
|
||||
>
|
||||
<div class="flex size-11 items-center justify-center rounded-lg bg-muted text-muted-foreground transition-colors group-hover:bg-foreground group-hover:text-background">
|
||||
<IconBookmarks class="size-5" />
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm font-semibold">{{ $t('posts.create.template_title') }}</p>
|
||||
<p class="text-xs leading-relaxed text-muted-foreground">
|
||||
{{ $t('posts.create.template_description') }}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- AI flow -->
|
||||
<AiPostWizard
|
||||
v-else-if="view === 'ai'"
|
||||
:social-accounts="socialAccounts"
|
||||
@update:step-header="aiHeader = $event"
|
||||
@cancel="view = 'choice'; aiHeader = null"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
|
@ -10,6 +10,8 @@ import {
|
|||
import { trans } from 'laravel-vue-i18n';
|
||||
import { computed, onUnmounted, ref, watch } from 'vue';
|
||||
|
||||
import AiGenerateDialog from '@/components/posts/ai/AiGenerateDialog.vue';
|
||||
import AiReviewDialog from '@/components/posts/ai/AiReviewDialog.vue';
|
||||
import ConfirmDeleteModal from '@/components/ConfirmDeleteModal.vue';
|
||||
import PostEditorComposer from '@/components/posts/editor/PostEditorComposer.vue';
|
||||
import PostEditorSidebar from '@/components/posts/editor/PostEditorSidebar.vue';
|
||||
|
|
@ -186,8 +188,8 @@ const getMediaIncompatibilityReason = (contentType: string, mediaItems: MediaIte
|
|||
if (!rules.acceptVideos && videos.length > 0) return trans('posts.edit.compliance.no_videos');
|
||||
if (!rules.acceptImages && images.length > 0) return trans('posts.edit.compliance.no_images');
|
||||
if (!rules.acceptsGif && gifs.length > 0) return trans('posts.edit.compliance.no_gifs');
|
||||
if (total > rules.maxFiles) return trans('posts.edit.compliance.too_many_files', { max: rules.maxFiles });
|
||||
if (rules.minFiles && total < rules.minFiles) return trans('posts.edit.compliance.too_few_files', { min: rules.minFiles });
|
||||
if (total > rules.maxFiles) return trans('posts.edit.compliance.too_many_files', { max: String(rules.maxFiles) });
|
||||
if (rules.minFiles && total < rules.minFiles) return trans('posts.edit.compliance.too_few_files', { min: String(rules.minFiles) });
|
||||
|
||||
for (const m of mediaItems) {
|
||||
const isVideo = m.type === 'video' || m.mime_type?.startsWith('video/');
|
||||
|
|
@ -199,7 +201,7 @@ const getMediaIncompatibilityReason = (contentType: string, mediaItems: MediaIte
|
|||
if (isVideo) {
|
||||
if (rules.maxVideoBytes && size > 0 && size > rules.maxVideoBytes) return trans('posts.edit.compliance.video_too_large');
|
||||
if (rules.maxVideoDurationSec && duration > 0 && duration > rules.maxVideoDurationSec) {
|
||||
return trans('posts.edit.compliance.video_too_long', { seconds: rules.maxVideoDurationSec });
|
||||
return trans('posts.edit.compliance.video_too_long', { seconds: String(rules.maxVideoDurationSec) });
|
||||
}
|
||||
} else if (rules.maxImageBytes && size > 0 && size > rules.maxImageBytes) {
|
||||
return trans('posts.edit.compliance.image_too_large');
|
||||
|
|
@ -289,6 +291,16 @@ const selectedLabelIds = ref<string[]>(post.value.labels?.map((l) => l.id) || []
|
|||
const isSubmitting = ref(false);
|
||||
const isSaving = ref(false);
|
||||
const showSaved = ref(false);
|
||||
const isAiGenerateOpen = ref(false);
|
||||
const isAiReviewOpen = ref(false);
|
||||
|
||||
const onAiGenerateApply = (newContent: string) => {
|
||||
content.value = newContent;
|
||||
};
|
||||
|
||||
const onAiReviewApply = (original: string, suggestion: string) => {
|
||||
content.value = content.value.replace(original, suggestion);
|
||||
};
|
||||
|
||||
const isPostActionDisabled = computed(
|
||||
() => isSubmitting.value || selectedPlatformIds.value.length === 0 || !canSchedule.value,
|
||||
|
|
@ -296,7 +308,7 @@ const isPostActionDisabled = computed(
|
|||
const queryParams = typeof window !== 'undefined' ? new URLSearchParams(window.location.search) : null;
|
||||
const initialTabFromQuery = (() => {
|
||||
const tab = queryParams?.get('tab');
|
||||
return ['preview', 'schedule', 'comments', 'assistant'].includes(tab ?? '') ? (tab as string) : 'schedule';
|
||||
return ['preview', 'schedule', 'comments'].includes(tab ?? '') ? (tab as string) : 'schedule';
|
||||
})();
|
||||
const initialHighlightCommentId = queryParams?.get('comment') ?? null;
|
||||
const activeTab = ref(initialTabFromQuery);
|
||||
|
|
@ -313,24 +325,6 @@ const togglePlatform = (platformId: string) => {
|
|||
}
|
||||
};
|
||||
|
||||
const addedTextFromMessageIds = ref<Set<string>>(new Set());
|
||||
|
||||
const addMediaFromAssistant = (payload: {
|
||||
messageId: string;
|
||||
messageContent: string;
|
||||
media: { id: string; path: string; url: string; type: string; mime_type: string };
|
||||
}) => {
|
||||
media.value = [...media.value, payload.media];
|
||||
|
||||
const text = payload.messageContent.trim();
|
||||
if (text === '' || addedTextFromMessageIds.value.has(payload.messageId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
content.value = content.value.trim() === '' ? text : `${content.value}\n\n${text}`;
|
||||
addedTextFromMessageIds.value.add(payload.messageId);
|
||||
};
|
||||
|
||||
// Save logic
|
||||
const getSubmitData = () => {
|
||||
const platforms = post.value.post_platforms
|
||||
|
|
@ -416,10 +410,6 @@ const toggleLabel = (labelId: string) => {
|
|||
}
|
||||
};
|
||||
|
||||
const focusAssistant = () => {
|
||||
activeTab.value = 'assistant';
|
||||
};
|
||||
|
||||
const deletePost = () => {
|
||||
if (isReadOnly.value) return;
|
||||
deleteModal.value?.open({ url: destroyPost.url(post.value.id) });
|
||||
|
|
@ -535,7 +525,8 @@ useEcho(`post.${post.value.id}`, '.PostCommentCreated', (e: any) => {
|
|||
:hashtags="hashtags"
|
||||
:platform-limits="platformLimits"
|
||||
:media-issues="mediaIssues"
|
||||
@focus-assistant="focusAssistant"
|
||||
@open-ai-generate="isAiGenerateOpen = true"
|
||||
@open-ai-review="isAiReviewOpen = true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -562,7 +553,6 @@ useEcho(`post.${post.value.id}`, '.PostCommentCreated', (e: any) => {
|
|||
@toggle-label="toggleLabel"
|
||||
@update:platform-meta="updatePlatformMeta"
|
||||
@update:platform-content-type="updatePlatformContentType"
|
||||
@add-media-from-assistant="addMediaFromAssistant"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -577,4 +567,18 @@ useEcho(`post.${post.value.id}`, '.PostCommentCreated', (e: any) => {
|
|||
:action="$t('posts.delete.confirm')"
|
||||
:cancel="$t('posts.delete.cancel')"
|
||||
/>
|
||||
|
||||
<AiGenerateDialog
|
||||
v-model:open="isAiGenerateOpen"
|
||||
:post-id="post.id"
|
||||
:current-content="content"
|
||||
@apply="onAiGenerateApply"
|
||||
/>
|
||||
|
||||
<AiReviewDialog
|
||||
v-model:open="isAiReviewOpen"
|
||||
:post-id="post.id"
|
||||
:content="content"
|
||||
@apply="onAiReviewApply"
|
||||
/>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
<script setup lang="ts">
|
||||
import { Head, InfiniteScroll, Link, router } from '@inertiajs/vue3';
|
||||
import { Head, InfiniteScroll, router } from '@inertiajs/vue3';
|
||||
import { IconFileText, IconSearch, IconTrash } from '@tabler/icons-vue';
|
||||
import { trans } from 'laravel-vue-i18n';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import { destroy as destroyPost, edit as editPost, index as postsIndex, show as showPost, store as storePost } from '@/actions/App/Http/Controllers/App/PostController';
|
||||
import { create as createPost, destroy as destroyPost, edit as editPost, index as postsIndex, show as showPost } from '@/actions/App/Http/Controllers/App/PostController';
|
||||
import ConfirmDeleteModal from '@/components/ConfirmDeleteModal.vue';
|
||||
import EmptyState from '@/components/EmptyState.vue';
|
||||
import PageHeader from '@/components/PageHeader.vue';
|
||||
|
|
@ -161,7 +161,7 @@ const hasActiveSearch = computed(() => Boolean(searchQuery.value?.trim()));
|
|||
/>
|
||||
</div>
|
||||
|
||||
<Link :href="storePost.url()" method="post">
|
||||
<Link :href="createPost.url()">
|
||||
<Button>{{ $t('posts.new_post') }}</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
|
@ -289,4 +289,5 @@ const hasActiveSearch = computed(() => Boolean(searchQuery.value?.trim()));
|
|||
:action="$t('posts.edit.delete_modal.action')"
|
||||
:cancel="$t('posts.edit.delete_modal.cancel')"
|
||||
/>
|
||||
|
||||
</template>
|
||||
|
|
|
|||
195
resources/js/pages/posts/templates/Index.vue
Normal file
195
resources/js/pages/posts/templates/Index.vue
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
<script setup lang="ts">
|
||||
import { Head, InfiniteScroll, router } from '@inertiajs/vue3';
|
||||
import { IconBookmarks, IconLoader2, IconSearch } from '@tabler/icons-vue';
|
||||
import { trans } from 'laravel-vue-i18n';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import { apply as applyRoute, index as templatesIndex } from '@/actions/App/Http/Controllers/App/PostTemplateController';
|
||||
import EmptyState from '@/components/EmptyState.vue';
|
||||
import PageHeader from '@/components/PageHeader.vue';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { getPlatformLogo } from '@/composables/usePlatformLogo';
|
||||
import debounce from '@/debounce';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import { calendar } from '@/routes/app';
|
||||
|
||||
interface Slide {
|
||||
title: string;
|
||||
body: string;
|
||||
image_keywords: string[];
|
||||
}
|
||||
|
||||
interface PostTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
category: string;
|
||||
platform: string;
|
||||
content: string;
|
||||
slides: Slide[] | null;
|
||||
image_count: number;
|
||||
image_keywords: string[] | null;
|
||||
}
|
||||
|
||||
interface ScrollTemplates {
|
||||
data: PostTemplate[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
templates: ScrollTemplates;
|
||||
filters: {
|
||||
search: string;
|
||||
platform: string;
|
||||
};
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const searchQuery = ref(props.filters.search);
|
||||
const applyingId = ref<string | null>(null);
|
||||
|
||||
const PLATFORM_LABELS: Record<string, string> = {
|
||||
instagram_carousel: 'Instagram Carousel',
|
||||
instagram_feed: 'Instagram Feed',
|
||||
linkedin_post: 'LinkedIn',
|
||||
linkedin_page_post: 'LinkedIn Page',
|
||||
x_post: 'X',
|
||||
};
|
||||
|
||||
const PLATFORM_LOGO_KEY: Record<string, string> = {
|
||||
instagram_carousel: 'instagram',
|
||||
instagram_feed: 'instagram',
|
||||
linkedin_post: 'linkedin',
|
||||
linkedin_page_post: 'linkedin-page',
|
||||
x_post: 'x',
|
||||
};
|
||||
|
||||
const platformLabel = (platform: string): string => PLATFORM_LABELS[platform] ?? platform;
|
||||
const platformLogo = (platform: string): string => getPlatformLogo(PLATFORM_LOGO_KEY[platform] ?? platform);
|
||||
|
||||
const search = debounce(() => {
|
||||
router.get(
|
||||
templatesIndex.url(),
|
||||
{ search: searchQuery.value || undefined },
|
||||
{ preserveState: true, preserveScroll: true, replace: true },
|
||||
);
|
||||
}, 300);
|
||||
|
||||
watch(searchQuery, () => search());
|
||||
|
||||
const hasActiveSearch = computed(() => Boolean(searchQuery.value?.trim()));
|
||||
|
||||
const applyTemplate = async (template: PostTemplate) => {
|
||||
if (applyingId.value) return;
|
||||
applyingId.value = template.id;
|
||||
|
||||
try {
|
||||
const csrfToken = document.querySelector<HTMLMetaElement>('meta[name="csrf-token"]')?.content ?? '';
|
||||
const response = await fetch(applyRoute.url(template.id), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
'X-CSRF-TOKEN': csrfToken,
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.json().catch(() => ({}));
|
||||
throw new Error(err?.message ?? `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
router.visit(data.redirect_url);
|
||||
} finally {
|
||||
applyingId.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const breadcrumbs = computed(() => [
|
||||
{ title: trans('sidebar.posts.calendar'), href: calendar.url() },
|
||||
{ title: trans('posts.templates.browser_title'), href: '' },
|
||||
]);
|
||||
|
||||
const pageTitle = computed(() => trans('posts.templates.browser_title'));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Head :title="pageTitle" />
|
||||
|
||||
<AppLayout :breadcrumbs="breadcrumbs">
|
||||
<div class="flex h-full flex-1 flex-col gap-4 p-4">
|
||||
<PageHeader :title="pageTitle" :description="$t('posts.templates.browser_description')" />
|
||||
|
||||
<!-- Search bar -->
|
||||
<div class="relative max-w-md">
|
||||
<IconSearch class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
v-model="searchQuery"
|
||||
:placeholder="$t('posts.templates.search_placeholder')"
|
||||
class="pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<EmptyState
|
||||
v-if="templates.data.length === 0"
|
||||
:icon="IconBookmarks"
|
||||
:title="hasActiveSearch ? $t('posts.templates.no_search_results') : $t('posts.templates.no_templates')"
|
||||
:description="hasActiveSearch ? $t('posts.templates.try_different_search') : ''"
|
||||
/>
|
||||
|
||||
<InfiniteScroll v-else data="templates" items-element="#templates-grid" preserve-url>
|
||||
<!-- CSS columns masonry: cards keep their natural height + don't split mid-card -->
|
||||
<div id="templates-grid" class="columns-1 gap-4 sm:columns-2 lg:columns-3 xl:columns-4">
|
||||
<article
|
||||
v-for="template in templates.data"
|
||||
:key="template.id"
|
||||
class="mb-4 break-inside-avoid rounded-xl border bg-card p-5 transition-colors hover:border-primary/40"
|
||||
>
|
||||
<header class="flex items-center gap-2">
|
||||
<img
|
||||
:src="platformLogo(template.platform)"
|
||||
:alt="template.platform"
|
||||
class="size-6 shrink-0 rounded-full ring-1 ring-border"
|
||||
/>
|
||||
<span class="truncate text-xs font-medium text-muted-foreground">{{ platformLabel(template.platform) }}</span>
|
||||
<Badge variant="secondary" class="ml-auto shrink-0 text-xs font-normal">
|
||||
{{ $t(`posts.templates.category.${template.category}`) }}
|
||||
</Badge>
|
||||
</header>
|
||||
|
||||
<h3 class="mt-3 text-base font-semibold leading-snug tracking-tight">{{ template.name }}</h3>
|
||||
|
||||
<p
|
||||
v-if="template.description"
|
||||
class="mt-2 text-sm leading-relaxed text-muted-foreground"
|
||||
>
|
||||
{{ template.description }}
|
||||
</p>
|
||||
|
||||
<p
|
||||
v-if="template.slides && template.slides.length > 0"
|
||||
class="mt-3 text-xs text-muted-foreground"
|
||||
>
|
||||
{{ $t('posts.templates.slides_count', { count: template.slides.length }) }}
|
||||
</p>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
class="mt-4 w-full"
|
||||
:disabled="applyingId === template.id"
|
||||
@click="applyTemplate(template)"
|
||||
>
|
||||
<IconLoader2 v-if="applyingId === template.id" class="mr-1 size-3 animate-spin" />
|
||||
{{ applyingId === template.id ? $t('posts.templates.applying') : $t('posts.templates.use_this') }}
|
||||
</Button>
|
||||
</article>
|
||||
</div>
|
||||
</InfiniteScroll>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue