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:
<!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
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
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:
<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:
[
{
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:
signals()->validate([
'photos' => 'required|array|min:1',
'photos.*' => 'required|b64image|b64max:5120|b64mimes:jpg,jpeg,png,gif'
]);Validation rules (Hyper):
b64image- Validates base64 imageb64max:5120- Max size 5MB (in KB)b64mimes:jpg,jpeg,png,gif- Allowed MIME types
File Storage
Hyper's signals()->store() helper saves base64 files:
$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:
<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.

