Upload Components

<file-upload> and <image-upload> are the two upload controls Reepolee ships. Unlike <date-input> and <markdown-editor> - true custom elements registered with customElements.define() - these are ReeTag components: a components/*.ree file renders the wrapper markup, and a small vanilla script in static/ (file-upload.js / image-upload.js) gives that markup its behavior. The component template includes its own <script> tag at the end, so the behavior arrives with the component - there's nothing to wire up in your layout.

Both upload a file asynchronously the moment it's picked (no form submit), store the returned URL in a hidden <input>, and dispatch bubbling input / change events so the surrounding form and any signal-bound state see the value. The server-side story - the save endpoints, S3/disk storage, the /system/files library, and the _file / _image domain types - is on File Uploads.

<file-upload name="contract_file" label="Contract" folder="contracts" module="sales"></file-upload>

<image-upload name="avatar" label="Avatar" folder="avatars"></image-upload>

The Init Pattern

Each script is an IIFE that runs on DOMContentLoaded (or immediately if the document is already loaded) and scans for its wrapper class:

document.querySelectorAll(".file-upload-wrapper").forEach(init_wrapper);
// or .image-upload-wrapper

init_wrapper() guards against double-init with a dataset flag (fuInit / iuInit) - important because the same component can be rendered more than once on a page (for example in every row of a localized-form panel). Each wrapper is initialized independently, so multiple uploads on one page don't interfere.

The wrapper's data-* attributes carry the configuration the script reads: data-name, data-folder, data-module, and the translated message strings in data-msg-invalid-type / data-msg-uploaded / data-msg-failed (filled from the errors.* / messages.* translations at render time - see Translations).

The Async Upload Flow

When a file is chosen (or dropped), upload_file() runs:

  1. Builds a FormData body with the hidden CSRF token, the file (under the file / image field name, keeping the original filename), original_filename, and any folder / module from the attributes. Image uploads also send keep_original=0.
  2. POSTs to the save endpoint with redirect: "manual" - POST /system/files/save for <file-upload>, POST /system/images/save for <image-upload> (the same endpoint the full image editor uses).
  3. Handles auth redirects - an opaqueredirect or status 0 response means the session expired and the request was redirected to login, so it's treated as a failure rather than parsed as a result.
  4. Parses the JSON response - any non-OK status surfaces the server's message; success must include an s3_url.
  5. Writes the result into the hidden input - hidden.value = result.s3_url, plus the db_id into hidden.dataset.dbId, then dispatches bubbling input and change events so FormController and signal-bound state pick up the new value.
  6. Updates the UI - the filename (or image preview), a status line ("Uploaded" in green, or the failure message in red), and a spinner while the request is in flight.
// simplified shape of the success path
hidden.value = result.s3_url;
hidden.dispatchEvent(new Event("input", { bubbles: true }));
hidden.dispatchEvent(new Event("change", { bubbles: true }));

While the upload is in flight the dropzone gets pointer-events-none and a spinner overlay, so a double-pick can't fire two uploads for the same field.

The Dropzone

Both components share the same dropzone interactions:

  • Click - opens the file picker.
  • Keyboard - the dropzone is tabindex="0"; Enter or Space opens the picker, so the control is usable without a mouse.
  • Drag-and-drop - dragover highlights the dropzone (blue border + tint), dragleave / drop remove the highlight, and the dropped file uploads directly. The same upload_file() path runs whether the file came from the picker or a drop.

The hidden <input type="file"> is reset after every pick (file_input.value = ""), so choosing the same file twice fires a change again.

file-upload

The document-upload control. Its accept filter is accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.csv,.zip" - document types up to 25 MB (server-enforced). Beyond the shared flow:

  • Folder handling - a static folder attribute, or an editable folder field when show-folder="true". The script resolves the current folder from the folder input (if present) at upload time, so the value the user typed is what gets used.
  • Filename display - shows the stored file's basename (value.split("/").pop()) once uploaded, and restores the placeholder when cleared.
  • fu:sync event - the hidden input listens for a custom fu:sync event and re-renders the filename from the current value. Firing fu:sync refreshes the filename when the value changed outside the component (for example via the localized-form panels).
AttributePurpose
nameHidden-input name (what your form submits)
valueExisting stored path, to pre-fill on edit
labelLabel shown above the dropzone
folderStorage folder (namespaces the S3 key), e.g. contracts
show-folder"true" renders an editable folder field
folder-inputID of an external input to read the folder from instead
moduleRequires this module permission before the upload is accepted

Its root-namespace translation keys: labels.folder, errors.file_upload_invalid_type, messages.file_upload_uploaded, errors.file_upload_failed, and the dropzone copy ui.file_upload_click / ui.file_upload_or / ui.file_upload_drag_drop / ui.file_upload_here.

image-upload

The image-field control, used for avatar-style and per-record images. Its accept filter is accept="image/*", and - unlike <file-upload> - it validates the MIME type client-side (file.type.startsWith("image/")) before uploading, showing errors.image_upload_invalid_type if the picker was bypassed. Beyond the shared flow:

  • Preview - shows the current image in a <img> preview, swapping to the newly uploaded s3_url on success and restoring the placeholder when cleared.
  • keep_original=0 - sent with every upload; the image pipeline processes the upload into its display variants (see Image Processing).
  • iu:sync event - the hidden input listens for a custom iu:sync event and re-renders the preview from the current value, mirroring file-upload's fu:sync (same refresh-when-changed-externally use case).
  • disabled / localized-value - the hidden input can be disabled or marked data-localized-value for localized-form panels, where the value is per-locale.
AttributePurpose
nameHidden-input name (what your form submits)
valueExisting stored image path, to pre-fill on edit
labelLabel shown above the dropzone
folderStorage folder (namespaces the S3 key), e.g. avatars
moduleRequires this module permission before the upload is accepted
localized-value"true" marks the value as per-locale (localized-form panels)
disabled"true" disables the hidden input

Its root-namespace translation keys: errors.image_upload_invalid_type, messages.image_upload_uploaded, errors.image_upload_failed, and the dropzone copy ui.image_upload_click / ui.image_upload_or / ui.image_upload_drag_drop / ui.image_upload_here.

Without JavaScript

Both components degrade gracefully: the hidden <input> still holds the existing value and submits with the form, and a plain <input type="file"> in a regular multipart form remains the documented no-JS upload path (see File Uploads → Without JavaScript). What the scripts add is the immediate async upload, the drag-and-drop surface, and the in-place status/preview feedback - not the ability to upload at all.