Add sortable columns with a fixed status width, and keep the show page in sync via Echo, polling, and Alpine hydrate while transcription runs.
235 lines
10 KiB
PHP
235 lines
10 KiB
PHP
<div
|
|
@if ($hasActiveTranscriptions)
|
|
wire:poll.2s.visible
|
|
@endif
|
|
x-data="recordingsIndex()"
|
|
x-init="
|
|
start();
|
|
return () => destroy();
|
|
"
|
|
>
|
|
<div class="mb-8 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
|
<div>
|
|
<flux:heading size="xl">Recordings</flux:heading>
|
|
<flux:text class="mt-1">Manage pocket-recorder audio and transcripts.</flux:text>
|
|
</div>
|
|
<div class="flex flex-col gap-2 sm:items-end">
|
|
<div class="flex items-end gap-2">
|
|
<flux:input
|
|
type="search"
|
|
wire:model.live.debounce.400ms="search"
|
|
placeholder="Search title, artist, transcript…"
|
|
class="min-w-[16rem]"
|
|
/>
|
|
</div>
|
|
<div class="flex flex-wrap items-center justify-end gap-2">
|
|
@if ($pendingCount > 0)
|
|
<flux:button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
wire:click="queuePending"
|
|
>
|
|
Queue {{ $pendingCount }} pending
|
|
{{ $pendingCount === 1 ? 'transcription' : 'transcriptions' }}
|
|
</flux:button>
|
|
@endif
|
|
|
|
@if ($totalCount > 0)
|
|
<flux:modal.trigger name="delete-all-recordings">
|
|
<flux:button type="button" variant="danger" size="sm">
|
|
Delete all
|
|
</flux:button>
|
|
</flux:modal.trigger>
|
|
@endif
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
@if ($totalCount > 0)
|
|
<flux:modal name="delete-all-recordings" class="max-w-md">
|
|
<div class="space-y-6">
|
|
<div>
|
|
<flux:heading size="lg">Delete all recordings?</flux:heading>
|
|
<flux:text class="mt-2">
|
|
This permanently removes
|
|
{{ $totalCount === 1 ? 'your 1 recording' : "all {$totalCount} recordings" }}
|
|
and their audio files. This cannot be undone.
|
|
</flux:text>
|
|
</div>
|
|
<div class="flex justify-end gap-2">
|
|
<flux:modal.close>
|
|
<flux:button variant="ghost">Cancel</flux:button>
|
|
</flux:modal.close>
|
|
<flux:button type="button" variant="danger" wire:click="deleteAll">
|
|
Delete all
|
|
</flux:button>
|
|
</div>
|
|
</div>
|
|
</flux:modal>
|
|
@endif
|
|
|
|
@if ($recordings->isEmpty())
|
|
@if (filled($search))
|
|
<flux:card class="border-dashed py-16 text-center">
|
|
<flux:text>No recordings match “{{ $search }}”.</flux:text>
|
|
<div class="mt-4">
|
|
<flux:button variant="ghost" wire:click="$set('search', '')">Clear search</flux:button>
|
|
</div>
|
|
</flux:card>
|
|
@else
|
|
<livewire:upload-recordings :show-cancel="false" />
|
|
@endif
|
|
@else
|
|
<audio
|
|
x-ref="player"
|
|
class="hidden"
|
|
preload="none"
|
|
@play="syncPlayer()"
|
|
@pause="syncPlayer()"
|
|
@ended="playingId = null; syncPlayer()"
|
|
></audio>
|
|
|
|
<flux:table :paginate="$recordings">
|
|
<flux:table.columns>
|
|
<flux:table.column
|
|
sortable
|
|
:sorted="$sortBy === 'title'"
|
|
:direction="$sortDirection"
|
|
wire:click="sort('title')"
|
|
>
|
|
Title
|
|
</flux:table.column>
|
|
<flux:table.column
|
|
sortable
|
|
:sorted="$sortBy === 'duration'"
|
|
:direction="$sortDirection"
|
|
wire:click="sort('duration')"
|
|
>
|
|
Duration
|
|
</flux:table.column>
|
|
<flux:table.column
|
|
sortable
|
|
:sorted="$sortBy === 'words'"
|
|
:direction="$sortDirection"
|
|
wire:click="sort('words')"
|
|
>
|
|
Words
|
|
</flux:table.column>
|
|
<flux:table.column
|
|
class="w-36"
|
|
sortable
|
|
:sorted="$sortBy === 'status'"
|
|
:direction="$sortDirection"
|
|
wire:click="sort('status')"
|
|
>
|
|
Status
|
|
</flux:table.column>
|
|
<flux:table.column
|
|
sortable
|
|
:sorted="$sortBy === 'uploaded'"
|
|
:direction="$sortDirection"
|
|
wire:click="sort('uploaded')"
|
|
>
|
|
Uploaded
|
|
</flux:table.column>
|
|
<flux:table.column class="w-24"></flux:table.column>
|
|
</flux:table.columns>
|
|
|
|
<flux:table.rows>
|
|
@foreach ($recordings as $recording)
|
|
<flux:table.row wire:key="recording-{{ $recording->id }}-{{ $recording->transcription_status }}-{{ $recording->transcription_percent }}">
|
|
<flux:table.cell class="max-w-xl">
|
|
<div class="flex min-w-0 items-center gap-2">
|
|
<flux:link
|
|
href="{{ route('recordings.show', $recording) }}"
|
|
wire:navigate
|
|
class="shrink-0 font-medium"
|
|
>
|
|
{{ $recording->title }}
|
|
</flux:link>
|
|
<flux:button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
square
|
|
class="shrink-0"
|
|
data-audio-url="{{ route('recordings.audio', $recording) }}"
|
|
x-bind:aria-label="isPlayingRow({{ $recording->id }}) ? 'Pause' : 'Play'"
|
|
x-on:click="togglePlay({{ $recording->id }}, $el.dataset.audioUrl)"
|
|
>
|
|
<flux:icon.play
|
|
variant="micro"
|
|
x-show="! isPlayingRow({{ $recording->id }})"
|
|
/>
|
|
<flux:icon.pause
|
|
variant="micro"
|
|
x-show="isPlayingRow({{ $recording->id }})"
|
|
x-cloak
|
|
/>
|
|
</flux:button>
|
|
@if ($preview = $recording->transcriptFirstLine())
|
|
<span class="min-w-0 truncate text-sm text-zinc-500 dark:text-zinc-400" title="{{ $preview }}">
|
|
{{ $preview }}
|
|
</span>
|
|
@endif
|
|
</div>
|
|
</flux:table.cell>
|
|
<flux:table.cell>{{ $recording->duration_formatted }}</flux:table.cell>
|
|
<flux:table.cell>
|
|
<span class="tabular-nums">
|
|
{{ $recording->word_count > 0 ? number_format($recording->word_count) : '—' }}
|
|
</span>
|
|
</flux:table.cell>
|
|
<flux:table.cell class="w-36 whitespace-nowrap">
|
|
<x-transcription-status-badge
|
|
:status="$recording->transcription_status"
|
|
:label="$recording->transcriptionStatusLabel()"
|
|
/>
|
|
</flux:table.cell>
|
|
<flux:table.cell>
|
|
{{ $recording->created_at?->format('Y-m-d H:i') }}
|
|
</flux:table.cell>
|
|
<flux:table.cell>
|
|
<flux:modal.trigger name="delete-recording-{{ $recording->id }}">
|
|
<flux:button
|
|
type="button"
|
|
variant="danger"
|
|
size="sm"
|
|
square
|
|
aria-label="Delete {{ $recording->title }}"
|
|
>
|
|
<flux:icon.trash variant="micro" />
|
|
</flux:button>
|
|
</flux:modal.trigger>
|
|
|
|
<flux:modal name="delete-recording-{{ $recording->id }}" class="max-w-md">
|
|
<div class="space-y-6">
|
|
<div>
|
|
<flux:heading size="lg">Delete recording?</flux:heading>
|
|
<flux:text class="mt-2">
|
|
This permanently removes “{{ $recording->title }}” and its audio file.
|
|
</flux:text>
|
|
</div>
|
|
<div class="flex justify-end gap-2">
|
|
<flux:modal.close>
|
|
<flux:button variant="ghost">Cancel</flux:button>
|
|
</flux:modal.close>
|
|
<flux:button
|
|
type="button"
|
|
variant="danger"
|
|
wire:click="delete({{ $recording->id }})"
|
|
>
|
|
Delete
|
|
</flux:button>
|
|
</div>
|
|
</div>
|
|
</flux:modal>
|
|
</flux:table.cell>
|
|
</flux:table.row>
|
|
@endforeach
|
|
</flux:table.rows>
|
|
</flux:table>
|
|
@endif
|
|
</div>
|