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];

                // Calculate file size from base64 contents (RC6 doesn't include size property)
                $fileSize = 0;
                if (isset($file['contents'])) {
                    $fileSize = (int)(strlen(base64_decode($file['contents'])) / 1024); // Size in KB
                }

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

                $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.