feat: AI image generation overhaul + post creation flow + realtime broadcast#22
Merged
Conversation
Adds a private workspace channel (WorkspaceChannel, authorised by
membership) so list views can subscribe once and receive events for
every post in the workspace, instead of opening N per-post channels.
New events:
- PostCreated (broadcast on workspace.{id})
- PostDeleted (broadcast on workspace.{id}; carries primitive ids so
it fires after \$post->delete())
- PostPlatformStatusUpdated now broadcasts on both post.{id} and
workspace.{id} so focused views and lists share the same trigger.
All broadcast events migrated to the namespaced 'entity.action'
convention from Laravel's broadcasting docs and switched from
ShouldBroadcastNow to ShouldBroadcast on a dedicated 'broadcasts'
queue (added to the supervisor-1 list in config/horizon.php) to keep
HTTP responses fast:
PostCreated -> post.created
PostDeleted -> post.deleted
PostPlatformStatusUpdated -> post.platform.status.updated
PostCommentCreated -> post.comment.created
NotificationCreated -> notification.created
PostCreationReady -> ai.creation.completed
Drops the SubscriptionCreated event — it was broadcast on
users.{owner_id} but had no listeners (frontend or backend) and the
billing/PostHog flow already handles plan-change tracking elsewhere.
PostPlatformStatusUpdated payload trimmed to {post_id} since every
consumer (Show, Edit, Index) does router.reload({ only: [...] }) and
ignored the rich shape.
The event was dispatched by StripeEventListener but had no consumers
on either side (no Event::listen registration in PHP, no Echo listener
in JS, no subscriber on the users.{owner_id} channel). Removed along
with its tests; the listener no longer references it. Cleans up the
StripeEventListenerTest to match (drops Event::fake(SubscriptionCreated)
in five negative-path tests, replaces with Bus::assertNotDispatched(
TrackBilling) which is the actual cascade-side-effect we care about).
…fresh Two new composables under resources/js/composables/echo/ that abstract channel naming for the two Reverb subscription patterns we now use: - useWorkspaceEcho — list/dashboard views: 1 subscription per workspace delivers events for many entities. Resolves currentWorkspace.id from Inertia shared props; no-ops when there is no workspace yet. - usePostEcho — focused views (Show, Edit): per-post channel. Posts index now subscribes via useWorkspaceEcho to the 3 lifecycle events (post.created / post.deleted / post.platform.status.updated) and reloads the posts prop on receipt. `reset: ['posts']` is required on the reload — without it, InfiniteScroll's merge prop appends every broadcast onto the existing list and produces duplicates. Show / Edit migrated from raw useEcho to usePostEcho; AiGenerateDialog, NotificationBell and AiPostWizard updated to the new event names from the namespaced convention. AiStream composable moved into the same echo/ folder for organisation; its bound event names (TextDelta / StreamEnd / Error) come from the laravel/ai package and stay as-is. Bonus: comments tab send button was overriding shadcn's size="icon" (40px) with class="size-9" (36px), which left it visibly shorter than the textarea. Removed the override and matched the textarea's min-h to size-10 so both render at 40px.
Class names and method signatures already explain what these classes do. Project guideline (CLAUDE.md): comments only when the WHY is non-obvious — implementation details belong in commits, not noise on top of every class. Kept the one comment that earns it: the (int) cast in HasUsage::cachedPostCount, which documents the load-bearing workaround for Laravel's Redis cache numeric optimisation.
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.
…ling across post previews
- Refactor ImagePreviewDialog to expose open(url, type) / openCollection(items, idx) via defineExpose; drop external props/state. GalleryBrowser and PostEditorComposer now call lightbox.open()/openCollection() instead of managing previewItem/previewIndex locally. - Lightbox closes when clicking the empty space inside the wrapper (between image and arrows) using @click.self. Image click also closes; video click triggers play/pause without bubbling to close. Arrow buttons keep @click.stop so navigation never closes. - Assets gallery (uploads / Unsplash / Giphy) now opens the lightbox in collection mode so the user can navigate the full tab list with arrows or keyboard. - Add 'delete' typed-confirmation on the post-delete dialog (Index + Edit pages) and on the asset-delete dialog using the new common.confirm_modal.delete_keyword translation (en: 'delete', pt-BR: 'deletar', es: 'eliminar').
…names
Convert all 'foo.'.$bar style concatenations to "foo.{$bar}" interpolation
in event broadcast channels (PostCreated, PostDeleted, PostCommentCreated,
PostPlatformStatusUpdated, NotificationCreated) and the related event
tests.
Document the convention in CLAUDE.md so new code follows the pattern.
- TemplateImageGenerator->render() now returns a typed array shape
{path: string, source_meta: array} instead of a one-off RenderedSlide
DTO. Single internal callsite, no need for a dedicated class.
- Drop the `?? 'en'` fallback on $workspace->content_language in 6
callsites: the column has a NOT NULL default of 'en' at the DB level,
so the null coalesce was dead code.
- WorkspaceFactory now seeds content_language, brand_tone, brand_font
and image_style explicitly so make() (no DB persist) produces a
complete model — DB defaults aren't applied until create().
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
This branch grew well beyond the original realtime broadcast work into a full AI image generation overhaul. Key pieces:
AI image generation
AiImageClientbuilds prompts from a Blade template (prompts/post_image/generator.blade.php) seeded by:ImageStyleenum (8-option visual picker: cinematic, illustration, isometric, cartoon, typographic, infographic, minimalist, mockup)content_language,brand_color(mapped to a human-readable name viaHexColorNamehelper) andbrand_descriptionTemplateImageGeneratorsimplified: every slide is Template A (full-bleed photo + bottom gradient + white/grey text). Template B,renderTemplateB,roundCorners,blendHex,ensureContrastand the closing slide all gonePost creation flow
StreamPostCreationcreates thePostdirectly and dispatchesPostCreationReadywithpost_idfinalizeendpoint removedMediaItemJSON gains optionalsource(ai/unsplash/giphy) andsource_meta(regen recipe). Gallery picker tags Unsplash/GiphyBrand surface
Workspace.image_stylefield + visual picker shared by/workspaces/createand/settings/workspace/brandvia singleBrandForm.vuecomponent (autofill is a prop)CssColorFrequencyExtractorparses 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--primaryis exposed (e.g. Clinyx)Credits
gpt-image-2metered at 15 credits/image (low quality default)Layout
AuthSplitLayoutright column nowsticky h-svhso form growth doesn't stretch the marketing sliderCarry-overs from the original PR scope
useWorkspaceEcho/usePostEchocomposables and realtime list refreshCleanup
App\Services\Unsplash\UnsplashClient(slide-only client; galleryUnsplashServicestays)Test plan
source: ai+source_metainposts.mediaimage_stylein workspace settings → saves and is reflected on next generationsource: unsplash+source_meta.photo_id