Improve live recordings UI, upload flow, and default Flux theme.
Refresh list status over Reverb with polling fallback, clarify queued vs processing, streamline upload empty states, and seed an admin login.
This commit is contained in:
@@ -1,10 +1,6 @@
|
||||
<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 />
|
||||
|
||||
@@ -1,25 +1,8 @@
|
||||
<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,
|
||||
]))"
|
||||
@if ($hasActiveTranscriptions)
|
||||
wire:poll.2s.visible
|
||||
@endif
|
||||
x-data="recordingsIndex()"
|
||||
x-init="
|
||||
start();
|
||||
return () => destroy();
|
||||
@@ -39,34 +22,64 @@
|
||||
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 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())
|
||||
<flux:card class="border-dashed py-16 text-center">
|
||||
@if (filled($search))
|
||||
@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>
|
||||
@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>
|
||||
</flux:card>
|
||||
@else
|
||||
<livewire:upload-recordings :show-cancel="false" />
|
||||
@endif
|
||||
@else
|
||||
<audio
|
||||
x-ref="player"
|
||||
@@ -90,7 +103,7 @@
|
||||
|
||||
<flux:table.rows>
|
||||
@foreach ($recordings as $recording)
|
||||
<flux:table.row wire:key="recording-{{ $recording->id }}">
|
||||
<flux:table.row wire:key="recording-{{ $recording->id }}-{{ $recording->transcription_status }}-{{ $recording->transcription_percent }}">
|
||||
<flux:table.cell>
|
||||
<flux:button
|
||||
type="button"
|
||||
@@ -127,10 +140,7 @@
|
||||
</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"
|
||||
>
|
||||
<span class="tabular-nums">
|
||||
{{ $recording->word_count > 0 ? number_format($recording->word_count) : '—' }}
|
||||
</span>
|
||||
</flux:table.cell>
|
||||
@@ -138,17 +148,25 @@
|
||||
<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>
|
||||
@if ($recording->transcription_status === 'pending' && filled($recording->transcription_progress))
|
||||
<div
|
||||
class="mt-1 max-w-[14rem] truncate text-xs text-zinc-500 dark:text-zinc-400"
|
||||
title="{{ $recording->transcription_progress }}"
|
||||
>
|
||||
{{ $recording->transcription_progress }}
|
||||
</div>
|
||||
@elseif ($recording->transcription_status === 'processing' && filled($recording->transcription_progress))
|
||||
<div
|
||||
class="mt-1 max-w-[14rem] truncate text-xs text-amber-700 dark:text-amber-300"
|
||||
title="{{ $recording->transcription_progress }}"
|
||||
>
|
||||
@if ($recording->transcription_percent)
|
||||
{{ $recording->transcription_percent }}% ·
|
||||
@endif
|
||||
{{ $recording->transcription_progress }}
|
||||
</div>
|
||||
@endif
|
||||
</flux:table.cell>
|
||||
<flux:table.cell>
|
||||
{{ $recording->created_at?->format('Y-m-d H:i') }}
|
||||
|
||||
@@ -145,16 +145,31 @@
|
||||
<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>
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<flux:icon.loading
|
||||
variant="micro"
|
||||
class="size-4"
|
||||
x-show="status.status === 'processing'"
|
||||
x-cloak
|
||||
/>
|
||||
<span x-text="status.progress || 'Working…'"></span>
|
||||
</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>
|
||||
<span
|
||||
class="tabular-nums"
|
||||
x-show="status.status === 'processing' && status.percent != null"
|
||||
x-cloak
|
||||
>
|
||||
<span x-text="status.percent + '%'"></span>
|
||||
·
|
||||
</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 class="mt-3" x-show="status.status === 'processing'" x-cloak>
|
||||
<flux:progress color="amber" x-bind:value="status.percent || 0" />
|
||||
</div>
|
||||
|
||||
<ul class="mt-3 space-y-1 text-xs text-zinc-600 dark:text-zinc-400">
|
||||
|
||||
@@ -1,28 +1,59 @@
|
||||
<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 }"
|
||||
class="max-w-2xl space-y-6 rounded-lg border border-zinc-200 bg-white p-6 shadow-sm dark:border-zinc-700 dark:bg-zinc-800"
|
||||
x-data="{
|
||||
uploading: false,
|
||||
progress: 0,
|
||||
dragging: false,
|
||||
openPicker() {
|
||||
this.$refs.fileInput.click();
|
||||
},
|
||||
onDrop(event) {
|
||||
this.dragging = false;
|
||||
|
||||
const files = event.dataTransfer?.files;
|
||||
|
||||
if (! files?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const transfer = new DataTransfer();
|
||||
|
||||
Array.from(files).forEach((file) => transfer.items.add(file));
|
||||
|
||||
this.$refs.fileInput.files = transfer.files;
|
||||
this.$refs.fileInput.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
},
|
||||
}"
|
||||
x-on:livewire-upload-start="uploading = true; progress = 0"
|
||||
x-on:livewire-upload-finish="uploading = false; progress = 100"
|
||||
x-on:livewire-upload-cancel="uploading = false"
|
||||
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>
|
||||
|
||||
<input
|
||||
x-ref="fileInput"
|
||||
type="file"
|
||||
class="sr-only"
|
||||
wire:model="audio"
|
||||
multiple
|
||||
accept=".mp3,.wav,.ogg,.oga,.flac,.m4a,.mp4,.aac,.webm,.wma,.aiff,.aif,audio/*"
|
||||
>
|
||||
|
||||
<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"
|
||||
x-on:click="openPicker()"
|
||||
x-on:keydown.enter.prevent="openPicker()"
|
||||
x-on:keydown.space.prevent="openPicker()"
|
||||
x-on:dragenter.prevent="dragging = true"
|
||||
x-on:dragover.prevent="dragging = true"
|
||||
x-on:dragleave.prevent="dragging = false"
|
||||
x-on:drop.prevent="onDrop($event)"
|
||||
x-bind:class="dragging ? 'border-accent bg-accent/5 dark:border-accent dark:bg-accent/10' : 'border-zinc-300 bg-zinc-50 dark:border-white/20 dark:bg-white/5'"
|
||||
class="mt-2 flex cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed px-6 py-10 text-center transition-colors"
|
||||
>
|
||||
<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" />
|
||||
@@ -57,11 +88,9 @@
|
||||
@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>
|
||||
@if ($showCancel)
|
||||
<div class="flex items-center gap-3">
|
||||
<flux:link href="{{ route('recordings.index') }}" wire:navigate>Cancel</flux:link>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user