Core changes:
- Replace Unsplash slide pipeline with gpt-image-2 via Laravel AI SDK.
New AiImageClient builds prompts from a Blade template seeded by the
workspace's ImageStyle enum, content language, brand color (mapped to a
human-readable name via HexColorName helper) and brand description.
- Drop Template B from TemplateImageGenerator: every slide now renders as
Template A (full-bleed photo + bottom gradient + white/grey overlay).
Removes renderTemplateB, roundCorners, blendHex, ensureContrast and the
closing-slide pipeline.
- StreamPostCreation creates the Post directly and dispatches
PostCreationReady with post_id; the wizard kills its preview step and
redirects straight to the post editor on completion. Finalize endpoint
removed.
- New Workspace.image_style enum field with an 8-option visual picker
shared by /workspaces/create and /settings/workspace/brand via a single
BrandForm component (autofill is a prop). 8 sample webp thumbs ship
under public/images/branding/image-styles/.
- Media items gain optional source ('ai'|'unsplash'|'giphy') and
source_meta (recipe needed to regenerate AI images later); the gallery
picker tags Unsplash/Giphy attachments.
- Brand-color autofill: new CssColorFrequencyExtractor parses every
hex/rgb/hsl value in the homepage CSS, clusters perceptually similar
shades in CIE LAB (Delta E 76 < 12), filters neutrals and returns the
most frequent cluster. Solves Tailwind/utility-CSS sites where no
semantic --primary variable is exposed.
- Credits: gpt-image-2 metered at 15 credits/image (low quality default).
- Layout: AuthSplitLayout right column is sticky/h-svh so the form
textarea growth no longer stretches the marketing slider.
- i18n cleanup: localized labels follow the no-em-dash convention.
135 lines
4.5 KiB
PHP
135 lines
4.5 KiB
PHP
<?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\Requests\App\PostTemplate\ApplyPostTemplateRequest;
|
|
use App\Http\Requests\App\PostTemplate\IndexPostTemplateRequest;
|
|
use App\Http\Resources\App\PostTemplateResource;
|
|
use App\Models\SocialAccount;
|
|
use App\Models\Workspace;
|
|
use App\Services\Image\TemplateImageGenerator;
|
|
use App\Services\PostTemplate\Registry;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Inertia\Inertia;
|
|
use Inertia\Response as InertiaResponse;
|
|
use Symfony\Component\HttpFoundation\Response;
|
|
|
|
class PostTemplateController extends Controller
|
|
{
|
|
public function __construct(private readonly Registry $registry) {}
|
|
|
|
public function index(IndexPostTemplateRequest $request): InertiaResponse
|
|
{
|
|
$paginator = $this->registry->paginate(
|
|
locale: app()->getLocale(),
|
|
platform: $request->input('platform'),
|
|
search: $request->input('search'),
|
|
perPage: (int) config('app.pagination.default'),
|
|
page: (int) $request->input('page', 1),
|
|
path: $request->url(),
|
|
query: $request->query(),
|
|
);
|
|
|
|
return Inertia::render('posts/templates/Index', [
|
|
'templates' => Inertia::scroll(fn () => PostTemplateResource::collection($paginator)),
|
|
'filters' => [
|
|
'search' => $request->input('search', ''),
|
|
'platform' => $request->input('platform', ''),
|
|
],
|
|
'date' => $request->input('date'),
|
|
]);
|
|
}
|
|
|
|
public function apply(ApplyPostTemplateRequest $request, string $slug, TemplateImageGenerator $generator): JsonResponse
|
|
{
|
|
$workspace = $request->user()->currentWorkspace;
|
|
|
|
$this->authorize('createPost', $workspace);
|
|
|
|
$template = $this->registry->find($slug, app()->getLocale());
|
|
|
|
$socialAccountId = $request->input('social_account_id');
|
|
$socialAccount = null;
|
|
|
|
if ($socialAccountId) {
|
|
$socialAccount = SocialAccount::where('id', $socialAccountId)
|
|
->where('workspace_id', $workspace->id)
|
|
->first();
|
|
|
|
abort_if($socialAccount === null, Response::HTTP_FORBIDDEN);
|
|
}
|
|
|
|
$content = $this->interpolate($template->content, $workspace);
|
|
|
|
$media = [];
|
|
|
|
if ($socialAccount && $template->slides) {
|
|
foreach ($template->slides as $slide) {
|
|
$rendered = $generator->render(
|
|
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 ($rendered) {
|
|
$mediaItem = $this->createMediaItem($workspace, $rendered->path);
|
|
$media[] = $mediaItem;
|
|
}
|
|
}
|
|
}
|
|
|
|
$post = CreatePost::execute($workspace, $request->user(), [
|
|
'content' => $content,
|
|
'media' => $media,
|
|
'date' => $request->input('date'),
|
|
]);
|
|
|
|
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
|
|
{
|
|
// Use the relationship (not `mediable_type = Workspace::class`) so
|
|
// the morph map alias `'workspace'` is persisted instead of the FQCN.
|
|
$media = $workspace->media()->create([
|
|
'collection' => 'ai-generated',
|
|
'type' => MediaType::Image,
|
|
'path' => $path,
|
|
'original_filename' => basename($path),
|
|
'mime_type' => 'image/webp',
|
|
'size' => Storage::size($path),
|
|
'order' => 0,
|
|
]);
|
|
|
|
return [
|
|
'id' => $media->id,
|
|
'path' => $media->path,
|
|
'url' => $media->url,
|
|
'type' => 'image',
|
|
'mime_type' => 'image/webp',
|
|
];
|
|
}
|
|
}
|