Commit graph

997 commits

Author SHA1 Message Date
Paulo Castellano
e7f93f4aef refactor(ai-create): clarify prompt-rule naming and label the counter
- Rename AiPromptRules::promptRule() to wizardPromptRule() so the asymmetry is
  explicit: only the create wizard carries a minimum; the editor's generation
  reuses just the shared maximum.
- Add aria-live and a data-testid to the prompt counter so the over-limit state
  is announced to assistive tech and reachable from browser tests.
2026-07-17 11:26:15 -03:00
Paulo Castellano
c432034dd1 test(ai): guard prompt max on the editor endpoint and the wizard min boundary
- Assert GeneratePostContentRequest rejects a prompt over the shared max, so the
  editor's limit is pinned explicitly (not only implied by the create wizard).
- Assert the create wizard accepts a prompt at exactly the minimum length,
  complementing the below-minimum rejection.
2026-07-17 11:08:50 -03:00
Paulo Castellano
3dbb8e6f7e fix(ai-create): sync the prompt limit across front and back
The counter added earlier drifted from the backend in two ways: it counted
UTF-16 code units over the raw (untrimmed) value, while the backend measures
Unicode characters (mb_strlen) over the trimmed value that is actually sent —
so emoji or trailing whitespace could falsely turn the counter red and block
the button. The 2000 limit was also copied into three places, and the wizard's
frontend `>= 3` minimum had no backend counterpart.

- Add App\Support\AiPromptRules as the single source of truth for the prompt
  bounds; both StartPostCreationRequest and GeneratePostContentRequest use it.
- Add min:3 to the create wizard endpoint so front and back agree (the editor's
  generate-content flow keeps `required` — it has no counter to mirror).
- Count code points over the trimmed value in AiPostWizard so the counter and
  the submit gate match what the backend validates, matching AltTextDialog.
- Cover min/max/boundary in PostAiCreateTest.
2026-07-17 11:03:41 -03:00
Paulo Castellano
a9628b7e8d
Merge branch 'main' into feat/ai-prompt-char-counter 2026-07-17 10:46:44 -03:00
Paulo Castellano
67cf43a5f2
Merge pull request #123 from trypostit/feat/story-blurred-background
feat(instagram): auto-fit story images to 9:16 with blurred background
2026-07-17 10:45:28 -03:00
Paulo Castellano
af9736642c Render the story background as a soft, lightened blur
- Rewrite fitToCanvas to build the blurred story background with Imagick: scale the image to fill the width, heavily gaussian-blur it so shapes dissolve into a colour wash, gamma-lighten it, and mirror the top half onto the bottom for a symmetric background; the foreground is contained (fills the width, never cropped). Falls back to a GD downscale-blur on hosts without ext-imagick.
- Clean up the fit temp file if the blur/encode step throws.
- Update the editor preview (VerticalMediaCanvas) to a matching mirrored, lightened blur so it tracks the publish output.
- Cover the lightened image-derived background, the vertical mirror, and the GD fallback path with unit tests.
2026-07-17 10:31:35 -03:00
Paulo Castellano
e1acdab2e3 Revert Facebook and YouTube preview refactors (video-only, no feature effect)
These previews are video-only, so routing them through VerticalMediaCanvas changed nothing; keep the shared canvas on Instagram (Story) and TikTok (Photo) where it actually fits off-ratio images.
2026-07-16 20:51:07 -03:00
Paulo Castellano
2722effba9
Merge branch 'main' into feat/story-blurred-background 2026-07-16 20:34:00 -03:00
Paulo Castellano
2d63de7a97 Address review: dedupe memory-budget logic, fix stale doc, cover crop errors
- Extract estimatedDecodeMemory() + a MAX_DECODE_MEMORY_BYTES constant so optimizeImage (fallback) and the crop/fit guard (throw) share one estimate instead of duplicating the formula and threshold.
- Correct the cropFailureException docblock: it now covers download, crop, and story-fit failures, not just downloads.
- Add a Facebook crop process-failure test and an Instagram cropped-temp-leak test so the crop path matches the fit path's error coverage.
2026-07-16 20:28:21 -03:00
Paulo Castellano
90ce205763 Harden Instagram story image fitting and cover it end to end
- Guard MediaOptimizer::fitToCanvas and cropToAspectRatio against huge-dimension sources (getimagesize budget check) so they fail cleanly instead of exhausting GD memory.
- Fit and crop now translate decode/process failures into a clean InstagramPublishException and remove their temp files via finally; the two paths are symmetric.
- publishStory reads the story canvas dimensions via data_get.
- Previews: restore the IG empty-state background, align the autoFitsImage suppression predicate to isImage, drop the explanatory comment, and use single-quote imports.
- Tests: real end-to-end story fit, undecodable/download/container failures, the memory guard (fit and crop), temp-file cleanup, blurred-background pixel assertions, and the aspect-ratio warning suppression.
2026-07-16 20:09:43 -03:00
Paulo Castellano
3e8b351872
Merge pull request #179 from trypostit/feat/media-alt-text
Add per-image alt text for accessibility across 9 platforms
2026-07-16 16:12:16 -03:00
Paulo Castellano
949dfb5143 Preserve media alt text when regenerating an AI image
The regenerate job replaced the media item without carrying its meta, silently dropping the user's alt text (and slide metadata) from the persisted post.

Copy meta from the freshly-locked post row (not the pre-render snapshot) inside the transaction, so an alt edit made while the multi-second render runs is kept rather than overwritten.
2026-07-16 16:07:24 -03:00
Paulo Castellano
cf045f2cdb Harden per-image alt text across publishers, validation, and attach paths
Publishing:
- Only send alt text for images (isImage guards on LinkedIn, X, Discord, Mastodon); never inject altText into video/document payloads.
- X sets alt via a best-effort media/metadata call so a metadata failure no longer blocks the tweet.

Validation:
- Validate media alt_text with a closure on media.*.meta so width/height/duration/slide_* survive a post update (Laravel's excludeUnvalidatedArrayKeys was stripping them).
- Add ALT_TEXT_MAX_LENGTH constant, a proper string-type error, and a localized attribute name.

Media attach (REST + MCP):
- Support per-image alt on attach-media-from-url via structured urls: [{url, alt?}] and on the MCP upload tool via an optional alt; alt is stored only for images.
- Carry submitted meta onto hosted external-URL media so alt is no longer dropped.

Composer:
- Alt-text dialog disables Save and reddens the counter over the limit, counting code points of the trimmed value to match the backend.
- Autosave shows 'Saved' only on a successful response; the lightbox alt overlay renders for images only.

Adds unit, feature, MCP, and browser tests covering every path above.
2026-07-16 13:55:33 -03:00
Paulo Castellano
a1f2fea1bc Merge the alt-text setter into the save handler 2026-07-13 08:54:25 -03:00
Paulo Castellano
b5dfc37022 Show alt text inside the image lightbox (opt-in) 2026-07-11 13:09:34 -03:00
Paulo Castellano
e61d12ea94 Show only the alt text on the thumbnail, without the icon 2026-07-11 13:01:02 -03:00
Paulo Castellano
797750a1c8 Show the alt text on the thumbnail and drop the file-size badge 2026-07-11 12:49:56 -03:00
Paulo Castellano
6b39616be2 Cover the composer alt-text round-trip with a browser test 2026-07-11 10:52:26 -03:00
Paulo Castellano
3397b829c2 Show the alt-text length limit in the editor dialog 2026-07-11 10:49:31 -03:00
Paulo Castellano
f74053b118 Extract alt-text truncation onto MediaItem and close test gaps
Centralize the per-platform mb_substr truncation that every publisher was
repeating into MediaItem::altTextFor(Platform), delete each publisher's
private altFor() helper, and clarify the Platform::altTextMaxLength()
docblock so it doesn't imply Instagram's documented 1000-char cap is a
guess. Add the assertions review flagged as missing: LinkedInPage/
InstagramFacebook alt-text caps, non-string and literal-"0" alt_text
normalization, altTextFor() truncation/unsupported-platform behavior,
Mastodon's no-description-part case, and the public API's accept/reject
path for media.*.meta.alt_text.
2026-07-11 10:34:57 -03:00
Paulo Castellano
0e93d7e5e3 Normalize alt-text null checks across publishers
Use an explicit `!== null` guard in the Facebook, Instagram, Threads, and
Discord publishers instead of a truthy `if ($alt = ...)`, matching the
Pinterest/X/Mastodon style. The truthy form dropped a literal "0" alt; the
explicit check treats every non-null description uniformly. Also add the
missing PHPDoc to Platform::supportsAltText().
2026-07-11 10:02:50 -03:00
Paulo Castellano
8d7948cb2e Add per-image alt text editor to the post composer 2026-07-11 09:45:52 -03:00
Paulo Castellano
f12f525d54 Publish per-image alt text to Discord attachments 2026-07-11 09:34:57 -03:00
Paulo Castellano
6115362c07 Read Pinterest pin alt text from the image, not per-platform meta 2026-07-11 09:29:39 -03:00
Paulo Castellano
b1162cb67a Publish per-image alt text to Threads containers 2026-07-11 09:25:07 -03:00
Paulo Castellano
9a86aba235 Publish per-image alt text to Instagram image containers 2026-07-11 09:18:58 -03:00
Paulo Castellano
2c81416bd0 Publish per-image alt text to Facebook photos 2026-07-11 09:13:05 -03:00
Paulo Castellano
941e517b2e Publish per-image alt text to LinkedIn images and carousels 2026-07-11 09:08:15 -03:00
Paulo Castellano
65b5007784 Publish per-image alt text to Mastodon media description 2026-07-11 09:02:26 -03:00
Paulo Castellano
4beced64a8 Publish per-image alt text to X via media metadata 2026-07-11 08:56:42 -03:00
Paulo Castellano
555957de61 Publish per-image alt text to Bluesky image embeds 2026-07-11 08:50:13 -03:00
Paulo Castellano
521cfca724 Validate per-image media alt_text (max 2000) in PostMediaRules 2026-07-11 08:46:09 -03:00
Paulo Castellano
603ac84b5f Add MediaItem::altText() accessor for per-image alt text 2026-07-11 08:41:10 -03:00
Paulo Castellano
30a94341f9 Add Platform::altTextMaxLength() as the alt-text cap source of truth 2026-07-11 08:38:42 -03:00
Paulo Castellano
eee0a4a499
Merge pull request #176 from trypostit/feat/paid-first-month-checkout
Charge $1 for the first month instead of a free trial
2026-07-09 16:07:49 -03:00
Paulo Castellano
2c1cd4ec61 Ignore never-started subscriptions when detecting a first-time customer
A raw subscriptions()->exists() check treated a leftover incomplete /
incomplete_expired row — which Cashier persists when a first payment
fails or a 3DS challenge is abandoned — as a prior subscription, so a
genuinely new customer retrying after a failed first attempt lost the
$1 coupon and was charged full price. Exclude those never-started
statuses so only a subscription that actually started (active, canceled,
etc.) marks the account as a returning customer.
2026-07-09 16:03:25 -03:00
Paulo Castellano
3dd4621f57 Limit the $1 first month to a new customer's single workspace
The fixed amount_off coupon only nets $1 for a quantity of one, and the
offer is meant for genuinely new signups. A lapsed account that kept
several workspaces and re-subscribes through onboarding would otherwise
be charged N*price - amount_off (not $1), and a returning customer could
whittle down to one workspace to claim the discount again.

Apply the coupon only when the paid first month is enabled, the account
bills a single workspace, and it has never subscribed before; every other
checkout falls back to allowing promotion codes at the full price.
2026-07-09 15:51:50 -03:00
Paulo Castellano
f36881cb6a Fail loud on a missing coupon and block unpaid extra workspaces
Two hardening fixes for the paid first month:

- FirstMonthCheckoutDiscount throws when the paid first month is enabled
  but STRIPE_FIRST_MONTH_COUPON_ID is unset, instead of silently charging
  every new customer the full price with no discount.
- Guard workspace store() with the same active-subscription check create()
  already applies, so a direct POST can't bootstrap a second billable
  workspace and inflate checkout quantity past the fixed first-month coupon.
2026-07-09 15:37:25 -03:00
Paulo Castellano
c940826031 Charge $1 for the first month instead of a free trial at checkout
A $0 trial only runs a weak card authorization, so invalid cards slip
through and only fail once the real charge fires days later. Applying a
$11-off, duration=once Stripe coupon at checkout charges $1 for real on
the first invoice instead, which validates the card immediately, then
reverts to the full monthly price on the next invoice with no manual
swap needed.
2026-07-09 15:18:00 -03:00
Paulo Castellano
c74170a25a
Merge branch 'main' into feat/story-blurred-background 2026-07-07 11:13:59 -03:00
Paulo Castellano
d6c40d425c
Merge pull request #174 from trypostit/feat/avatar-logo-crop-native
Crop the avatar/logo before upload with a native selection cropper
2026-07-04 16:16:45 -03:00
Paulo Castellano
44127c4890 Remove the Vitest frontend unit-test layer
The project's test strategy is backend + e2e only, so drop the Vitest setup that had been added for the crop math: the imageCrop.test.ts suite, vitest.config.ts, the dependency and test:unit script, the CI step, and the test:all reference. The crop math is exercised through the Pest browser test.
2026-07-04 10:53:08 -03:00
Paulo Castellano
de75ef4703 Bump release-docker actions to Node 24
Move checkout, upload-artifact, and download-artifact to their current majors so the release image build stops relying on the deprecated Node 20 runtime. Docker actions are left as-is (not flagged). Only runs on release tags, so it is not exercised by PR CI.
2026-07-04 10:35:36 -03:00
Paulo Castellano
45d0dbf510 Bump CI actions to Node 24 and shard the e2e job
Move actions/checkout, setup-node, cache, and upload-artifact to their current majors (Node 24 runtime). Run the browser suite as a sharded matrix aggregated by an e2e-gate job, so the required status check stays a stable name as the shard count scales. Vitest runs once (shard 1). Ready for the incoming e2e test expansion.
2026-07-04 10:28:17 -03:00
Paulo Castellano
2d72d74cc8 Split CI into parallel backend and e2e jobs
Extract the shared PHP/Composer/Node/database/Passport setup into a composite action, and run the backend suite (Unit + Feature) and the frontend/e2e suite (Vitest + Playwright browser tests) as two parallel jobs. This isolates the flaky browser suite so it can be rerun on its own, surfaces failures by area, gives the growing e2e suite room to shard, and uploads Playwright artifacts on failure.
2026-07-04 10:14:01 -03:00
Paulo Castellano
ef81ea74a8 Add frontend unit tests and harden the cropper's test coverage
Add Vitest (test:unit) with exact-value tests for the crop math (containScale, defaultSelection, clampSelection, all resizeSelection corners, and mime/filename resolution), and wire it plus the Pest browser test into CI. The browser test now samples output pixels against a four-quadrant fixture, so it detects a blank, flipped, or wrong crop rather than only asserting a 512x512 canvas. Add composer test:all to run PHP + Vitest + browser tests locally.
2026-07-04 09:56:38 -03:00
Paulo Castellano
36ccc570e0 Rewrite the image cropper as a movable/resizable selection box
Replace the move-image-behind-a-fixed-frame model with a selection box over the fully visible (contain-fit) image: drag to move, corner handles to resize, locked to 1:1 for the square avatar/logo output. The default selection is inset so the crop outline is always visible, handles sit inside the frame so they are never clipped, and wheel/pinch zoom is swallowed. Reset drag state on open/close, guard pointer capture and multi-touch, and fail-safe the canvas encode. Update the crop copy in all 15 locales to match the selection model.
2026-07-04 09:56:31 -03:00
Paulo Castellano
2549a82e9b Address review: cropper mime/zoom/error fixes, harden browser test
- Output a canvas-encodable mime (jpeg/png/webp, else png) and keep the File's
  name/extension in sync, so non-encodable input (gif/svg/heic) no longer ships
  PNG bytes mislabeled as the original type.
- Clamp zoom to a maximum (8x cover) so scrolling in can't collapse the crop to
  a sub-pixel region.
- Handle undecodable/zero-dimension images (@error + naturalWidth guard) with a
  crop_error message instead of a permanently-disabled Save.
- Replace the str_contains(static::class) Vite heuristic with a dedicated
  BrowserTestCase ($fakesVite = false).
- The browser test now decodes the dispatched blob and asserts a 512x512 image,
  and uses route(..., absolute: false) instead of a hardcoded path.
- Remove tests/Browser/ProbeTest.php (committed debug scratch).
2026-07-03 22:07:23 -03:00
Paulo Castellano
c6369a6ddb Crop the avatar/logo before upload with a dependency-free cropper
Selecting an avatar or workspace logo now opens a crop dialog (drag + zoom)
before uploading, so the image is framed the way it renders. The crop is
performed client-side and the resized 512x512 result is what gets uploaded.

This reworks the idea from #131 without its cropper dependency: vue-advanced-cropper
was last released ~2 years ago and we did not want an unmaintained package for
something this load-bearing. What we need is narrow (fixed 1:1, a circle/square
mask, fixed-size output), so a small canvas-based cropper covers it:

- imageCrop.ts: pure transform math (cover-fit, clamp, zoom, viewport->source).
- ImageCropperDialog.vue: CSS-transform preview, pointer drag, wheel/button zoom,
  a ResizeObserver to measure the modal (no requestAnimationFrame timing hacks),
  and a canvas toBlob only on save.
- PhotoUpload.vue: opens the cropper on file select; the mask shape follows the
  display shape (round avatar / square logo) instead of always being round.
- crop_* strings added to all 15 locales.

Also installs Pest browser testing (pest-plugin-browser + Playwright) and adds a
browser test for the crop flow. TestCase only calls withoutVite() for non-browser
tests, since browser tests need the real Vite assets to boot the SPA. The Pest
browser server does not parse multipart uploads, so the test asserts the crop
dispatches the correct upload request; endpoint persistence stays covered by
ProfileUpdateTest.
2026-07-03 21:46:19 -03:00
Paulo Castellano
f609073b9a
Merge pull request #173 from trypostit/fix/autofill-save-logo
Save the autofilled site logo as the workspace logo on brand settings update
2026-07-03 20:19:08 -03:00