-
Notifications
You must be signed in to change notification settings - Fork 0
release: v0.1.6 — Phase 06 Dashboard Upload Flow #90
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
609cf94
2f41a20
4b50b8a
1906f5f
aea6364
d26cb2f
95d0e72
c44007f
5446538
309be1c
4a1a0aa
118d681
317c01c
f72bc78
1c2c459
502b953
7b9ea18
ca254bf
0f19397
8bd7214
77e0c06
ca36bbf
4f74f6b
6a53bd0
07d2ab8
3ff4da0
fcaab6c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,89 @@ | ||
| <?php | ||
|
|
||
| namespace App\Livewire\Dashboard; | ||
|
|
||
| use App\Actions\Files\StoreUploadedFileAction; | ||
| use App\Exceptions\Files\FileStorageException; | ||
| use App\Exceptions\Files\UnsupportedFileFormatException; | ||
| use App\Models\FileRecord; | ||
| use App\Support\Files\UploadedFileRules; | ||
| use Livewire\Component; | ||
| use Livewire\WithFileUploads; | ||
|
|
||
| class DashboardConverter extends Component | ||
| { | ||
| use WithFileUploads; | ||
|
|
||
| public string $step = 'upload'; | ||
|
|
||
| public $upload = null; | ||
|
|
||
| public ?int $currentFileId = null; | ||
|
|
||
| public ?string $uploadError = null; | ||
|
|
||
| public function storeUpload(StoreUploadedFileAction $storeUploadedFile): void | ||
| { | ||
| $this->resetErrorBag(); | ||
| $this->uploadError = null; | ||
|
|
||
| $this->validate([ | ||
| 'upload' => [ | ||
| 'required', | ||
| 'file', | ||
| 'max:'.UploadedFileRules::MAX_FILE_KILOBYTES, | ||
| ], | ||
| ], [ | ||
| 'upload.max' => 'This file is too large. Max upload size is '.(UploadedFileRules::MAX_FILE_KILOBYTES / 1024).' MB.', | ||
| ]); | ||
|
|
||
| try { | ||
| $fileRecord = $storeUploadedFile->handle( | ||
| user: auth()->user(), | ||
| file: $this->upload, | ||
| ); | ||
| } catch (UnsupportedFileFormatException) { | ||
| $this->uploadError = 'This file type is not supported in beta. Upload PNG, JPG, WEBP or PDF.'; | ||
| $this->step = 'upload'; | ||
|
|
||
| return; | ||
| } catch (FileStorageException) { | ||
| $this->uploadError = 'We could not store your file. Please try again.'; | ||
| $this->step = 'upload'; | ||
|
|
||
| return; | ||
| } | ||
|
|
||
| $this->currentFileId = $fileRecord->id; | ||
| $this->step = 'format'; | ||
| } | ||
|
|
||
| public function replaceFile(): void | ||
| { | ||
| $this->resetCurrentUpload(); | ||
| } | ||
|
|
||
| public function removeFile(): void | ||
| { | ||
| $this->resetCurrentUpload(); | ||
| } | ||
|
|
||
| private function resetCurrentUpload(): void | ||
| { | ||
| $this->reset('upload', 'currentFileId', 'uploadError'); | ||
| $this->resetErrorBag(); | ||
| $this->step = 'upload'; | ||
| } | ||
|
|
||
| public function getCurrentFileProperty(): ?FileRecord | ||
| { | ||
| return $this->currentFileId | ||
| ? FileRecord::query()->where('user_id', auth()->id())->find($this->currentFileId) | ||
| : null; | ||
| } | ||
|
|
||
| public function render() | ||
| { | ||
| return view('livewire.dashboard.dashboard-converter'); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,76 @@ | ||
| <div> | ||
| <x-card variant="elevated"> | ||
| <x-stepper :steps="['File', 'Format', 'Settings', 'Convert']" :active="$step === 'upload' ? 'File' : 'Format'" class="mb-6" /> | ||
| @if ($step === 'upload') | ||
| <div | ||
| x-data="{ isDragging: false }" | ||
| x-on:dragover.prevent="isDragging = true" | ||
| x-on:dragenter.prevent="isDragging = true" | ||
| x-on:dragleave.prevent="isDragging = false" | ||
| x-on:drop="isDragging = false" | ||
| x-bind:class="isDragging ? 'border-[var(--ca-primary)] bg-[var(--ca-primary)]/5' : 'border-[var(--ca-border)] bg-[var(--ca-surface-muted)]/40'" | ||
| class="flex flex-col items-center justify-center gap-4 rounded-[var(--ca-radius-md)] border-2 border-dashed px-6 py-12 text-center transition-colors"> | ||
| <div class="flex h-14 w-14 items-center justify-center rounded-full bg-[var(--ca-surface-muted)] text-[var(--ca-muted)]"> | ||
| <svg xmlns="http://www.w3.org/2000/svg" class="h-7 w-7" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> | ||
| <path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 7.5m0 0L7.5 12M12 7.5V18" /> | ||
| </svg> | ||
| </div> | ||
|
|
||
| <div class="flex flex-col gap-1"> | ||
| <p class="text-base font-semibold text-[var(--ca-text)]">Drop your file here</p> | ||
| <p class="text-sm text-[var(--ca-muted)]">PNG, JPG, WEBP and PDF supported in beta</p> | ||
| </div> | ||
|
|
||
| <label class="cursor-pointer"> | ||
| <input type="file" wire:model="upload" wire:loading.attr="disabled" wire:target="upload,storeUpload" class="sr-only"> | ||
|
Comment on lines
+5
to
+25
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The dropzone does not handle dropped files.
Either wire Possible fix- <div
+ <div
x-data="{ isDragging: false }"
x-on:dragover.prevent="isDragging = true"
x-on:dragenter.prevent="isDragging = true"
x-on:dragleave.prevent="isDragging = false"
- x-on:drop="isDragging = false"
+ x-on:drop.prevent="
+ isDragging = false;
+ const files = $event.dataTransfer?.files;
+ if (files?.length) {
+ $refs.upload.files = files;
+ $refs.upload.dispatchEvent(new Event('change', { bubbles: true }));
+ }
+ "
x-bind:class="isDragging ? 'border-[var(--ca-primary)] bg-[var(--ca-primary)]/5' : 'border-[var(--ca-border)] bg-[var(--ca-surface-muted)]/40'"
class="flex flex-col items-center justify-center gap-4 rounded-[var(--ca-radius-md)] border-2 border-dashed px-6 py-12 text-center transition-colors">
...
- <input type="file" wire:model="upload" wire:loading.attr="disabled" wire:target="upload,storeUpload" class="sr-only">
+ <input x-ref="upload" type="file" wire:model="upload" wire:loading.attr="disabled" wire:target="upload,storeUpload" class="sr-only">🤖 Prompt for AI Agents |
||
| <span class="inline-flex items-center justify-center gap-2 rounded-[var(--ca-radius-md)] px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:brightness-110" style="background:var(--ca-primary);"> | ||
| <span wire:loading.remove wire:target="upload,storeUpload">Choose file</span> | ||
| <span wire:loading wire:target="upload,storeUpload">Uploading…</span> | ||
| </span> | ||
| </label> | ||
|
|
||
| <p class="text-xs text-[var(--ca-muted)]">Your files stay private. We delete them after conversion.</p> | ||
|
|
||
| @error('upload') | ||
| <p class="text-sm text-[var(--ca-danger)]">{{ $message }}</p> | ||
| @enderror | ||
|
|
||
| @if ($uploadError) | ||
| <p class="text-sm text-[var(--ca-danger)]">{{ $uploadError }}</p> | ||
| @endif | ||
| </div> | ||
| @endif | ||
|
|
||
| @if ($step === 'format' && $this->currentFile) | ||
| @php($file = $this->currentFile) | ||
| <div class="flex flex-col gap-4"> | ||
| <div class="flex items-center justify-between gap-4 rounded-[var(--ca-radius-md)] border border-[var(--ca-border)] bg-white p-4"> | ||
| <div class="flex items-center gap-3"> | ||
| <x-file-icon :format="$file->extension" /> | ||
| <div class="flex flex-col"> | ||
| <p class="text-sm font-semibold text-[var(--ca-text)]">{{ $file->original_name }}</p> | ||
| <p class="text-xs text-[var(--ca-muted)]"> | ||
| {{ strtoupper($file->extension) }} · | ||
| {{ number_format($file->size_bytes / 1024, 1) }} KB | ||
| @php($meta = $file->metadata_json ?? []) | ||
| @if (isset($meta['width'], $meta['height'])) | ||
| · {{ $meta['width'] }}×{{ $meta['height'] }} | ||
| @endif | ||
| </p> | ||
| </div> | ||
| </div> | ||
|
|
||
| <div class="flex items-center gap-2"> | ||
| <x-button variant="secondary" size="sm" wire:click="replaceFile">Replace</x-button> | ||
| <x-button variant="ghost" size="sm" wire:click="removeFile">Remove</x-button> | ||
| </div> | ||
| </div> | ||
|
|
||
| <div class="rounded-[var(--ca-radius-md)] border border-dashed border-[var(--ca-border)] bg-[var(--ca-surface-muted)]/40 px-6 py-8 text-center"> | ||
| <p class="text-base font-semibold text-[var(--ca-text)]">Choose output format</p> | ||
| <p class="mt-1 text-sm text-[var(--ca-muted)]">Target format selection will be added in Phase 7.</p> | ||
| </div> | ||
| </div> | ||
| @endif | ||
| </x-card> | ||
| </div> | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| <?php | ||
|
|
||
| use App\Livewire\Dashboard\DashboardConverter; | ||
| use Livewire\Livewire; | ||
|
|
||
| it('renders empty upload state', function () { | ||
| Livewire::test(DashboardConverter::class) | ||
| ->assertSee('Drop your file here') | ||
| ->assertSee('PNG, JPG, WEBP and PDF supported in beta') | ||
| ->assertSee('Choose file'); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,113 @@ | ||
| <?php | ||
|
|
||
| use App\Livewire\Dashboard\DashboardConverter; | ||
| use App\Models\FileRecord; | ||
| use App\Models\User; | ||
| use Illuminate\Http\UploadedFile; | ||
| use Illuminate\Support\Facades\Storage; | ||
| use Livewire\Livewire; | ||
|
|
||
| it('uploads a valid image file and moves to format step', function () { | ||
| Storage::fake('local'); | ||
|
|
||
| $user = User::factory()->create(); | ||
|
|
||
| Livewire::actingAs($user) | ||
| ->test(DashboardConverter::class) | ||
| ->set('upload', UploadedFile::fake()->image('sample.png', 600, 400)) | ||
| ->call('storeUpload') | ||
| ->assertSet('step', 'format') | ||
| ->assertSee('sample.png') | ||
| ->assertSee('Choose output format'); | ||
|
|
||
| expect(FileRecord::query()->where('original_name', 'sample.png')->exists())->toBeTrue(); | ||
| }); | ||
|
|
||
| it('resets current file when replacing uploaded file', function () { | ||
| Storage::fake('local'); | ||
|
|
||
| $user = User::factory()->create(); | ||
|
|
||
| Livewire::actingAs($user) | ||
| ->test(DashboardConverter::class) | ||
| ->set('upload', UploadedFile::fake()->image('first.png')) | ||
| ->call('storeUpload') | ||
| ->assertSet('step', 'format') | ||
| ->call('replaceFile') | ||
| ->assertSet('step', 'upload') | ||
| ->assertSet('currentFileId', null) | ||
| ->assertSee('Drop your file here'); | ||
| }); | ||
|
|
||
| it('removes uploaded file from current flow', function () { | ||
| Storage::fake('local'); | ||
|
|
||
| $user = User::factory()->create(); | ||
|
|
||
| Livewire::actingAs($user) | ||
| ->test(DashboardConverter::class) | ||
| ->set('upload', UploadedFile::fake()->image('remove-me.png')) | ||
| ->call('storeUpload') | ||
| ->assertSet('step', 'format') | ||
| ->call('removeFile') | ||
| ->assertSet('step', 'upload') | ||
| ->assertSet('currentFileId', null) | ||
| ->assertDontSee('remove-me.png'); | ||
| }); | ||
|
|
||
| it('shows an error for unsupported upload format', function () { | ||
| Storage::fake('local'); | ||
|
|
||
| $user = User::factory()->create(); | ||
|
|
||
| Livewire::actingAs($user) | ||
| ->test(DashboardConverter::class) | ||
| ->set('upload', UploadedFile::fake()->create('notes.txt', 10, 'text/plain')) | ||
| ->call('storeUpload') | ||
| ->assertSet('step', 'upload') | ||
| ->assertSee('not supported'); | ||
| }); | ||
|
|
||
| it('shows format step placeholder after successful upload', function () { | ||
| Storage::fake('local'); | ||
|
|
||
| $user = User::factory()->create(); | ||
|
|
||
| Livewire::actingAs($user) | ||
| ->test(DashboardConverter::class) | ||
| ->set('upload', UploadedFile::fake()->image('photo.png')) | ||
| ->call('storeUpload') | ||
| ->assertSet('step', 'format') | ||
| ->assertSee('Choose output format') | ||
| ->assertSee('Target format selection will be added in Phase 7'); | ||
| }); | ||
|
|
||
| it('shows uploaded file summary after upload', function () { | ||
| Storage::fake('local'); | ||
|
|
||
| $user = User::factory()->create(); | ||
|
|
||
| Livewire::actingAs($user) | ||
| ->test(DashboardConverter::class) | ||
| ->set('upload', UploadedFile::fake()->image('avatar.jpg', 800, 600)) | ||
| ->call('storeUpload') | ||
| ->assertSee('avatar.jpg') | ||
| ->assertSee('JPG') | ||
| ->assertSee('KB') | ||
| ->assertSee('Replace') | ||
| ->assertSee('Remove'); | ||
| }); | ||
|
|
||
| it('does not create a file record when no file is provided', function () { | ||
| Storage::fake('local'); | ||
|
|
||
| $user = User::factory()->create(); | ||
|
|
||
| Livewire::actingAs($user) | ||
| ->test(DashboardConverter::class) | ||
| ->call('storeUpload') | ||
| ->assertSet('step', 'upload') | ||
| ->assertHasErrors(['upload' => 'required']); | ||
|
|
||
| expect(FileRecord::query()->count())->toBe(0); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Delete the persisted upload before clearing component state.
By this point the file has already been stored via
StoreUploadedFileAction, butreplaceFile()/removeFile()only null out Livewire state. That leaves orphanedfile_recordsrows and blobs behind, so “Remove” does not actually remove anything and repeated replacements will leak storage.A small follow-up action here is to delegate cleanup to an
app/Actionsdelete/remove action beforeresetCurrentUpload(). As per coding guidelines, "Controllers and Livewire components must stay thin - business logic goes into app/Actions".🤖 Prompt for AI Agents