Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
609cf94
chore: backmerge main into develop before Phase 6
menvil May 29, 2026
2f41a20
CONV-071: Create DashboardConverter component
menvil May 29, 2026
4b50b8a
Merge pull request #77 from menvil/feature/CONV-071-create-dashboard-…
menvil May 29, 2026
1906f5f
CONV-072: Connect dashboard route to Livewire component
menvil May 29, 2026
aea6364
Merge pull request #78 from menvil/feature/CONV-072-connect-dashboard…
menvil May 29, 2026
d26cb2f
CONV-073: Render empty upload state
menvil May 29, 2026
95d0e72
Merge pull request #79 from menvil/feature/CONV-073-render-empty-uplo…
menvil May 29, 2026
c44007f
CONV-074: Test valid file upload flow
menvil May 29, 2026
5446538
Merge pull request #80 from menvil/feature/CONV-074-test-valid-file-u…
menvil May 29, 2026
309be1c
CONV-075: Implement Livewire upload handling
menvil May 29, 2026
4a1a0aa
Merge pull request #81 from menvil/feature/CONV-075-implement-livewir…
menvil May 29, 2026
118d681
CONV-076: Render uploaded file summary
menvil May 29, 2026
317c01c
Merge pull request #82 from menvil/feature/CONV-076-render-uploaded-f…
menvil May 29, 2026
f72bc78
CONV-077: Add replace uploaded file action
menvil May 29, 2026
1c2c459
Merge pull request #83 from menvil/feature/CONV-077-add-replace-uploa…
menvil May 29, 2026
502b953
CONV-078: Add remove uploaded file action
menvil May 29, 2026
7b9ea18
Merge pull request #84 from menvil/feature/CONV-078-add-remove-upload…
menvil May 29, 2026
ca254bf
CONV-079: Add drag hover upload UI
menvil May 29, 2026
0f19397
Merge pull request #85 from menvil/feature/CONV-079-add-drag-hover-up…
menvil May 29, 2026
8bd7214
CONV-080: Add upload error states
menvil May 29, 2026
77e0c06
Merge pull request #86 from menvil/feature/CONV-080-add-upload-error-…
menvil May 29, 2026
ca36bbf
CONV-081: Add upload loading state
menvil May 29, 2026
4f74f6b
Merge pull request #87 from menvil/feature/CONV-081-add-upload-loadin…
menvil May 29, 2026
6a53bd0
CONV-082: Add format step placeholder
menvil May 29, 2026
07d2ab8
Merge pull request #88 from menvil/feature/CONV-082-add-format-step-p…
menvil May 29, 2026
3ff4da0
CONV-083: Add dashboard upload flow smoke tests
menvil May 29, 2026
fcaab6c
Merge pull request #89 from menvil/feature/CONV-083-add-dashboard-upl…
menvil May 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions app/Livewire/Dashboard/DashboardConverter.php
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';
Comment on lines +61 to +75

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Delete the persisted upload before clearing component state.

By this point the file has already been stored via StoreUploadedFileAction, but replaceFile()/removeFile() only null out Livewire state. That leaves orphaned file_records rows 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/Actions delete/remove action before resetCurrentUpload(). As per coding guidelines, "Controllers and Livewire components must stay thin - business logic goes into app/Actions".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/Livewire/Dashboard/DashboardConverter.php` around lines 61 - 75, In
replaceFile() and removeFile() call a dedicated action in app/Actions to delete
the persisted upload (e.g., DeleteUploadedFileAction) before calling
resetCurrentUpload(); locate the current upload identifier (currentFileId or
upload) inside DashboardConverter and invoke the action (via constructor
injection or app()->make()) to remove the file_record and underlying blob,
handle any errors/exceptions (log or set uploadError) and only then call
resetCurrentUpload() to clear Livewire state; keep the deletion logic inside the
new action so the component stays thin.

}

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');
}
}
31 changes: 1 addition & 30 deletions resources/views/dashboard.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,7 @@

<div class="mt-8 grid gap-6 lg:grid-cols-3">
<div class="flex flex-col gap-6 lg:col-span-2">
<x-card variant="elevated">
<x-stepper :steps="['File', 'Format', 'Settings', 'Convert']" active="File" />

<div class="mt-6">
<x-conversion.dropzone variant="simple" />
</div>

<div class="mt-6 flex flex-wrap items-center gap-2 text-sm text-[var(--ca-muted)]">
<span>Try:</span>
<x-file-icon format="pdf" size="sm" />
<x-file-icon format="jpg" size="sm" />
<x-file-icon format="png" size="sm" />
<x-file-icon format="webp" size="sm" />
</div>
</x-card>
<livewire:dashboard.dashboard-converter />
</div>

<aside class="flex flex-col gap-6">
Expand Down Expand Up @@ -50,21 +36,6 @@
</aside>
</div>

<div class="mt-10">
<x-table.recent-conversions
:rows="[
['name' => 'Marketing Report.pdf', 'from' => 'pdf', 'to' => 'docx', 'size' => '24.8 MB', 'date' => 'May 12, 10:24 AM', 'status' => 'completed'],
['name' => 'Project Proposal.docx', 'from' => 'jpg', 'to' => 'pdf', 'size' => '1.8 MB', 'date' => 'May 12, 09:58 AM', 'status' => 'completed', 'starred' => true],
['name' => 'Sales Data.xlsx', 'from' => 'png', 'to' => 'webp', 'size' => '612 KB', 'date' => 'May 12, 09:15 AM', 'status' => 'completed'],
['name' => 'Product Demo.mp4', 'from' => 'png', 'to' => 'jpg', 'size' => '84 MB', 'date' => 'May 11, 04:42 PM', 'status' => 'completed'],
['name' => 'Screenshot 2024.jpg', 'from' => 'jpg', 'to' => 'png', 'size' => '3.2 MB', 'date' => 'May 11, 03:33 PM', 'status' => 'completed'],
['name' => 'Contract.doc', 'from' => 'pdf', 'to' => 'pdf', 'size' => '412 KB', 'date' => 'May 11, 11:07 AM', 'status' => 'processing'],
['name' => 'Client_Presentation.pptx', 'from' => 'pdf', 'to' => 'pdf', 'size' => '5.1 MB', 'date' => 'May 10, 06:20 PM', 'status' => 'completed', 'starred' => true],
['name' => 'Annual_Budget.xlsx', 'from' => 'pdf', 'to' => 'pdf', 'size' => '2.4 MB', 'date' => 'May 10, 02:05 PM', 'status' => 'completed'],
]"
/>
</div>

<div class="mt-10">
<x-footer-help-cards />
</div>
Expand Down
76 changes: 76 additions & 0 deletions resources/views/livewire/dashboard/dashboard-converter.blade.php
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

The dropzone does not handle dropped files.

x-on:drop only flips the hover state, so dropping a file neither uploads it nor selects it for the hidden input. Because the drop event is not prevented, browsers can also try to open the file and navigate away from the dashboard.

Either wire DataTransfer.files into the file input/Livewire upload path or remove the “Drop your file here” affordance until drop is actually supported.

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
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@resources/views/livewire/dashboard/dashboard-converter.blade.php` around
lines 5 - 25, The dropzone currently only toggles isDragging on x-on:drop and
doesn't prevent default or transfer files to the hidden file input
(wire:model="upload"), so dropped files won't upload and the browser may
navigate away; update the x-on:drop handler on the drop container to
preventDefault/stopPropagation, extract files from event.dataTransfer.files, and
programmatically assign them to the hidden input bound by wire:model="upload"
(or call Livewire.upload/dispatch a custom event to invoke the Livewire upload
handler such as storeUpload) so the dropped files are selected and processed
just like a normal file input; ensure the x-on:dragover and x-on:drop handlers
both call event.preventDefault() to stop default navigation.

<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>
22 changes: 9 additions & 13 deletions tests/Feature/DashboardRouteTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,15 @@
->assertSee('Convert any file');
});

it('renders dashboard converter on dashboard page', function () {
$user = User::factory()->create();

$this->actingAs($user)
->get(route('dashboard'))
->assertOk()
->assertSee('Drop your file here');
});

it('renders dashboard inside the app layout', function () {
$this->actingAs(User::factory()->create())
->get('/dashboard')
Expand All @@ -41,16 +50,3 @@
->assertSee('Contact Support')
->assertSee('Refer a Friend');
});

it('renders dashboard UI skeleton', function () {
$this->actingAs(User::factory()->create())
->get('/dashboard')
->assertOk()
->assertSee('Convert any file')
->assertSee('File')
->assertSee('Format')
->assertSee('Settings')
->assertSee('Convert')
->assertSee('Recent Conversions')
->assertSee('Marketing Report.pdf');
});
11 changes: 11 additions & 0 deletions tests/Feature/Livewire/DashboardConverterTest.php
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');
});
113 changes: 113 additions & 0 deletions tests/Feature/Livewire/DashboardConverterUploadTest.php
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);
});
Loading