* fix: give each chunked upload attempt a unique server-side identifier
The upload session identifier was derived only from user+filename+size
(ChunkedAssetReceiver::receive), with no per-attempt nonce. Two genuinely
concurrent attempts of the same file (e.g. closing and reopening the media
picker mid-upload, then re-uploading the same file) collided on the same
Redis cache key / temp file, producing RuntimeException("Chunked cloud
upload session expired or missing.") on the multipart/cloud path and
silent byte corruption on the local-assemble path.
The frontend now mints a UUID per upload attempt (X-Upload-Id header) that
gets folded into the identifier. Falls back to the old formula when the
header is absent, so any already-loaded frontend bundle keeps working.
Also guards the media picker's dropzone against re-triggering an upload
while one is in flight, and aborts the in-flight fetch when the dialog
unmounts mid-upload.
Fixes Nightwatch issue #23.
* fix: explicitly type upload_id when passing to receive()
Matches the existing explicit (int) casts on the sibling validated()
calls in the same method — validated() returns mixed, so this keeps
the nullable-string contract explicit instead of relying on an
implicit runtime type.
* style: inline the upload_id null-safe cast
Drop the intermediate variable so all receive() arguments read as a
single expression each, matching the sibling validated() casts.
* fix: require X-Upload-Id instead of falling back to the legacy identifier
Nullable upload_id only preserved the old (collision-prone) formula for
clients that omit the header — it didn't actually protect them. Making it
required closes that gap outright: a request without the header now fails
loud (422) instead of silently falling back to the vulnerable identifier.
ChunkedAssetReceiver::receive() now takes a required $attemptId. Updated
every existing test hitting app.assets.store-chunked (ChunkedCloudUploadTest,
ChunkedAssetReceiverTest, ChunkedUploadFilenameEncodingTest, AssetControllerTest)
to send a real upload id, and added a regression test asserting the endpoint
rejects a request with no X-Upload-Id header.
* fix: localize hardcoded workspace name validation messages
StoreWorkspaceRequest had its custom messages() hardcoded in pt-BR
regardless of the user's locale; UpdateWorkspaceRequest had the same
bug hardcoded in English. Both now go through __('validation.required'
/ 'validation.max.string') with the already-localized
workspaces.create.name attribute label (present in all 16 lang/
directories), matching the pattern already used by
StoreWorkspaceInviteRequest.
Unrelated to the chunked upload fix, but caught while reviewing this
file's messages() convention.
* simplify: drop messages() override on workspace name validation
Laravel already localizes the generic required/max messages from
lang/{locale}/validation.php automatically — no need to hand-roll
messages() for standard rules with no custom copy.
* fix: localize StoreChunkedAssetRequest validation messages
Drop the hardcoded English messages for required/ends_with rules —
Laravel's own localized validation.php messages already cover them
adequately (ends_with's generic message is actually more useful, since
it lists the accepted extensions). total_size.max still needs a custom
message (the rule is in raw bytes, unreadable without MB conversion),
so it now goes through __('assets.upload.file_too_large') with the key
added to all 16 lang/ locales.
Also fixed test flakiness discovered while touching this file:
ChunkedCloudUploadTest used random_bytes() for the first mp4 chunk,
which occasionally collides with an unrelated magic number (MZ/PE,
SIMH tape, ...) and makes finfo misdetect the mime type. Replaced with
real mp4 header bytes padded with nulls, so detection is deterministic.
* fix: address final code review findings
- ChunkedAssetReceiver: use double-quoted interpolation instead of
concatenation for the identifier hash, per project convention.
- AssetControllerTest: two chunked-upload rejection tests didn't send
X-Upload-Id, so their 422 assertions could pass for the wrong reason
(upload_id.required) instead of the field they claim to cover. Added
the header and asserted the specific validation error field.
- GalleryBrowser: centralize the upload-in-progress guard as a single
check at the top of uploadFiles() instead of three separate checks
at each entry point (click/select/drop) — matches the single-source-
of-truth pattern already used in PhotoUpload.vue.
- GalleryBrowser: show a toast when an in-flight upload is aborted
(dialog closed mid-upload) instead of silently discarding it with no
feedback. New assets.upload.cancelled key added to all 16 lang/
locales.
The uploads grid is client-fetched, so an Inertia delete with preserveState left the stale list on screen until a hard refresh.
Co-authored-by: Cursor <cursoragent@cursor.com>
Connect flow:
- surface OAuth failures: useOAuthPopup forwards {success, message}; the
accounts grid toasts the error instead of silently reloading
- detect a blocked popup and toast a "allow popups" message
- PopupCallback only promises auto-close when it has an opener, otherwise
tells the user to close it; adds role=status/aria-live and aria-hidden icons
- drop the dead LinkedInPage connect-route entry and the unsent access_token
field on the Facebook page-select type
Media/gallery:
- delete dead code: MediaDropzone.vue and useDragAndDrop.ts (no references)
- GalleryBrowser toasts per-file upload failures instead of swallowing them;
cards are always clickable now that videos open in the lightbox
- formatDurationMs rounds the sub-second branch and tolerates undefined
- advertise PDF in the gallery's accepted-formats hint
i18n: popup_blocked, manual_close (accounts) and upload.failed (assets) in all locales.
ImagePreviewDialog now renders documents via an <iframe> (the browser's
native PDF viewer) alongside images and videos. The post view, gallery
browser, and editor composer no longer divert PDFs to a new tab or strip
them from the collection — a PDF opens in the dialog and navigates
(prev/next) together with the other media.
Mirror the backend App\Enums\Media\Type on the frontend so "is this an
image/video/PDF?" has a single definition instead of being hand-written
(type === 'x' || mime_type startsWith ...) across a dozen files.
- add lib/mediaType.ts: MediaType union, classify(), fromMimeType(),
fromExtension(), isImage/isVideo/isDocument/isGif, acceptAttribute()
- route every MediaItem detection and every File-upload validator through it
- fix MediaDropzone rendering a typeless image item (AI/Unsplash/Giphy)
as a broken <video> via the img-vs-video v-else
- collapse getMediaIncompatibilityReason onto the shared
getMediaValidationWarning core (canSchedule / variant selection unchanged)
- tighten MediaItem/AssetMedia/SavedMedia/PickedMedia .type to MediaType
- replace GalleryBrowser's hardcoded accept list with acceptAttribute()
Selecting media in the gallery dropped original_filename, size, and meta when building the picked item, so they never reached the post. Since the gallery is the only way to add media, this left PDF documents without a filename (empty title field, 'PDF' thumbnail label, 'Document' fallback on publish) and skipped size/duration validation for all gallery media. Carry those fields through (guarded for unsplash/giphy items that lack them).
LinkedIn settings module: render only when a PDF is attached (its only relevant config now that format is inferred from media), pre-fill the document title from the PDF filename (editable), drop the redundant media warning (already surfaced on the channel) and the unused contentType prop/binding.
LinkedInSelect: add Person/Organization tag icons. i18n: clearer document-not-alone message in all locales.
Add LinkedIn document posts — the swipeable PDF carousel — for both personal profiles and company pages. This is the format every major competitor exposes via native PDF upload, and the reason a trial user churned.
- New 'document' media type (application/pdf) across the upload pipeline (Type enum, HasMedia, FormRequests incl. chunked, Platform media types)
- New LinkedInDocument / LinkedInPageDocument content types: PDF-only, single-file, with a supportsDocument() flag
- Publisher flow: documents initializeUpload -> PUT -> poll AVAILABLE -> post with content.media.{id,title}; optional document_title meta (falls back to file name)
- PDF is mutually exclusive with image/video, enforced in ContentTypeCompatibleWithMedia
- Frontend: 'Document (PDF)' variant, media rules (100MB cap), composer/gallery/detail PDF cards, real PDF embed in the LinkedIn editor preview, i18n in en/es/pt-BR
- Tests: publishers (personal + page, incl. processing-failure path), enums, compatibility rule, chunked PDF upload, API + MCP document_title round-trip
LinkedIn caps documents at 100MB / 300 pages (Documents API). The page limit is enforced by LinkedIn at publish, not validated client-side.
The /assets page (and the picker dialog that reuses GalleryBrowser)
posted files in a single multipart request, so anything past nginx /
php-fpm's body limit failed with 413. The chunked endpoint and util
already existed but were only wired into an orphan composable.
- GalleryBrowser now always uploads via uploadChunked
- Drop the orphan useMediaManager composable
- Drop shouldUseChunkedUpload (no longer referenced)
- 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').
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.
- Replace global AppHeader slots with in-page headers on Calendar, Edit,
and Show pages — full-width canvas with the page-specific controls
living in their own ink-bordered bar.
- Remove sidebar collapse mechanism: drop Cmd+B keyboard shortcut, force
defaultOpen=true (ignore persisted cookie), make toggleSidebar a no-op
on desktop. Sidebar is permanent on desktop, sheet behavior preserved
on mobile.
- Redesign Tabs to match the design system: each TabsTrigger renders as
a standalone sticker button (border-2 + rounded-md + shadow-xs), with
amber-200 active state mirroring the "POST" badge from post-templates.
- Show page layout: cap preview Card to max-w-xl, preserve natural
aspect for single-image posts (object-contain, max-h-[480px]), drop
redundant stats grid, swap header sides (Back button left, status +
date right with date first).
- Assets: bump GalleryBrowser search inputs to h-12 for comfortable
browsing across uploads, Unsplash, and Giphy tabs.
- Fix PostPlatformMetrics loading-forever bug: hoist useHttp out of the
onMounted callback into setup, and read the response array directly
(backend returns the array at root, not under .data).
- i18n: rename posts.show.back_to_posts → posts.show.back; remove unused
posts.show.summary.* keys.
Mentions in post comments
- @mention autocomplete (workspace members, current user excluded) with
marker syntax @[uuid] persisted, display names rendered via CommentBody
chips; live edit replaces markers with names and converts back on save.
- NotifyMentions action with workspace-scoped membership check, dedupes
same user, only newly-added mentions on update.
- Email + in-app via SendNotification job, respecting per-user
notification_preferences.mentioned_in_comment.
- Heartbeat-based presence (Cache, 60s TTL, 30s ping) so online recipients
get only the in-app notification — no email noise.
- Real-time bell on workspace.{id}.user.{id} private channel
(NotificationCreated event), scoped channel name avoids client-side
filtering and lays out a convention for future workspace channels.
- Mailable localized via lang/{en,es,pt-BR}/mail.php; Maizzle source
template for the email is committed and built into resources/views/mail.
AI generation refactor (Action layer + MCP)
- Extracted Actions/Ai/Generate{Image,Video} with QuotaExhaustedException
so agent tools and MCP tools share a single domain entry point.
- Mcp/Tools/Ai/Generate{Image,Video}Tool registered in TryPostServer; both
return MediaResource payloads.
- Orientation::imageApiSize maps non-OpenAI ratios to 1:1/2:3/3:2.
- config/ai.php is now the single source of truth driven by env, removing
the trypost.ai shim. Default text/image providers flipped to OpenAI.
Settings/UX
- /settings/workspace split into shadcn Tabs (Workspace / Brand / Users)
with three components.
- /assets and the in-editor MediaPicker open the ImagePreviewDialog
lightbox on image click while preserving action button behaviour.
- Comments tab landed via ?tab=comments&comment=<id> from notification
click (scroll-to + temporary highlight).
- Mention autocomplete popover flips above when near the viewport bottom.
- Real social platform PNGs replace Tabler brand glyphs in schedule
pills and post list, with hover tooltip carrying display_name + handle.
Bug fixes
- AcceptInvite: controller now passes workspace + role payload that the
Vue page expects; login/register CTAs preselect the invite email.
- WorkspaceInvite mailable: stopped referencing nonexistent
$invite->workspace and $invite->role; column added to the migration,
Invite model casts role to WorkspaceRole, CreateInvite persists it.
- PostCommentCreated: added broadcastAs so .PostCommentCreated actually
matches the Echo listener; payload now includes mentioned_users so
receivers render the chip correctly without a refetch.
- Preview components for X/Pinterest/Threads/Bluesky/LinkedIn/Mastodon/
TikTok/YouTube switched from item.type === 'image' to
!isVideoMedia(item) so media without a persisted type still renders.
- UpdatePostRequest now accepts media.*.{type,mime_type,size,...} so the
posts.media JSON keeps the metadata that the previews need.
- Removed throttle:6,1 from social connect routes (was 429ing legitimate
OAuth retries).
- Used MediaType enum cases instead of literal 'image'/'video' strings
when creating media rows.
Tests
- MentionParser unit tests, NotifyMentions feature tests including
online/offline channel selection and preference gating, MCP AI tool
happy paths, MentionedInComment mailable rendering, AcceptInvite +
search-members + index mentioned_users path. 1229 passing.
- gallery: extract /assets tabs (uploads, Unsplash, Giphy) into shared
GalleryBrowser used by both /assets and a new MediaPickerDialog inside the
post editor; add JSON search endpoint for workspace assets with tests
- emoji: replace broken emoji-picker-element web component with a custom
EmojiPicker (full Unicode set, search, categories, recently-used,
light/dark, i18n)
- preview tab: platform selector pills, variant tabs (data-driven from
content_types map) so the user can switch Feed/Reel/Story etc. and have
it autosave through the same handler ScheduleTab uses
- platform logos: shared usePlatformLogo composable (logo + label + content
types); replaces inline maps across 5 components, fixes
instagram-facebook falling back to default.png
- tooltips: hover details (display_name · @username + platform label) on
platform avatars across editor, posts list and calendar
- settings cards: show ` · @username` in the title bar so multiple accounts
on the same network are distinguishable
- routes: drop the throttle:6,1 group middleware on social connect routes
(was 429ing legitimate OAuth retries) and rely on the default limiter