Move recordings UI to Livewire pages with Flux components.
Replace controller-driven Blade views with full-page Livewire index/show/create flows so Flux tables, toasts, and uploads work natively.
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
<div>
|
||||
<div class="mb-8">
|
||||
<flux:heading size="xl">Upload recordings</flux:heading>
|
||||
<flux:text class="mt-1">
|
||||
Drop one or many pocket-recorder files. Embedded metadata is extracted when available.
|
||||
Duplicate files (same name and size, or identical content) are skipped.
|
||||
</flux:text>
|
||||
</div>
|
||||
|
||||
<livewire:upload-recordings />
|
||||
</div>
|
||||
@@ -0,0 +1,160 @@
|
||||
<div
|
||||
x-data="recordingsIndex(@js([
|
||||
'recordings' => $recordings->map(fn ($recording) => [
|
||||
'id' => $recording->id,
|
||||
'status' => $recording->transcription_status,
|
||||
'status_label' => $recording->transcriptionStatusLabel(),
|
||||
'progress' => $recording->transcription_progress,
|
||||
'percent' => $recording->transcription_percent,
|
||||
'is_active' => $recording->isTranscribing(),
|
||||
'word_count' => $recording->word_count,
|
||||
'word_count_display' => $recording->word_count > 0
|
||||
? number_format($recording->word_count)
|
||||
: '—',
|
||||
'badge_color' => match ($recording->transcription_status) {
|
||||
'done' => 'teal',
|
||||
'processing', 'pending' => 'amber',
|
||||
'failed' => 'red',
|
||||
default => 'zinc',
|
||||
},
|
||||
])->values(),
|
||||
'pendingCount' => $pendingCount,
|
||||
]))"
|
||||
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 x-show="pendingCount > 0" x-cloak>
|
||||
<flux:button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
wire:click="queuePending"
|
||||
>
|
||||
Queue <span x-text="pendingCount"></span> pending
|
||||
<span x-text="pendingCount === 1 ? 'transcription' : 'transcriptions'"></span>
|
||||
</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($recordings->isEmpty())
|
||||
<flux:card class="border-dashed py-16 text-center">
|
||||
@if (filled($search))
|
||||
<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>
|
||||
@else
|
||||
<flux:text>No recordings yet.</flux:text>
|
||||
<div class="mt-4">
|
||||
<flux:link href="{{ route('recordings.create') }}" wire:navigate>Upload your first MP3</flux:link>
|
||||
</div>
|
||||
@endif
|
||||
</flux:card>
|
||||
@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 class="w-12"></flux:table.column>
|
||||
<flux:table.column>Title</flux:table.column>
|
||||
<flux:table.column>Duration</flux:table.column>
|
||||
<flux:table.column>Words</flux:table.column>
|
||||
<flux:table.column>Status</flux:table.column>
|
||||
<flux:table.column>Uploaded</flux:table.column>
|
||||
</flux:table.columns>
|
||||
|
||||
<flux:table.rows>
|
||||
@foreach ($recordings as $recording)
|
||||
<flux:table.row wire:key="recording-{{ $recording->id }}">
|
||||
<flux:table.cell>
|
||||
<flux:button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
square
|
||||
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>
|
||||
</flux:table.cell>
|
||||
<flux:table.cell class="whitespace-normal">
|
||||
<flux:link href="{{ route('recordings.show', $recording) }}" wire:navigate class="font-medium">
|
||||
{{ $recording->title }}
|
||||
</flux:link>
|
||||
@if ($recording->artist)
|
||||
<flux:text class="mt-0.5 text-xs">{{ $recording->artist }}</flux:text>
|
||||
@endif
|
||||
@if ($snippet = $recording->transcriptSnippet($search ?: null))
|
||||
<flux:text class="mt-1 max-w-xl text-xs leading-relaxed">
|
||||
{{ $snippet }}
|
||||
</flux:text>
|
||||
@endif
|
||||
</flux:table.cell>
|
||||
<flux:table.cell>{{ $recording->duration_formatted }}</flux:table.cell>
|
||||
<flux:table.cell>
|
||||
<span
|
||||
class="tabular-nums"
|
||||
x-text="row({{ $recording->id }}).word_count_display"
|
||||
>
|
||||
{{ $recording->word_count > 0 ? number_format($recording->word_count) : '—' }}
|
||||
</span>
|
||||
</flux:table.cell>
|
||||
<flux:table.cell class="whitespace-normal py-2">
|
||||
<x-transcription-status-badge
|
||||
:status="$recording->transcription_status"
|
||||
:label="$recording->transcriptionStatusLabel()"
|
||||
:alpine-row="'row('.$recording->id.')'"
|
||||
/>
|
||||
<div
|
||||
class="mt-1 max-w-[14rem] truncate text-xs text-amber-700 dark:text-amber-300"
|
||||
x-show="row({{ $recording->id }}).is_active && row({{ $recording->id }}).progress"
|
||||
x-cloak
|
||||
:title="row({{ $recording->id }}).progress"
|
||||
>
|
||||
<span x-text="row({{ $recording->id }}).percent ? (row({{ $recording->id }}).percent + '% · ') : ''"></span>
|
||||
<span x-text="row({{ $recording->id }}).progress"></span>
|
||||
</div>
|
||||
</flux:table.cell>
|
||||
<flux:table.cell>
|
||||
{{ $recording->created_at?->format('Y-m-d H:i') }}
|
||||
</flux:table.cell>
|
||||
</flux:table.row>
|
||||
@endforeach
|
||||
</flux:table.rows>
|
||||
</flux:table>
|
||||
@endif
|
||||
</div>
|
||||
@@ -0,0 +1,210 @@
|
||||
<div
|
||||
x-data="transcriptionMonitor(@js([
|
||||
'statusUrl' => route('recordings.transcription-status', $recording),
|
||||
'initial' => $recording->transcriptionStatusPayload(),
|
||||
]))"
|
||||
x-init="
|
||||
start();
|
||||
return () => destroy();
|
||||
"
|
||||
>
|
||||
<div class="mb-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<flux:link href="{{ route('recordings.index') }}" wire:navigate class="text-sm">← Recordings</flux:link>
|
||||
<flux:heading size="xl" class="mt-2">{{ $recording->title }}</flux:heading>
|
||||
<div class="mt-2 flex flex-wrap items-center gap-2">
|
||||
<x-transcription-status-badge
|
||||
:status="$recording->transcription_status"
|
||||
:label="$recording->transcriptionStatusLabel()"
|
||||
alpine-row="status"
|
||||
/>
|
||||
<flux:text
|
||||
class="text-xs"
|
||||
x-show="status.driver_label"
|
||||
x-cloak
|
||||
x-text="status.driver_label"
|
||||
></flux:text>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<flux:button
|
||||
type="button"
|
||||
variant="primary"
|
||||
icon="play"
|
||||
@click="$refs.player.paused ? $refs.player.play() : $refs.player.pause()"
|
||||
>
|
||||
Play
|
||||
</flux:button>
|
||||
|
||||
<flux:modal.trigger name="delete-recording">
|
||||
<flux:button type="button" variant="danger">Delete</flux:button>
|
||||
</flux:modal.trigger>
|
||||
|
||||
<flux:modal name="delete-recording" 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 the recording 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">Delete</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
</flux:modal>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<flux:card class="mb-6">
|
||||
<flux:heading size="sm" class="uppercase tracking-wide text-zinc-500 dark:text-zinc-400">Audio</flux:heading>
|
||||
<audio
|
||||
x-ref="player"
|
||||
class="mt-4 w-full"
|
||||
controls
|
||||
preload="metadata"
|
||||
src="{{ route('recordings.audio', $recording) }}"
|
||||
>
|
||||
Your browser does not support audio playback.
|
||||
</audio>
|
||||
</flux:card>
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-2">
|
||||
<flux:card>
|
||||
<flux:heading size="sm" class="uppercase tracking-wide text-zinc-500 dark:text-zinc-400">Metadata</flux:heading>
|
||||
<dl class="mt-4 space-y-3 text-sm">
|
||||
<div class="flex justify-between gap-4">
|
||||
<dt><flux:text>Original file</flux:text></dt>
|
||||
<dd class="text-right font-medium">{{ $recording->original_filename }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-4">
|
||||
<dt><flux:text>Duration</flux:text></dt>
|
||||
<dd class="font-medium">{{ $recording->duration_formatted }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-4">
|
||||
<dt><flux:text>Artist</flux:text></dt>
|
||||
<dd class="font-medium">{{ $recording->artist ?: '—' }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-4">
|
||||
<dt><flux:text>Album</flux:text></dt>
|
||||
<dd class="font-medium">{{ $recording->album ?: '—' }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-4">
|
||||
<dt><flux:text>Recorded</flux:text></dt>
|
||||
<dd class="font-medium">{{ $recording->recorded_at?->format('Y-m-d') ?: '—' }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-4">
|
||||
<dt><flux:text>Size</flux:text></dt>
|
||||
<dd class="font-medium">{{ number_format($recording->file_size_bytes / 1024, 1) }} KB</dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-4">
|
||||
<dt><flux:text>Uploaded</flux:text></dt>
|
||||
<dd class="font-medium">{{ $recording->created_at?->format('Y-m-d H:i') }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</flux:card>
|
||||
|
||||
<flux:card>
|
||||
<flux:heading size="sm" class="uppercase tracking-wide text-zinc-500 dark:text-zinc-400">Transcribe</flux:heading>
|
||||
<div class="mt-4">
|
||||
<flux:button type="button" variant="primary" wire:click="startTranscription">
|
||||
<span x-text="startButtonLabel"></span>
|
||||
</flux:button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
x-show="status.is_active"
|
||||
x-cloak
|
||||
class="mt-3"
|
||||
>
|
||||
<flux:button type="button" variant="outline" wire:click="cancelTranscription">
|
||||
Stop transcription
|
||||
</flux:button>
|
||||
</div>
|
||||
</flux:card>
|
||||
</div>
|
||||
|
||||
<flux:card class="mt-6">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<flux:heading size="sm" class="uppercase tracking-wide text-zinc-500 dark:text-zinc-400">Transcript</flux:heading>
|
||||
<flux:button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
x-show="!status.is_active && status.has_transcript"
|
||||
x-cloak
|
||||
@click="navigator.clipboard.writeText(status.transcript || '')"
|
||||
>
|
||||
Copy
|
||||
</flux:button>
|
||||
</div>
|
||||
|
||||
<div x-show="status.is_active" x-cloak class="mt-4">
|
||||
<flux:callout variant="warning" icon="arrow-path">
|
||||
<flux:callout.heading>
|
||||
<span x-text="status.progress || 'Working…'"></span>
|
||||
</flux:callout.heading>
|
||||
<flux:callout.text>
|
||||
<span class="tabular-nums" x-text="(status.percent ?? 0) + '%'"></span>
|
||||
· Elapsed <span x-text="status.elapsed_human || '0s'"></span>
|
||||
</flux:callout.text>
|
||||
</flux:callout>
|
||||
|
||||
<div class="mt-3">
|
||||
<flux:progress color="amber" x-bind:value="Math.max(status.percent || 5, 5)" />
|
||||
</div>
|
||||
|
||||
<ul class="mt-3 space-y-1 text-xs text-zinc-600 dark:text-zinc-400">
|
||||
<li>
|
||||
Engine:
|
||||
<span class="font-medium" x-text="status.driver_label || '—'"></span>
|
||||
</li>
|
||||
<template x-if="status.duration_seconds">
|
||||
<li>
|
||||
Audio length:
|
||||
<span class="font-medium" x-text="formatDuration(status.duration_seconds)"></span>
|
||||
<span class="text-zinc-500">(longer files take longer)</span>
|
||||
</li>
|
||||
</template>
|
||||
<li x-show="pollError" class="text-red-600 dark:text-red-400" x-text="pollError"></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div x-show="!status.is_active && status.status === 'cancelled'" x-cloak class="mt-4">
|
||||
<flux:callout icon="stop-circle">
|
||||
<flux:callout.text>
|
||||
Transcription stopped. Use the button above to start again.
|
||||
</flux:callout.text>
|
||||
</flux:callout>
|
||||
</div>
|
||||
|
||||
<div x-show="!status.is_active && status.status === 'failed'" x-cloak class="mt-4">
|
||||
<flux:callout variant="danger" icon="exclamation-triangle">
|
||||
<flux:callout.heading>Transcription failed</flux:callout.heading>
|
||||
<flux:callout.text>
|
||||
<span x-text="status.error || 'Check the logs and try again.'"></span>
|
||||
</flux:callout.text>
|
||||
</flux:callout>
|
||||
</div>
|
||||
|
||||
<div x-show="!status.is_active && status.has_transcript" x-cloak>
|
||||
<p class="mt-4 whitespace-pre-wrap text-sm leading-relaxed text-zinc-800 dark:text-zinc-100" x-text="status.transcript"></p>
|
||||
<flux:text
|
||||
class="mt-4 text-xs"
|
||||
x-show="status.transcribed_at"
|
||||
x-text="status.transcribed_at ? ('Transcribed ' + formatTimestamp(status.transcribed_at)) : ''"
|
||||
></flux:text>
|
||||
</div>
|
||||
|
||||
<flux:text
|
||||
x-show="!status.is_active && status.status !== 'failed' && status.status !== 'cancelled' && !status.has_transcript"
|
||||
x-cloak
|
||||
class="mt-4"
|
||||
>
|
||||
No transcript yet. Transcription starts automatically after upload, or use the button above.
|
||||
</flux:text>
|
||||
</flux:card>
|
||||
</div>
|
||||
@@ -0,0 +1,67 @@
|
||||
<div
|
||||
class="max-w-2xl space-y-6 rounded border border-stone-200 bg-white p-6 shadow-sm dark:border-zinc-700 dark:bg-zinc-800"
|
||||
x-data="{ uploading: false, progress: 0 }"
|
||||
x-on:livewire-upload-start="uploading = true; progress = 0"
|
||||
x-on:livewire-upload-finish="uploading = false; progress = 100"
|
||||
x-on:livewire-upload-error="uploading = false"
|
||||
x-on:livewire-upload-progress="progress = $event.detail.progress"
|
||||
>
|
||||
<flux:input
|
||||
wire:model="title"
|
||||
label="Title (optional)"
|
||||
description="Used when you upload a single file. Leave blank to use embedded title or filename."
|
||||
placeholder="Leave blank to use embedded title or filename"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<flux:label>Audio files</flux:label>
|
||||
|
||||
<div
|
||||
wire:drop.file="$upload('audio', { multiple: true, accept: '.mp3,.wav,.ogg,.oga,.flac,.m4a,.mp4,.aac,.webm,.wma,.aiff,.aif,audio/*' })"
|
||||
wire:click="$upload('audio', { multiple: true, accept: '.mp3,.wav,.ogg,.oga,.flac,.m4a,.mp4,.aac,.webm,.wma,.aiff,.aif,audio/*' })"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
wire:keydown.enter="$upload('audio', { multiple: true, accept: '.mp3,.wav,.ogg,.oga,.flac,.m4a,.mp4,.aac,.webm,.wma,.aiff,.aif,audio/*' })"
|
||||
class="mt-2 flex cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed border-zinc-300 bg-zinc-50 px-6 py-10 text-center transition-colors data-dragging:border-accent data-dragging:bg-accent/5 dark:border-white/20 dark:bg-white/5 dark:data-dragging:border-accent dark:data-dragging:bg-accent/10"
|
||||
>
|
||||
<div class="mb-3 flex size-12 items-center justify-center rounded-full bg-white shadow-sm ring-1 ring-zinc-200 dark:bg-zinc-800 dark:ring-white/10">
|
||||
<flux:icon.cloud-arrow-up class="size-6 text-zinc-500 dark:text-zinc-400" />
|
||||
</div>
|
||||
|
||||
<flux:heading size="sm" class="text-zinc-800 dark:text-zinc-100">
|
||||
Drop audio files here or click to browse
|
||||
</flux:heading>
|
||||
|
||||
<flux:text class="mt-1 max-w-sm text-zinc-500 dark:text-zinc-400">
|
||||
MP3, WAV, OGG, FLAC, M4A, AAC, WebM, WMA, or AIFF · max 2 GB each · up to 50 files
|
||||
</flux:text>
|
||||
|
||||
<div
|
||||
x-show="uploading || $wire.saving"
|
||||
x-cloak
|
||||
class="mt-5 w-full max-w-sm space-y-2"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3 text-xs text-zinc-600 dark:text-zinc-400">
|
||||
<span x-text="$wire.saving ? 'Saving recordings…' : 'Uploading…'"></span>
|
||||
<span x-show="uploading" x-text="progress + '%'"></span>
|
||||
</div>
|
||||
<flux:progress color="teal" x-bind:value="uploading ? progress : 100" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@error('audio')
|
||||
<flux:error>{{ $message }}</flux:error>
|
||||
@enderror
|
||||
@error('audio.*')
|
||||
<flux:error>{{ $message }}</flux:error>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<flux:text class="text-sm text-zinc-500 dark:text-zinc-400">
|
||||
Uploads start as soon as you drop or choose files. Duplicate files (same name and size, or identical content) are skipped.
|
||||
</flux:text>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<flux:link href="{{ route('recordings.index') }}">Cancel</flux:link>
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user