Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
06106a4
CONV-147: Add convert button test
menvil Jun 1, 2026
c3b18fc
Merge pull request #145 from menvil/feature/CONV-147-add-convert-butt…
menvil Jun 1, 2026
4dfed27
CONV-148: Connect convert button to CreateConversionJobAction
menvil Jun 1, 2026
8e7e0b1
Merge pull request #146 from menvil/feature/CONV-148-connect-convert-…
menvil Jun 1, 2026
a2678a7
CONV-149: Add duplicate convert protection
menvil Jun 1, 2026
2b4596f
Merge pull request #147 from menvil/feature/CONV-149-add-duplicate-co…
menvil Jun 1, 2026
f1393c7
CONV-150: Add converting state test
menvil Jun 1, 2026
8a0cdb7
Merge pull request #148 from menvil/feature/CONV-150-add-converting-s…
menvil Jun 1, 2026
959c322
CONV-151: Implement converting state UI
menvil Jun 1, 2026
f5ea45a
Merge pull request #149 from menvil/feature/CONV-151-implement-conver…
menvil Jun 1, 2026
36f370c
CONV-152: Add job status polling test
menvil Jun 1, 2026
a74c427
Merge pull request #150 from menvil/feature/CONV-152-add-job-status-p…
menvil Jun 1, 2026
20b309a
CONV-153: Implement job status polling
menvil Jun 1, 2026
b414475
Merge pull request #151 from menvil/feature/CONV-153-implement-job-st…
menvil Jun 1, 2026
0ee44c6
CONV-154: Add completed state test
menvil Jun 1, 2026
ea6b4d8
Merge pull request #152 from menvil/feature/CONV-154-add-completed-st…
menvil Jun 1, 2026
b13154d
CONV-155: Implement completed state UI
menvil Jun 1, 2026
65cf1db
Merge pull request #153 from menvil/feature/CONV-155-implement-comple…
menvil Jun 1, 2026
e8864f4
CONV-156: Add failed state test
menvil Jun 1, 2026
64b1c61
Merge pull request #154 from menvil/feature/CONV-156-add-failed-state…
menvil Jun 1, 2026
f901c14
CONV-157: Implement failed state UI
menvil Jun 1, 2026
741579c
Merge pull request #155 from menvil/feature/CONV-157-implement-failed…
menvil Jun 1, 2026
dc41406
CONV-158: Add result download route test
menvil Jun 1, 2026
ad4b878
Merge pull request #156 from menvil/feature/CONV-158-add-result-downl…
menvil Jun 1, 2026
5d93e38
CONV-159: Implement result download route
menvil Jun 1, 2026
be2a9a2
Merge pull request #157 from menvil/feature/CONV-159-implement-result…
menvil Jun 1, 2026
100ec73
CONV-160: Add convert another action
menvil Jun 1, 2026
2468e3d
Merge pull request #158 from menvil/feature/CONV-160-add-convert-anot…
menvil Jun 1, 2026
3d9bd65
CONV-161: Add convert with different settings action
menvil Jun 1, 2026
6f44a38
Merge pull request #159 from menvil/feature/CONV-161-add-convert-with…
menvil Jun 1, 2026
b9daf0f
CONV-162: Add result expired UI handling
menvil Jun 1, 2026
41589c4
Merge pull request #160 from menvil/feature/CONV-162-add-result-expir…
menvil Jun 1, 2026
c42c9e2
CONV-163: Add convert flow integration test
menvil Jun 2, 2026
90e1934
Merge pull request #161 from menvil/feature/CONV-163-add-convert-flow…
menvil Jun 2, 2026
63c7984
Phase 11 review fixes: convert error handling, stale job ID, type saf…
menvil Jun 2, 2026
32e23ba
Merge pull request #163 from menvil/fix/phase11-review-fixes
menvil Jun 2, 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
32 changes: 32 additions & 0 deletions app/Http/Controllers/DownloadConversionResultController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php

declare(strict_types=1);

namespace App\Http\Controllers;

use App\Enums\ConversionStatus;
use App\Models\ConversionJob;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;

final class DownloadConversionResultController extends Controller
{
public function __invoke(Request $request, ConversionJob $conversion)
{
abort_unless((int) $conversion->user_id === (int) $request->user()->id, 403);
abort_unless($conversion->status === ConversionStatus::Completed, 404);
abort_unless($conversion->resultFile !== null, 404);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

$file = $conversion->resultFile;

abort_if($file->isExpired(), 410);

abort_unless(Storage::disk('local')->exists($file->stored_path), 404);

return Storage::disk('local')->download(
$file->stored_path,
$file->original_name,
['Content-Type' => $file->mime_type],
);
}
}
118 changes: 116 additions & 2 deletions app/Livewire/Dashboard/DashboardConverter.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,14 @@

namespace App\Livewire\Dashboard;

use App\Actions\Conversions\CreateConversionJobAction;
use App\Actions\Files\StoreUploadedFileAction;
use App\Enums\ConversionStatus;
use App\Exceptions\Files\FileStorageException;
use App\Exceptions\Files\UnsupportedFileFormatException;
use App\Models\ConversionJob;
use App\Models\FileRecord;
use App\Support\Conversions\Exceptions\UnsupportedConversionException;
use App\Support\Converters\ConverterRegistry;
use App\Support\Converters\DTO\ConverterTarget;
use App\Support\Converters\Exceptions\InvalidConverterOptionsException;
Expand All @@ -14,6 +18,7 @@
use App\ViewModels\TargetFormatCardViewModel;
use Livewire\Component;
use Livewire\WithFileUploads;
use Throwable;

class DashboardConverter extends Component
{
Expand Down Expand Up @@ -48,6 +53,12 @@ class DashboardConverter extends Component
*/
public array $optionsByTarget = [];

public ?int $currentConversionJobId = null;

public int $pollCount = 0;

public ?string $convertError = null;

public function updatedUpload(): void
{
$this->storeUpload(app(StoreUploadedFileAction::class));
Expand Down Expand Up @@ -196,11 +207,46 @@ public function continueFromSettings(): void
return;
}

// Phase 8 stops at a convert placeholder. CreateConversionJobAction and
// the real conversion flow arrive in Phase 9.
$this->step = 'convert';
}

public function convert(): void
{
if ($this->step === 'converting' || $this->currentConversionJobId !== null) {
return;
}

$file = $this->currentFile;

if ($file === null || $this->selectedTargetFormat === null) {
return;
}

$this->convertError = null;

try {
$job = app(CreateConversionJobAction::class)->handle(
user: auth()->user(),
sourceFile: $file,
targetFormat: $this->selectedTargetFormat,
options: $this->options,
);
} catch (UnsupportedConversionException $e) {
$this->convertError = 'This conversion is not supported. Please choose a different format.';
$this->step = 'format';

return;
} catch (Throwable) {
$this->convertError = 'Something went wrong. Please try again.';

return;
}

$this->currentConversionJobId = $job->id;
$this->pollCount = 0;
$this->step = 'converting';
}

public function validateSettings(): bool
{
$this->resetErrorBag();
Expand Down Expand Up @@ -285,6 +331,74 @@ private function resetTargetSelection(): void
$this->optionsByTarget = [];
}

public function getCurrentJobProperty(): ?ConversionJob
{
if ($this->currentConversionJobId === null) {
return null;
}

return ConversionJob::query()
->with(['sourceFile', 'resultFile'])
->where('user_id', auth()->id())
->find($this->currentConversionJobId);
}

public function convertAnother(): void
{
$this->reset(['upload', 'currentFileId', 'uploadError', 'selectedTargetFormat',
'selectedConverterKey', 'targetFormatError', 'optionsSchema', 'options',
'optionsByTarget', 'currentConversionJobId']);
$this->step = 'upload';
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
}

public function convertWithDifferentSettings(): void
{
$this->currentConversionJobId = null;
$this->pollCount = 0;

if ($this->currentFile === null || $this->selectedTargetFormat === null) {
$this->step = 'upload';

return;
}

$this->step = 'settings';
}

public function refreshConversionStatus(): void
{
if ($this->currentConversionJobId === null) {
return;
}

$this->pollCount++;

if ($this->pollCount > 60) {
$this->step = 'failed';
$this->convertError = 'Conversion is taking too long. Please try again.';

return;
}

$job = ConversionJob::query()
->where('user_id', auth()->id())
->find($this->currentConversionJobId);

if (! $job) {
return;
}

if ($job->status === ConversionStatus::Completed) {
$this->step = 'completed';

return;
}

if ($job->status === ConversionStatus::Failed) {
$this->step = 'failed';
}
}

public function getCurrentFileProperty(): ?FileRecord
{
return $this->currentFileId
Expand Down
5 changes: 5 additions & 0 deletions app/Models/FileRecord.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@ protected function casts(): array
];
}

public function isExpired(): bool
{
return $this->expires_at !== null && $this->expires_at->isPast();
}

public function user(): BelongsTo
{
return $this->belongsTo(User::class);
Expand Down
34 changes: 34 additions & 0 deletions database/factories/ConversionJobFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,38 @@ public function definition(): array
'error_message' => null,
];
}

public function queued(): static
{
return $this->state(['status' => ConversionStatus::Queued, 'progress' => 0]);
}

public function processing(): static
{
return $this->state([
'status' => ConversionStatus::Processing,
'progress' => 10,
'started_at' => now(),
]);
}

public function completed(): static
{
return $this->state([
'status' => ConversionStatus::Completed,
'progress' => 100,
'started_at' => now()->subSeconds(5),
'completed_at' => now(),
]);
}

public function failed(): static
{
return $this->state([
'status' => ConversionStatus::Failed,
'error_code' => 'driver_failed',
'error_message' => 'Conversion driver encountered an error.',
'completed_at' => now(),
]);
}
}
5 changes: 5 additions & 0 deletions database/factories/FileRecordFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ class FileRecordFactory extends Factory
{
protected $model = FileRecord::class;

public function expired(): static
{
return $this->state(['expires_at' => now()->subHour()]);
}

public function definition(): array
{
$extension = fake()->randomElement(['png', 'jpg', 'webp', 'pdf']);
Expand Down
89 changes: 88 additions & 1 deletion resources/views/livewire/dashboard/dashboard-converter.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,83 @@ class="flex items-center justify-between gap-4 rounded-[var(--ca-radius-md)] bor
</div>
@endif

@if ($step === 'failed')
<div class="flex flex-col gap-4">
<div class="rounded-[var(--ca-radius-md)] border border-red-200 bg-red-50 px-6 py-8 text-center">
<p class="text-base font-semibold text-red-700">Conversion failed</p>
<p class="mt-1 text-sm text-red-600">We could not convert this file. Try another file or change settings.</p>
</div>

<div class="flex flex-col gap-2">
<x-button wire:click="convertWithDifferentSettings" class="w-full">
Change settings
</x-button>

<x-button variant="secondary" wire:click="convertAnother" class="w-full">
Try another file
</x-button>
</div>
</div>
@endif

@if ($step === 'completed' && $this->currentJob)
@php($job = $this->currentJob)
<div class="flex flex-col gap-4">
@if ($job->resultFile?->isExpired())
<div class="rounded-[var(--ca-radius-md)] border 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)]">This result has expired</p>
<p class="mt-1 text-sm text-[var(--ca-muted)]">Upload the original file again to create a new result.</p>
</div>

<x-button variant="ghost" wire:click="convertAnother" class="w-full">
Convert another file
</x-button>
@else
<div class="rounded-[var(--ca-radius-md)] border 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)]">Done! Your file is ready.</p>
@if ($job->resultFile)
<p class="mt-1 text-sm text-[var(--ca-muted)]">{{ $job->resultFile->original_name }}</p>
@endif
</div>

<div class="flex flex-col gap-2">
<a
href="{{ route('conversions.download', $job) }}"
class="inline-flex w-full 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);"
>Download</a>

<x-button variant="secondary" wire:click="convertWithDifferentSettings" class="w-full">
Change settings
</x-button>

<x-button variant="ghost" wire:click="convertAnother" class="w-full">
Convert another file
</x-button>
</div>
@endif
</div>
@endif

@if ($step === 'converting')
<div wire:poll.2s="refreshConversionStatus">
<div class="flex flex-col items-center gap-4 py-8 text-center">
<div class="h-12 w-12 animate-spin rounded-full border-4 border-[var(--ca-border)] border-t-[var(--ca-primary)]"></div>

<div>
<p class="text-base font-semibold text-[var(--ca-text)]">Converting your file</p>
@if ($this->currentJob)
<p class="mt-1 text-sm text-[var(--ca-muted)]">
{{ strtoupper($this->currentJob->source_format) }} → {{ strtoupper($this->currentJob->target_format) }}
</p>
@endif
</div>

<p class="text-xs text-[var(--ca-muted)]">Please keep this page open while we prepare your file.</p>
</div>
</div>
@endif

@if ($step === 'convert' && $this->currentFile)
@php($file = $this->currentFile)
<div class="flex flex-col gap-4">
Expand All @@ -191,8 +268,18 @@ class="flex items-center justify-between gap-4 rounded-[var(--ca-radius-md)] bor
<p class="text-base font-semibold text-[var(--ca-text)]">
Ready to convert {{ strtoupper($file->extension) }} to {{ strtoupper($selectedTargetFormat) }}
</p>
<p class="mt-1 text-sm text-[var(--ca-muted)]">Conversion will be available in Phase 9.</p>
</div>

<x-button
wire:click="convert"
wire:loading.attr="disabled"
wire:target="convert"
:disabled="$step === 'converting'"
class="w-full"
>
<span wire:loading.remove wire:target="convert">Convert Now</span>
<span wire:loading wire:target="convert">Starting…</span>
</x-button>
</div>
@endif
</x-card>
Expand Down
4 changes: 4 additions & 0 deletions routes/web.php
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<?php

use App\Http\Controllers\DownloadConversionResultController;
use App\Http\Controllers\ProfileController;
use Illuminate\Support\Facades\Route;

Expand All @@ -14,6 +15,9 @@
Route::view('/ui-kit', 'ui-kit')->name('ui-kit');

Route::middleware('auth')->group(function () {
Route::get('/conversions/{conversion}/download', DownloadConversionResultController::class)
->name('conversions.download');

Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit');
Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update');
Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy');
Expand Down
Loading
Loading