Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
182 changes: 182 additions & 0 deletions app/Livewire/ConversionHistoryTable.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
<?php

declare(strict_types=1);

namespace App\Livewire;

use App\Enums\ConversionStatus;
use App\Models\ConversionJob;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Builder;
use Livewire\Component;
use Livewire\WithPagination;

class ConversionHistoryTable extends Component
{
use WithPagination;

public string $search = '';

public string $status = 'all';

public string $sourceFormat = 'all';

public string $targetFormat = 'all';

public ?string $dateFrom = null;

public ?string $dateTo = null;

public function updatedSearch(): void
{
$this->resetPage();
}

public function updatedStatus(): void
{
$this->resetPage();
}

public function updatedSourceFormat(): void
{
$this->resetPage();
}

public function updatedTargetFormat(): void
{
$this->resetPage();
}

public function updatedDateFrom(): void
{
$this->resetPage();
}

public function updatedDateTo(): void
{
$this->resetPage();
}

public function statusOptions(): array
{
return [
'all' => 'All',
'queued' => 'Queued',
'processing' => 'Processing',
'completed' => 'Completed',
'failed' => 'Failed',
'cancelled' => 'Cancelled',
'expired' => 'Expired',
];
}

public function formatOptions(): array
{
return [
'all' => 'All formats',
'png' => 'PNG',
'jpg' => 'JPG',
'webp' => 'WEBP',
'pdf' => 'PDF',
'gif' => 'GIF',
'bmp' => 'BMP',
'svg' => 'SVG',
'tiff' => 'TIFF',
];
}

public function jobs(): LengthAwarePaginator
{
return ConversionJob::query()
->where('user_id', auth()->id())
->with(['sourceFile', 'resultFile', 'creditCharge'])
->when(trim($this->search) !== '', function (Builder $query) {
$search = '%'.trim($this->search).'%';

$query->where(function (Builder $query) use ($search) {
$query
->where('source_format', 'like', $search)
->orWhere('target_format', 'like', $search)
->orWhereHas('sourceFile', fn (Builder $q) => $q->where('original_name', 'like', $search))
->orWhereHas('resultFile', fn (Builder $q) => $q->where('original_name', 'like', $search));
});
})
->when($this->status !== 'all', fn (Builder $query) => $query->where('status', $this->status))
->when($this->sourceFormat !== 'all', fn (Builder $query) => $query->where('source_format', $this->sourceFormat))
->when($this->targetFormat !== 'all', fn (Builder $query) => $query->where('target_format', $this->targetFormat))
->when($this->dateFrom, fn (Builder $query) => $query->whereDate('created_at', '>=', $this->dateFrom))
->when($this->dateTo, fn (Builder $query) => $query->whereDate('created_at', '<=', $this->dateTo))
->latest()
->paginate(15);
}

public function canDownload(ConversionJob $job): bool
{
return $job->isCompleted()
&& $job->result_file_id !== null
&& $job->resultFile !== null
&& ! $job->resultFile->isExpired();
}

public function canConvertAgain(ConversionJob $job): bool
{
return $job->sourceFile !== null && ! $job->sourceFile->isExpired();
}

public function convertAgain(int $jobId): void
{
$job = ConversionJob::query()
->where('user_id', auth()->id())
->with('sourceFile')
->find($jobId);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if (! $job || ! $this->canConvertAgain($job)) {
$this->dispatch('toast', type: 'error', message: 'Original file expired. Upload it again to repeat this conversion.');

return;
}

$this->dispatch('conversion-repeat-requested', conversionJobId: $job->id);
$this->redirectRoute('dashboard');
}

public function statusBadgeVariant(ConversionStatus $status): string
{
return match ($status) {
ConversionStatus::Queued => 'purple',
ConversionStatus::Processing => 'warning',
ConversionStatus::Completed => 'success',
ConversionStatus::Failed => 'danger',
default => 'neutral',
};
}

public function formatBytes(?int $bytes): string
{
if ($bytes === null) {
return '—';
}

if ($bytes >= 1_000_000_000) {
return round($bytes / 1_000_000_000, 1).' GB';
}

if ($bytes >= 1_000_000) {
return round($bytes / 1_000_000, 1).' MB';
}

if ($bytes >= 1_000) {
return round($bytes / 1_000).' KB';
}

return $bytes.' B';
}

public function render(): View
{
return view('livewire.conversion-history-table', [
'jobs' => $this->jobs(),
]);
}
}
10 changes: 10 additions & 0 deletions resources/views/history/index.blade.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<x-layouts.app title="Conversion History — ConvertAI">
<section class="flex flex-col gap-2">
<h1 class="text-3xl font-semibold tracking-tight text-[var(--ca-text)]">Conversion History</h1>
<p class="text-[var(--ca-muted)]">Find and reuse your previous conversions.</p>
</section>

<div class="mt-8">
<livewire:conversion-history-table />
</div>
</x-layouts.app>
135 changes: 135 additions & 0 deletions resources/views/livewire/conversion-history-table.blade.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
<div>
{{-- Filters --}}
<div class="flex flex-wrap gap-3">
<input
type="search"
wire:model.live.debounce.300ms="search"
placeholder="Search files or formats..."
class="w-full rounded-[var(--ca-radius-md)] border border-[var(--ca-border)] bg-white px-3 py-2 text-sm text-[var(--ca-text)] placeholder-[var(--ca-muted)] focus:outline-none focus:ring-2 focus:ring-[var(--ca-primary)]/30 sm:max-w-xs"
>

<select
wire:model.live="status"
class="rounded-[var(--ca-radius-md)] border border-[var(--ca-border)] bg-white px-3 py-2 text-sm text-[var(--ca-text)] focus:outline-none focus:ring-2 focus:ring-[var(--ca-primary)]/30"
>
@foreach ($this->statusOptions() as $value => $label)
<option value="{{ $value }}">{{ $label }}</option>
@endforeach
</select>

<select
wire:model.live="sourceFormat"
class="rounded-[var(--ca-radius-md)] border border-[var(--ca-border)] bg-white px-3 py-2 text-sm text-[var(--ca-text)] focus:outline-none focus:ring-2 focus:ring-[var(--ca-primary)]/30"
>
@foreach ($this->formatOptions() as $value => $label)
<option value="{{ $value }}">From: {{ $label }}</option>
@endforeach
</select>

<select
wire:model.live="targetFormat"
class="rounded-[var(--ca-radius-md)] border border-[var(--ca-border)] bg-white px-3 py-2 text-sm text-[var(--ca-text)] focus:outline-none focus:ring-2 focus:ring-[var(--ca-primary)]/30"
>
@foreach ($this->formatOptions() as $value => $label)
<option value="{{ $value }}">To: {{ $label }}</option>
@endforeach
</select>

<input
type="date"
wire:model.live="dateFrom"
class="rounded-[var(--ca-radius-md)] border border-[var(--ca-border)] bg-white px-3 py-2 text-sm text-[var(--ca-text)] focus:outline-none focus:ring-2 focus:ring-[var(--ca-primary)]/30"
>

<input
type="date"
wire:model.live="dateTo"
class="rounded-[var(--ca-radius-md)] border border-[var(--ca-border)] bg-white px-3 py-2 text-sm text-[var(--ca-text)] focus:outline-none focus:ring-2 focus:ring-[var(--ca-primary)]/30"
>
</div>

{{-- Table --}}
<div class="mt-4">
@if ($jobs->isEmpty())
<div class="flex flex-col items-center justify-center rounded-[var(--ca-radius-lg)] border border-dashed border-[var(--ca-border)] bg-[var(--ca-surface-muted)]/40 px-6 py-12 text-center">
<p class="text-base font-semibold text-[var(--ca-text)]">No conversions found</p>
<p class="mt-1 text-sm text-[var(--ca-muted)]">Try adjusting your filters or upload a file to start converting</p>
</div>
@else
<div class="overflow-hidden rounded-[var(--ca-radius-lg)] border border-[var(--ca-border)] bg-white shadow-[var(--ca-shadow-card)]">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-[var(--ca-border)] bg-[var(--ca-surface-muted)]/40">
<th class="px-4 py-3 text-left font-medium text-[var(--ca-muted)]">File Name</th>
<th class="px-4 py-3 text-left font-medium text-[var(--ca-muted)]">From</th>
<th class="px-4 py-3 text-left font-medium text-[var(--ca-muted)]">To</th>
<th class="px-4 py-3 text-left font-medium text-[var(--ca-muted)]">Size</th>
<th class="px-4 py-3 text-left font-medium text-[var(--ca-muted)]">Created</th>
<th class="px-4 py-3 text-left font-medium text-[var(--ca-muted)]">Completed</th>
<th class="px-4 py-3 text-left font-medium text-[var(--ca-muted)]">Status</th>
<th class="px-4 py-3 text-left font-medium text-[var(--ca-muted)]">Credits</th>
<th class="px-4 py-3 text-left font-medium text-[var(--ca-muted)]">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-[var(--ca-border)]">
@foreach ($jobs as $job)
<tr class="hover:bg-[var(--ca-surface-muted)]/40">
<td class="px-4 py-3 text-[var(--ca-text)]">
{{ $job->sourceFile?->original_name ?? '—' }}
</td>
<td class="px-4 py-3 text-[var(--ca-text)]">
{{ $job->source_format ? strtoupper($job->source_format) : '—' }}
</td>
<td class="px-4 py-3 text-[var(--ca-text)]">
{{ $job->target_format ? strtoupper($job->target_format) : '—' }}
</td>
<td class="px-4 py-3 text-[var(--ca-text)]">
{{ $this->formatBytes($job->sourceFile?->size_bytes) }}
</td>
<td class="px-4 py-3 text-[var(--ca-text)]">
{{ $job->created_at->format('M d, Y H:i') }}
</td>
<td class="px-4 py-3 text-[var(--ca-text)]">
{{ $job->completed_at?->format('M d, Y H:i') ?? '—' }}
</td>
<td class="px-4 py-3">
<x-badge :variant="$this->statusBadgeVariant($job->status)">
{{ ucfirst($job->status->value) }}
</x-badge>
</td>
<td class="px-4 py-3 text-[var(--ca-text)]">
@if ($job->creditCharge !== null && $job->creditCharge->captured_amount !== null)
{{ $job->creditCharge->captured_amount }} {{ $job->creditCharge->captured_amount === 1 ? 'credit' : 'credits' }}
@else
@endif
</td>
<td class="px-4 py-3">
<div class="flex items-center gap-2">
@if ($this->canDownload($job))
<a
href="{{ route('conversions.download', $job) }}"
class="text-sm font-medium text-[var(--ca-primary)] hover:underline"
>Download</a>
@endif

@if ($this->canConvertAgain($job))
<button
wire:click="convertAgain({{ $job->id }})"
class="text-sm font-medium text-[var(--ca-muted)] hover:text-[var(--ca-text)] hover:underline"
>Convert Again</button>
@endif
</div>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>

<div class="mt-4">
{{ $jobs->links() }}
</div>
@endif
</div>
</div>
3 changes: 3 additions & 0 deletions routes/web.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@
Route::view('/billing/credits/success', 'billing.credits-success')->name('billing.credits.success');
Route::view('/billing/credits/cancel', 'billing.credits-cancel')->name('billing.credits.cancel');

Route::view('/history', 'history.index')
->name('history');

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

Expand Down
19 changes: 19 additions & 0 deletions tests/Feature/History/HistoryPageAccessTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

declare(strict_types=1);

use App\Models\User;

it('redirects guest from history page to login', function () {
$this->get('/history')
->assertRedirect('/login');
});

it('allows authenticated user to access history page', function () {
$user = User::factory()->create();

$this->actingAs($user)
->get('/history')
->assertOk()
->assertSee('Conversion History');
});
Loading
Loading