← Laracrate 16 / 19

React and Svelte components

The three upload components also come for React (18 or later) and Svelte 5: Dropzone, DropzoneSingle and Uploader. They render the same markup as the Livewire ones, so laracrate.css, the themes and the appearance props apply as they are, and they put the files in the same page queue (window.Laracrate.uploads), so an upload keeps going when the component unmounts on an Inertia or SPA navigation.

<Uploader target="avatar" layout="portrait" rounded="full" />
<Dropzone target="venue.gallery" params={{ venue: venue.id }} />

Setup

They ship as source in resources/js and your app's Vite builds them, with the React or Svelte plugin it already has. There is no npm package. An alias keeps the imports short:

// vite.config.js
import path from 'node:path';

export default defineConfig({
    resolve: {
        alias: { '@laracrate': path.resolve(__dirname, 'vendor/edulazaro/laracrate/resources/js') },
    },
});
/* resources/css/app.css */
@import '../../vendor/edulazaro/laracrate/resources/css/laracrate.css';
import { Dropzone, DropzoneSingle, Uploader } from '@laracrate/react/index.js';   // React
import { Dropzone, DropzoneSingle, Uploader } from '@laracrate/svelte/index.js';  // Svelte

The endpoints take the CSRF token from <meta name="csrf-token">, as the Livewire components do. If you changed a route prefix, tell the components once with configure({ signUrl, confirmUrl, registerUrl, targetUrl, removeUrl, statusUrl }), exported from the same index.js.

Upload targets

A Livewire component builds its own upload grant on the server. These run in the browser, so the app declares where they may upload, and who may, once, in routes/uploads.php, the way it declares its broadcast channels:

// routes/uploads.php
use App\Models\User;
use App\Models\Venue;
use EduLazaro\Laracrate\Uploads\UploadGrant;
use EduLazaro\Laracrate\Uploads\UploadTargets;

UploadTargets::define('avatar', fn (User $user) => UploadGrant::for($user, 'avatar', replace: true));

UploadTargets::define('venue.gallery', fn (User $user, Venue $venue) =>
    $user->can('update', $venue) ? UploadGrant::for($venue, 'gallery') : null);

UploadTargets::define('venue.cover', fn (User $user, Venue $venue) =>
    $user->can('update', $venue) ? UploadGrant::for($venue, 'cover', replace: true) : null);

The callback gets the signed in user, then the component's params by name: a model by its route key (a slug if the model routes by slug), a scalar cast to its type. It returns a grant, or null to refuse. The params come from the browser: the callback is where the app checks that this user may upload there, as a channel checks who may listen. A param that does not resolve, a guest when the first argument is not nullable (?User $user), or a user of another class refuses the target too.

The file is read the first time a component asks for a target (laracrate.uploads.targets); a service provider can call UploadTargets::define() too. The endpoint lives in the uploads route group, so it takes its middleware (auth by default).

When it mounts, a component reads what to show, GET /laracrate/uploads/targets/{target}?params[venue]=12, with no grant yet:

  • the collection's rules: the mime types for the file picker, the extensions and the largest size for the hint, and the kind of file (image, video, audio, document or mixed) for the icon. The server checks them again on every upload;
  • for a target that replaces its file (replace: true), the current file;
  • the target's scope, to show again the uploads still running for it.

The callback runs there too, so a user it refuses sees why when the component mounts, not after picking a file. The grant is asked for only when the user picks or drops files (or presses Upload in a deferred one), POST /laracrate/uploads/targets/{target}, and used right away: no grant goes out to whoever only looks at the page, and none is ever old. Meanwhile the files wait in the queue with their spinner.

From there it does the rest: it shows the file just uploaded (the upload endpoints answer it whole, as File::toComponentArray()), polls GET /laracrate/files/{slug}/status while it is processed, and its delete button removes it with POST /laracrate/uploads/remove. Only a grant that replaces can remove, and only the current file of its target: that grant deletes it anyway with its next upload.

Dropzone

Several files, as a grid of tiles or a list of rows.

<Dropzone target="case.documents" params={{ case: caseId }} layout="list" maxFiles={10}
    onUploaded={(file) => ...} onBatchCompleted={({ ok, error }) => ...} />

DropzoneSingle

One file in a card: the drop area, or the current file to replace, view or remove.

<DropzoneSingle target="venue.cover" params={{ venue: venue.id }} />

Uploader

One file shown big, an avatar or a logo, in a row or portrait layout. rounded rounds the preview, not the card.

<Uploader target="avatar" layout="portrait" rounded="full" onuploaded={(file) => (me.avatar = file.preview)} />

Props

Svelte takes the same props, with the callbacks in lower case (onuploaded, onbatchcompleted) and class instead of className.

Prop Components Default
target, params all the upload target and its params
deferred all false stage the files until the Upload button, or startBatch()
hideActions Dropzone, DropzoneSingle false no Upload and Cancel buttons: the page calls startBatch()
multiple Dropzone true
maxFiles Dropzone how many files it takes; it closes once it holds them
layout Dropzone, Uploader grid, row grid or list; row or portrait
persistQueue Dropzone false keep the uploaded files listed
removable DropzoneSingle, Uploader true the delete button
pollMs DropzoneSingle, Uploader 1500 how often a file being processed is asked about
accept, maxSizeKb all the target's to show other rules than the collection's
extensions DropzoneSingle the target's shown in the hint
kind DropzoneSingle, Uploader the target's what the collection takes, for the icon and the preview
icon Dropzone, DropzoneSingle the drop area's icon: upload, image, video, audio, document, file
locale, labels all en en or es, and any text of your own: labels={{ upload: 'Add photos' }}
theme, rounded, bordered, shadow, size, className, style all as in Themes and customising
Callback
onUploaded(file, item) once per saved file, as File::toComponentArray() plus collection, contextKey and scope
onRejected(item) a file the server refused, with its reason in item.error
onBatchCompleted({ ok, error }) when a batch ends
onMaxFiles({ max, rejected }) Dropzone: files left out for maxFiles
onRemoved(file) DropzoneSingle, Uploader: the current file, once removed

A ref in React, or bind:this in Svelte, gives startBatch() and clear().

What runs after an upload no matter what (attach the file, start a job) belongs on the server, as with the Livewire components, not in onUploaded.

Without a target

An app can also issue the grant itself and pass it as grant: { grant, scope }, or a function that takes the files picked and returns them (or a promise of them). Then it also passes the current file as file, File::toComponentArray() or its own JSON with url, name and size (and preview, type, processing_status, slug to be polled):

$grant = UploadGrant::for($user, 'avatar', replace: true);

return Inertia::render('Profile/Edit', [
    'avatarGrant' => ['grant' => $grant->token(), 'scope' => $grant->scope()],
    'avatar' => $user->file('avatar')?->toComponentArray(),
]);
<Uploader grant={avatarGrant} file={avatar} />

Such a grant lives 24 hours. file also works with a target, to show another file than the target's (null for none).