Add sortable columns with a fixed status width, and keep the show page in sync via Echo, polling, and Alpine hydrate while transcription runs.
228 lines
6.1 KiB
PHP
228 lines
6.1 KiB
PHP
<?php
|
|
|
|
namespace App\Livewire\Recordings;
|
|
|
|
use App\Models\Recording;
|
|
use Flux\Flux;
|
|
use Illuminate\Contracts\View\View;
|
|
use Illuminate\Database\Eloquent\Builder;
|
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
|
use Illuminate\Support\Facades\Auth;
|
|
use Illuminate\Support\Facades\Gate;
|
|
use Livewire\Attributes\Layout;
|
|
use Livewire\Attributes\On;
|
|
use Livewire\Attributes\Title;
|
|
use Livewire\Attributes\Url;
|
|
use Livewire\Component;
|
|
use Livewire\WithPagination;
|
|
|
|
#[Layout('layouts.app')]
|
|
#[Title('Recordings')]
|
|
class Index extends Component
|
|
{
|
|
use WithPagination;
|
|
|
|
public int $userId;
|
|
|
|
#[Url(as: 'q', history: true)]
|
|
public string $search = '';
|
|
|
|
#[Url(as: 'sort', history: true)]
|
|
public string $sortBy = 'uploaded';
|
|
|
|
#[Url(as: 'dir', history: true)]
|
|
public string $sortDirection = 'desc';
|
|
|
|
/**
|
|
* @var array<string, string>
|
|
*/
|
|
private const SORTABLE = [
|
|
'title' => 'title',
|
|
'duration' => 'duration_seconds',
|
|
'status' => 'transcription_status',
|
|
'uploaded' => 'created_at',
|
|
];
|
|
|
|
public function mount(): void
|
|
{
|
|
$this->userId = (int) Auth::id();
|
|
$this->normalizeSort();
|
|
}
|
|
|
|
public function updatedSearch(): void
|
|
{
|
|
$this->resetPage();
|
|
}
|
|
|
|
public function sort(string $column): void
|
|
{
|
|
if ($column !== 'words' && ! array_key_exists($column, self::SORTABLE)) {
|
|
return;
|
|
}
|
|
|
|
if ($this->sortBy === $column) {
|
|
$this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc';
|
|
} else {
|
|
$this->sortBy = $column;
|
|
$this->sortDirection = $column === 'uploaded' ? 'desc' : 'asc';
|
|
}
|
|
|
|
$this->resetPage();
|
|
}
|
|
|
|
/**
|
|
* Re-render when any of this user's recordings broadcast a status change.
|
|
*/
|
|
#[On('echo-private:user.{userId}.recordings,.RecordingTranscriptionUpdated')]
|
|
public function onTranscriptionUpdated(): void
|
|
{
|
|
//
|
|
}
|
|
|
|
public function queuePending(): void
|
|
{
|
|
$queued = 0;
|
|
|
|
Auth::user()->recordings()
|
|
->whereIn('transcription_status', ['pending', 'failed', 'cancelled'])
|
|
->orderBy('id')
|
|
->each(function (Recording $recording) use (&$queued): void {
|
|
if ($recording->hasActiveTranscriptionJob()) {
|
|
return;
|
|
}
|
|
|
|
$recording->queueLocalTranscription();
|
|
$queued++;
|
|
});
|
|
|
|
if ($queued === 0) {
|
|
Flux::toast(text: 'No recordings need transcription right now.', variant: 'danger');
|
|
|
|
return;
|
|
}
|
|
|
|
Flux::toast(
|
|
text: $queued === 1
|
|
? 'Queued 1 recording for transcription.'
|
|
: "Queued {$queued} recordings for transcription.",
|
|
variant: 'success',
|
|
);
|
|
}
|
|
|
|
public function delete(int $recordingId): void
|
|
{
|
|
$recording = Auth::user()->recordings()->findOrFail($recordingId);
|
|
|
|
Gate::authorize('delete', $recording);
|
|
|
|
$recording->deleteFile();
|
|
$recording->delete();
|
|
|
|
Flux::toast(text: 'Recording deleted.', variant: 'success');
|
|
}
|
|
|
|
public function deleteAll(): void
|
|
{
|
|
$deleted = 0;
|
|
|
|
Auth::user()->recordings()
|
|
->orderBy('id')
|
|
->each(function (Recording $recording) use (&$deleted): void {
|
|
Gate::authorize('delete', $recording);
|
|
|
|
$recording->deleteFile();
|
|
$recording->delete();
|
|
$deleted++;
|
|
});
|
|
|
|
$this->resetPage();
|
|
|
|
if ($deleted === 0) {
|
|
Flux::toast(text: 'No recordings to delete.', variant: 'danger');
|
|
|
|
return;
|
|
}
|
|
|
|
Flux::toast(
|
|
text: $deleted === 1
|
|
? 'Deleted 1 recording.'
|
|
: "Deleted {$deleted} recordings.",
|
|
variant: 'success',
|
|
);
|
|
}
|
|
|
|
public function render(): View
|
|
{
|
|
$user = Auth::user();
|
|
|
|
$user->recordings()
|
|
->whereIn('transcription_status', ['pending', 'processing'])
|
|
->orderBy('id')
|
|
->each(fn (Recording $recording) => $recording->recoverOrphanedTranscription());
|
|
|
|
$totalCount = $user->recordings()->count();
|
|
|
|
$this->normalizeSort();
|
|
|
|
$query = $user->recordings();
|
|
|
|
$search = trim($this->search);
|
|
|
|
if ($search !== '') {
|
|
$query->search($search);
|
|
}
|
|
|
|
$this->applySort($query);
|
|
|
|
$recordings = $query->paginate(20);
|
|
|
|
$pendingCount = $user->recordings()
|
|
->whereIn('transcription_status', ['pending', 'failed', 'cancelled'])
|
|
->get()
|
|
->filter(fn (Recording $recording) => ! $recording->hasActiveTranscriptionJob())
|
|
->count();
|
|
|
|
$hasActiveTranscriptions = $user->recordings()
|
|
->whereIn('transcription_status', ['pending', 'processing'])
|
|
->exists();
|
|
|
|
return view('livewire.recordings.index', [
|
|
'recordings' => $recordings,
|
|
'search' => $search,
|
|
'pendingCount' => $pendingCount,
|
|
'hasActiveTranscriptions' => $hasActiveTranscriptions,
|
|
'totalCount' => $totalCount,
|
|
]);
|
|
}
|
|
|
|
private function normalizeSort(): void
|
|
{
|
|
if ($this->sortBy !== 'words' && ! array_key_exists($this->sortBy, self::SORTABLE)) {
|
|
$this->sortBy = 'uploaded';
|
|
}
|
|
|
|
if (! in_array($this->sortDirection, ['asc', 'desc'], true)) {
|
|
$this->sortDirection = 'desc';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param Builder<Recording>|HasMany<Recording, User> $query
|
|
*/
|
|
private function applySort(Builder|HasMany $query): void
|
|
{
|
|
$direction = $this->sortDirection === 'asc' ? 'asc' : 'desc';
|
|
|
|
if ($this->sortBy === 'words') {
|
|
$query->orderByRaw(
|
|
'CASE WHEN transcript IS NULL OR TRIM(transcript) = ? THEN 0 ELSE LENGTH(TRIM(transcript)) - LENGTH(REPLACE(TRIM(transcript), ?, ?)) + 1 END '.$direction,
|
|
['', ' ', ''],
|
|
);
|
|
} else {
|
|
$query->orderBy(self::SORTABLE[$this->sortBy], $direction);
|
|
}
|
|
|
|
$query->orderByDesc('id');
|
|
}
|
|
}
|