Skip to content

File Upload Preview

This recipe demonstrates uploading images with instant preview using Hyper's base64 file handling and action for displaying file previews.

What We're Building

An image uploader with:

  • Instant preview before upload
  • Multiple file uploads
  • File validation (type, size)
  • Upload progress indication
  • Remove files before upload
  • Display uploaded images

Complete Implementation

Blade Template

Create resources/views/gallery/upload.blade.php:

blade
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Image Gallery</title>
    @hyper
    <script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-gray-50">
    <div class="max-w-4xl mx-auto px-4 py-8">
        <h1 class="text-3xl font-bold mb-8">Image Gallery</h1>

        <div @signals([
            'photos' => [],
            'uploadedPhotos' => $photos,
            '_uploading' => false,
            'errors' => []
        ])>
            <!-- File Input -->
            <div class="bg-white rounded-lg shadow p-6 mb-6">
                <label class="block text-sm font-medium text-gray-700 mb-2">
                    Select Images to Upload
                </label>

                <input
                    type="file"
                    data-bind="photos"
                    accept="image/*"
                    multiple
                    class="block w-full text-sm text-gray-500
                           file:mr-4 file:py-2 file:px-4
                           file:rounded-md file:border-0
                           file:text-sm file:font-semibold
                           file:bg-blue-50 file:text-blue-700
                           hover:file:bg-blue-100"
                />
                <p class="mt-2 text-sm text-gray-500">
                    PNG, JPG, GIF up to 5MB each
                </p>
                <div data-error="photos" class="mt-2 text-sm text-red-600"></div>
            </div>

            <!-- Preview Selected Files -->
            <div data-show="$photos.length > 0" class="bg-white rounded-lg shadow p-6 mb-6">
                <div class="flex items-center justify-between mb-4">
                    <h2 class="text-lg font-semibold">Selected Files (<span data-text="$photos.length"></span>)</h2>
                    <button
                        data-on:click="$photos = []"
                        class="text-sm text-gray-600 hover:text-gray-800">
                        Clear All
                    </button>
                </div>

                <div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
                    <template data-for="photo, index in $photos">
                        <div class="relative group">
                            <img
                                data-attr:src="'data:' + photo.mime + ';base64,' + photo.contents"
                                data-attr:alt="photo.name"
                                class="w-full h-32 object-cover rounded-md bg-gray-200"
                            />
                            <button
                                data-on:click="$photos.splice(index, 1)"
                                class="absolute top-2 right-2 bg-red-600 text-white rounded-full w-6 h-6 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
                                ×
                            </button>
                            <p class="text-xs text-gray-600 mt-1 truncate" data-text="photo.name"></p>
                            <p class="text-xs text-gray-500" data-text="(photo.size / 1024).toFixed(1) + ' KB'"></p>
                        </div>
                    </template>
                </div>

                <button
                    data-on:click="$_uploading = true; @postx('/gallery/upload')"
                    data-attr:disabled="$_uploading"
                    data-class:opacity-50="$_uploading"
                    class="w-full bg-blue-600 text-white py-2 rounded-md hover:bg-blue-700">
                    <span data-show="!$_uploading">Upload <span data-text="$photos.length"></span> Images</span>
                    <span data-show="$_uploading">Uploading...</span>
                </button>
            </div>

            <!-- Uploaded Gallery -->
            <div data-show="$uploadedPhotos.length > 0" class="bg-white rounded-lg shadow p-6">
                <h2 class="text-lg font-semibold mb-4">Uploaded Images (<span data-text="$uploadedPhotos.length"></span>)</h2>

                <div class="grid grid-cols-2 md:grid-cols-4 gap-4">
                    <template data-for__key.id="photo in $uploadedPhotos">
                        <div class="relative group">
                            <img
                                data-attr:src="photo.url"
                                data-attr:alt="photo.filename"
                                class="w-full h-32 object-cover rounded-md cursor-pointer hover:opacity-90"
                            />
                            <button
                                data-on:click="@deletex('/gallery/' + photo.id)"
                                class="absolute top-2 right-2 bg-red-600 text-white rounded-full w-6 h-6 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
                                ×
                            </button>
                            <p class="text-xs text-gray-600 mt-1 truncate" data-text="photo.filename"></p>
                        </div>
                    </template>
                </div>
            </div>

            <!-- Empty State -->
            <div data-show="$photos.length === 0 && $uploadedPhotos.length === 0" class="bg-white rounded-lg shadow p-12 text-center">
                <svg class="mx-auto h-12 w-12 text-gray-400 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
                    <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
                </svg>
                <h3 class="text-lg font-medium text-gray-900 mb-2">No images yet</h3>
                <p class="text-gray-500">Upload some images to get started.</p>
            </div>
        </div>
    </div>
</body>
</html>

Controller

Create app/Http/Controllers/GalleryController.php:

php
<?php

namespace App\Http\Controllers;

use App\Models\Photo;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Log;

class GalleryController extends Controller
{
    public function index()
    {
        $photos = Photo::latest()->get()->map(function ($photo) {
            return [
                'id' => $photo->id,
                'filename' => $photo->filename,
                'url' => Storage::url($photo->path)
            ];
        });

        return view('gallery.upload', compact('photos'));
    }

    public function upload()
    {
        $photos = signals('photos', []);

        if (empty($photos)) {
            return hyper()->signals([
                'errors' => ['Please select at least one image.'],
                '_uploading' => false
            ]);
        }

        $uploadedPhotos = [];

        // Get the count of photos
        $photoCount = count($photos);

        for ($i = 0; $i < $photoCount; $i++) {
            try {
                // Store each file by its index
                $path = signals()->store("photos.{$i}", 'gallery', 'public');

                $file = $photos[$i];

                $photo = Photo::create([
                    'filename' => $file['name'] ?? 'image_' . time() . '_' . $i . '.jpg',
                    'path' => $path,
                    'size' => $file['size'] ?? 0
                ]);

                $uploadedPhotos[] = [
                    'id' => $photo->id,
                    'filename' => $photo->filename,
                    'url' => Storage::url($photo->path)
                ];
            } catch (\Exception $e) {
                // Log error but continue with other files
                Log::error('Failed to upload photo at index ' . $i . ': ' . $e->getMessage());
            }
        }

        return hyper()->signals([
            'photos' => [],
            'uploadedPhotos' => Photo::latest()->get()->map(function ($photo) {
                return [
                    'id' => $photo->id,
                    'filename' => $photo->filename,
                    'url' => Storage::url($photo->path)
                ];
            }),
            '_uploading' => false,
            'errors' => []
        ]);
    }

    public function destroy(Photo $photo)
    {
        Storage::disk('public')->delete($photo->path);
        $photo->delete();

        return hyper()->signals([
            'uploadedPhotos' => Photo::latest()->get()->map(function ($photo) {
                return [
                    'id' => $photo->id,
                    'filename' => $photo->filename,
                    'url' => Storage::url($photo->path)
                ];
            })
        ]);
    }
}

Routing

In web.php

php

use App\Http\Controllers\GalleryController;

Route::get('/gallery', [GalleryController::class, 'index'])->name('gallery.index');
Route::post('/gallery/upload', [GalleryController::class, 'upload'])->name('gallery.upload');
Route::delete('/gallery/{photo}', [GalleryController::class, 'destroy'])->name('gallery.destroy');

How It Works

File Input Binding

Datastar's data-bind works with file inputs:

blade
<input type="file" data-bind="photos" accept="image/*" multiple />

When files are selected, Datastar automatically encodes them as base64 and stores them in the photos signal:

javascript
[
  {
    name: "photo.jpg",
    size: 154832,
    type: "image/jpeg",
    lastModified: 1234567890,
    data: "data:image/jpeg;base64,/9j/4AAQSkZJRg..."
  }
]

File Validation

Hyper provides base64-specific validation rules:

php
signals()->validate([
    'photos' => 'required|array|min:1',
    'photos.*' => 'required|b64image|b64max:5120|b64mimes:jpg,jpeg,png,gif'
]);

Validation rules (Hyper):

  • b64image - Validates base64 image
  • b64max:5120 - Max size 5MB (in KB)
  • b64mimes:jpg,jpeg,png,gif - Allowed MIME types

File Storage

Hyper's signals()->store() helper saves base64 files:

php
$path = signals()->store('photos', 'gallery', 'public', $file['name']);

This decodes the base64 data and saves it to Laravel storage.

Remove Before Upload

Users can remove files before uploading:

blade
<button data-on:click="$photos.splice(index, 1)">×</button>

This uses JavaScript's splice() to remove the item from the array signal.

Key Takeaways

1. Automatic Base64 Encoding: Datastar automatically encodes file inputs as base64, making them transmittable in JSON signals.

2. Base64 Validation Rules: Hyper's b64* validation rules (Hyper) validate base64-encoded files just like regular uploaded files.

3. Seamless Storage:signals()->store() (Hyper) handles the complexity of decoding and saving base64 files.

Enhancements

Progress Indication

For large files, show upload progress using streaming or chunked uploads.