Skip to content

feat: content engine file fields - #752

Draft
aXenDeveloper wants to merge 5 commits into
canaryfrom
feat/content-engine-file-fields
Draft

feat: content engine file fields#752
aXenDeveloper wants to merge 5 commits into
canaryfrom
feat/content-engine-file-fields

Conversation

@aXenDeveloper

Copy link
Copy Markdown
Owner

Improving Documentation

Description

What?

Why?

aXenDeveloper and others added 2 commits August 21, 2026 10:41
`field.file()` stores one file by reference: the column is an `integer`
foreign key into `core_files` with `ON DELETE RESTRICT`, and nothing about
the file is copied onto the content row.

- `maxBytes` is mandatory - there is intentionally no unlimited file field,
  and a zero, negative, fractional or infinite value is a definition-time
  error.
- `allowedExtensions` and `allowedMimeTypes` are two independent rules; with
  both set, both must match, so a PNG renamed to `.gif` is refused.
  Extensions normalise, so `GIF`, `.gif` and `.Gif` are one rule.
- One generated multipart route per content type,
  `POST /admin/content/{module}/uploads/{field}`, driven from the browser by
  TanStack Query. No binary ever crosses a Server Action; the content
  mutation stays JSON carrying the identifier.
- A save re-validates the referenced `core_files` row, so uploading a PDF
  for one field cannot assign it to a GIF-only one.
- `StorageModel.deleteFile` now deletes the row first and the blob second:
  still referenced answers 409 `FILE_IN_USE` with the bytes intact.
- Retained revisions pin the files their snapshots name in
  `core_content_file_refs`, so an old revision stays restorable and pruning
  releases the file through the cascade.
- Reusable `AutoFormFile` with drag & drop, replace, remove, image preview
  and a constraint line that always shows the allowed formats and maximum
  size - read from the same descriptor the server validates against.
- Blog articles gain a shared `coverImage` and a localized `coverImageAlt`;
  `example.article` gains a GIF-only `animation` field as the strict
  reference.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…s as a dialog

A page-mode form stays mounted across its own saves, but `expectedVersion`
was only ever read once - on mount - and after a conflict was resolved.
So the second save of a session guarded on a version the record had
already left behind, and a solo editor got "someone else saved this
first" about their own previous click.

The translation half of this was already handled: the composite save reads
every translation back so the next one holds their new versions. The base
row's version was simply not reported. `editContentAction` and
`editLocalizedContentAction` now return it, and the form advances on it -
plus on any newer `data.version` the server hands down, which covers a
publish, an unpublish or a restore moving the version while the form is
open. Forwards only, so a stale list row cannot drag the precondition back
and a reloaded conflict cannot be un-resolved.

The notice is now an alert dialog rather than a banner above the fields. A
save that did not happen is not something to notice later, and on a form
long enough to scroll the banner could be off screen entirely - which is
exactly how somebody presses Save, sees nothing, and presses it again. All
three rules survive: nothing typed is discarded (the form stays mounted
behind the overlay), nothing is overwritten automatically, and no field is
merged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented Aug 21, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
vitnode-docs Error Error Aug 21, 2026 7:46pm

@github-actions github-actions Bot added the 💡 Feature A new feature label Aug 21, 2026
`core.content.conflict.desc` was written as
`{name, select, other {record}}` - a select with only an `other` branch, so
it required a `name` argument and then ignored it, always rendering the word
"record". The dialog passed only `{ version }`.

A missing ICU argument is not a blank in next-intl. It is a
FORMATTING_ERROR, and the fallback it renders is the key path - so an editor
whose save had just been refused read the literal string
`core.content.conflict.desc` where the explanation should have been. The one
screen that has to explain itself said nothing.

The message now takes `{name}` and the dialog passes the content type's
singular label, matching every other message in the namespace ("Add a new
{name}.", "{name} has been updated."). The copy also says the save did not
go through, which the old wording never did - it said the record "moved"
and that nothing was lost, leaving "press Save again" as the obvious next
move.

The test asserts the thing that actually broke: that each message's
placeholders are exactly the arguments its call site passes. Reverting
either half of this fix fails it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…again"

`uploadContentFile` read the error body with `response.json()` and fell back
to "The upload failed. Please try again." for anything else. Hono renders an
`HTTPException`'s message as *plain text*, so every failure outside the
route's own body arrived as text, failed the parse, and was replaced by a
sentence that says nothing:

  "File exceeds the maximum size of 5242880 bytes"  -> please try again
  "Unsupported file type: application/pdf"          -> please try again
  "Invalid or corrupt image file"                   -> please try again
  "Storage provider not found"                      -> please try again

The last one is the worst: nothing is wrong with the file, and the editor
retries a misconfiguration for ever.

Three changes:

- The route answers JSON `{ code, message }` for every refusal it owns -
  unknown field, forbidden - and re-shapes the `HTTPException`s escaping
  `StorageModel` into the same envelope, so a corrupt file and a missing
  adapter both arrive as something the browser can read and tell apart.
- The client reads the body in three layers: JSON, then plain text, then the
  status. Plain text was the layer that was missing. `413` gets its own
  sentence because a body the platform rejects never reaches the route, so
  no `maxBytes` check ran and the limit to raise is not the field's.
- The uploader restates the three rules it can say better - too large, wrong
  type, wrong extension - in the reader's own language from the field's own
  limits, and shows everything else verbatim.

`rawApiFetch` also stops discarding the body of a 500: it read
`statusText ?? errorText`, which never fell through, so the only part that
said what went wrong was thrown away on every 500.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

💡 Feature A new feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant