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:
- Builds a
FormDatabody with the hidden CSRF token, the file (under thefile/imagefield name, keeping the original filename),original_filename, and anyfolder/modulefrom the attributes. Image uploads also sendkeep_original=0. - POSTs to the save endpoint with
redirect: "manual"-POST /system/files/savefor<file-upload>,POST /system/images/savefor<image-upload>(the same endpoint the full image editor uses). - Handles auth redirects - an
opaqueredirector status0response means the session expired and the request was redirected to login, so it's treated as a failure rather than parsed as a result. - Parses the JSON response - any non-OK status surfaces the server's message; success must include an
s3_url. - Writes the result into the hidden input -
hidden.value = result.s3_url, plus thedb_idintohidden.dataset.dbId, then dispatches bubblinginputandchangeevents soFormControllerand signal-bound state pick up the new value. - 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 -
dragoverhighlights the dropzone (blue border + tint),dragleave/dropremove the highlight, and the dropped file uploads directly. The sameupload_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
folderattribute, or an editable folder field whenshow-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:syncevent - the hidden input listens for a customfu:syncevent and re-renders the filename from the current value. Firingfu:syncrefreshes the filename when the value changed outside the component (for example via the localized-form panels).
| Attribute | Purpose |
|---|---|
name | Hidden-input name (what your form submits) |
value | Existing stored path, to pre-fill on edit |
label | Label shown above the dropzone |
folder | Storage folder (namespaces the S3 key), e.g. contracts |
show-folder | "true" renders an editable folder field |
folder-input | ID of an external input to read the folder from instead |
module | Requires 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 uploadeds3_urlon 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:syncevent - the hidden input listens for a customiu:syncevent and re-renders the preview from the current value, mirroringfile-upload'sfu:sync(same refresh-when-changed-externally use case).disabled/localized-value- the hidden input can be disabled or markeddata-localized-valuefor localized-form panels, where the value is per-locale.
| Attribute | Purpose |
|---|---|
name | Hidden-input name (what your form submits) |
value | Existing stored image path, to pre-fill on edit |
label | Label shown above the dropzone |
folder | Storage folder (namespaces the S3 key), e.g. avatars |
module | Requires 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.