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:
ben
2026-08-12 19:17:52 +02:00
parent bfcfc12f58
commit 34ccf0c32b
37 changed files with 1482 additions and 1103 deletions
@@ -1,29 +1,24 @@
@php
/** @var array{used_percent: float, free_percent: float, free_human: string, total_human: string, used_human: string} $disk */
@endphp
<div class="border-b border-stone-200 bg-white dark:border-zinc-700 dark:bg-zinc-800">
<div class="mx-auto max-w-5xl px-4 py-2 sm:px-6">
<div class="flex items-center justify-between gap-3 text-xs text-stone-600 dark:text-zinc-400">
<span class="font-medium text-stone-700 dark:text-zinc-200">Disk space</span>
<span class="tabular-nums">
{{ $disk['free_human'] }} free
<span class="text-stone-400 dark:text-zinc-500">·</span>
{{ $disk['used_percent'] }}% used of {{ $disk['total_human'] }}
</span>
</div>
<div
class="flex items-center gap-2 rounded-lg border border-stone-200 bg-stone-50 px-2.5 py-1.5 dark:border-zinc-600 dark:bg-zinc-900/50"
title="{{ $disk['free_human'] }} free of {{ $disk['total_human'] }} · {{ $disk['used_percent'] }}% used"
>
<div
class="h-1.5 w-14 shrink-0 overflow-hidden rounded-full bg-stone-200 dark:bg-zinc-700"
role="progressbar"
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow="{{ (int) round($disk['used_percent']) }}"
aria-label="Disk space used"
>
<div
class="mt-1.5 h-1.5 overflow-hidden rounded-full bg-stone-200 dark:bg-zinc-700"
role="progressbar"
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow="{{ (int) round($disk['used_percent']) }}"
aria-label="Disk space used"
title="{{ $disk['free_human'] }} free of {{ $disk['total_human'] }}"
>
<div
class="h-full rounded-full transition-[width] duration-300 {{ $barColor }}"
style="width: {{ min(100, max(0, $disk['used_percent'])) }}%"
></div>
</div>
class="h-full rounded-full transition-[width] duration-300 {{ $barColor }}"
style="width: {{ min(100, max(0, $disk['used_percent'])) }}%"
></div>
</div>
<span class="hidden text-xs font-medium tabular-nums text-stone-600 sm:inline dark:text-zinc-300">
{{ $disk['free_human'] }} free
</span>
</div>
@@ -0,0 +1,51 @@
@props([
'status',
'label' => null,
/** @var string|null Alpine expression that returns a row object with badge_color + status_label */
'alpineRow' => null,
])
@php
$label ??= match ($status) {
'pending' => 'Queued',
'processing' => 'Transcribing',
'done' => 'Done',
'failed' => 'Failed',
'cancelled' => 'Cancelled',
default => (string) $status,
};
$color = match ($status) {
'done' => 'teal',
'processing', 'pending' => 'amber',
'failed' => 'red',
default => 'zinc',
};
@endphp
@if ($alpineRow)
<span {{ $attributes->class('inline-flex') }}>
@foreach (['teal', 'amber', 'red', 'zinc'] as $badgeColor)
@if ($badgeColor === $color)
<flux:badge
size="sm"
:color="$badgeColor"
x-show="{{ $alpineRow }}.badge_color === '{{ $badgeColor }}'"
x-text="{{ $alpineRow }}.status_label"
>{{ $label }}</flux:badge>
@else
<flux:badge
size="sm"
:color="$badgeColor"
x-show="{{ $alpineRow }}.badge_color === '{{ $badgeColor }}'"
x-cloak
x-text="{{ $alpineRow }}.status_label"
>{{ $label }}</flux:badge>
@endif
@endforeach
</span>
@else
<flux:badge size="sm" :color="$color" {{ $attributes }}>
{{ $label }}
</flux:badge>
@endif
+27 -21
View File
@@ -1,17 +1,15 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="dark">
<head>
@include('partials.head', ['title' => trim($__env->yieldContent('title')) ?: null])
@include('partials.head', ['title' => $title ?? null])
<style>[x-cloak]{display:none!important}</style>
</head>
<body class="min-h-screen bg-stone-100 text-stone-900 antialiased dark:bg-zinc-900 dark:text-zinc-100">
<x-disk-space-bar />
<flux:header class="border-b border-stone-200 bg-white dark:border-zinc-700 dark:bg-zinc-800">
<div class="mx-auto flex w-full max-w-5xl items-center gap-4 px-4 sm:px-6">
<flux:sidebar.toggle class="lg:hidden" icon="bars-2" inset="left" />
<a href="{{ route('recordings.index') }}" class="text-lg font-semibold tracking-tight text-teal-800 dark:text-teal-300">
<a href="{{ route('recordings.index') }}" wire:navigate class="text-lg font-semibold tracking-tight text-teal-800 dark:text-teal-300">
AndyTranscribe
</a>
@@ -19,12 +17,14 @@
<flux:navbar.item
:href="route('recordings.index')"
:current="request()->routeIs('recordings.index', 'recordings.show')"
wire:navigate
>
{{ __('Recordings') }}
</flux:navbar.item>
<flux:navbar.item
:href="route('recordings.create')"
:current="request()->routeIs('recordings.create')"
wire:navigate
>
{{ __('Upload') }}
</flux:navbar.item>
@@ -32,6 +32,8 @@
<flux:spacer />
<x-disk-space-bar />
<x-appearance-toggle />
@auth
@@ -42,17 +44,17 @@
<flux:sidebar collapsible="mobile" sticky class="border-e border-stone-200 bg-white lg:hidden dark:border-zinc-700 dark:bg-zinc-800">
<flux:sidebar.header>
<a href="{{ route('recordings.index') }}" class="text-base font-semibold text-teal-800 dark:text-teal-300">
<a href="{{ route('recordings.index') }}" wire:navigate class="text-base font-semibold text-teal-800 dark:text-teal-300">
AndyTranscribe
</a>
<flux:sidebar.collapse />
</flux:sidebar.header>
<flux:sidebar.nav>
<flux:sidebar.item :href="route('recordings.index')" :current="request()->routeIs('recordings.*')">
<flux:sidebar.item :href="route('recordings.index')" :current="request()->routeIs('recordings.*')" wire:navigate>
{{ __('Recordings') }}
</flux:sidebar.item>
<flux:sidebar.item :href="route('recordings.create')" :current="request()->routeIs('recordings.create')">
<flux:sidebar.item :href="route('recordings.create')" :current="request()->routeIs('recordings.create')" wire:navigate>
{{ __('Upload') }}
</flux:sidebar.item>
</flux:sidebar.nav>
@@ -60,30 +62,34 @@
<main class="mx-auto max-w-5xl px-4 py-8 sm:px-6">
@if (session('success'))
<div class="mb-6 rounded border border-teal-200 bg-teal-50 px-4 py-3 text-sm text-teal-900 dark:border-teal-800 dark:bg-teal-950 dark:text-teal-100">
{{ session('success') }}
</div>
<flux:callout variant="success" icon="check-circle" class="mb-6">
<flux:callout.text>{{ session('success') }}</flux:callout.text>
</flux:callout>
@endif
@if (session('error'))
<div class="mb-6 rounded border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-900 dark:border-red-900 dark:bg-red-950 dark:text-red-100">
{{ session('error') }}
</div>
<flux:callout variant="danger" icon="exclamation-triangle" class="mb-6">
<flux:callout.text>{{ session('error') }}</flux:callout.text>
</flux:callout>
@endif
@if ($errors->any())
<div class="mb-6 rounded border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-900 dark:border-red-900 dark:bg-red-950 dark:text-red-100">
<ul class="list-disc space-y-1 pl-5">
@foreach ($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
</div>
<flux:callout variant="danger" icon="exclamation-triangle" class="mb-6">
<flux:callout.text>
<ul class="list-disc space-y-1 pl-5">
@foreach ($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
</flux:callout.text>
</flux:callout>
@endif
@yield('content')
{{ $slot }}
</main>
<flux:toast />
@fluxScripts
</body>
</html>
+1 -2
View File
@@ -5,14 +5,13 @@
<style>[x-cloak]{display:none!important}</style>
</head>
<body class="min-h-screen bg-stone-100 text-stone-900 antialiased dark:bg-zinc-900 dark:text-zinc-100">
<x-disk-space-bar />
<flux:header class="border-b border-stone-200 bg-white dark:border-zinc-700 dark:bg-zinc-800">
<div class="mx-auto flex w-full max-w-5xl items-center gap-4 px-4 sm:px-6">
<a href="{{ route('recordings.index') }}" class="text-lg font-semibold tracking-tight text-teal-800 dark:text-teal-300">
AndyTranscribe
</a>
<flux:spacer />
<x-disk-space-bar />
<x-appearance-toggle />
@auth
<x-desktop-user-menu />
@@ -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>
-109
View File
@@ -1,109 +0,0 @@
@extends('layouts.app')
@section('title', 'Upload recording')
@section('content')
<div class="mb-8">
<h1 class="text-2xl font-semibold tracking-tight">Upload recordings</h1>
<p class="mt-1 text-sm text-stone-600 dark:text-zinc-400">
Drop one or many pocket-recorder files. Embedded metadata is extracted when available.
Duplicate files (same name and size, or identical content) are skipped.
</p>
</div>
<form
method="POST"
action="{{ route('recordings.store') }}"
enctype="multipart/form-data"
x-data="uploadDropzone(@js(['existingFingerprints' => $existingFingerprints ?? []]))"
@submit="ensureFilesSelected($event)"
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"
>
@csrf
<div>
<label class="block text-sm font-medium text-stone-700 dark:text-zinc-200">Audio files</label>
<div
@dragenter.prevent="dragging = true"
@dragover.prevent="dragging = true"
@dragleave.prevent="dragging = false"
@drop.prevent="onDrop($event)"
@click="$refs.fileInput.click()"
:class="dragging ? 'border-teal-600 bg-teal-50 dark:bg-teal-950/40' : 'border-stone-300 bg-stone-50 hover:border-teal-500 hover:bg-teal-50/40 dark:border-zinc-600 dark:bg-zinc-900/50 dark:hover:border-teal-500 dark:hover:bg-teal-950/30'"
class="mt-2 flex cursor-pointer flex-col items-center justify-center rounded border-2 border-dashed px-6 py-12 text-center transition-colors"
>
<p class="text-sm font-medium text-stone-800 dark:text-zinc-100">Drop audio files here</p>
<p class="mt-1 text-sm text-stone-600 dark:text-zinc-400">or click to browse</p>
<p class="mt-3 text-xs text-stone-500 dark:text-zinc-500">MP3, WAV, OGG, FLAC, M4A, AAC, WebM, WMA, or AIFF · max 2 GB each · up to 50 files</p>
</div>
<input
x-ref="fileInput"
id="audio"
type="file"
name="audio[]"
accept=".mp3,.wav,.ogg,.oga,.flac,.m4a,.mp4,.aac,.webm,.wma,.aiff,.aif,audio/*"
multiple
class="sr-only"
@change="onBrowse($event)"
>
</div>
<div x-show="files.length > 0" x-cloak class="space-y-2">
<div class="flex items-center justify-between gap-3">
<p class="text-sm font-medium text-stone-700 dark:text-zinc-200">
<span x-text="files.length"></span>
<span x-text="files.length === 1 ? 'file selected' : 'files selected'"></span>
</p>
<button type="button" @click="clearFiles()" class="text-sm text-stone-600 dark:text-zinc-400 hover:underline dark:text-zinc-400">
Clear all
</button>
</div>
<ul class="divide-y divide-stone-100 rounded border border-stone-200 dark:divide-zinc-700 dark:border-zinc-700">
<template x-for="(file, index) in files" :key="fileListKey(file, index)">
<li class="flex items-center justify-between gap-3 px-3 py-2 text-sm">
<div class="min-w-0">
<p class="truncate font-medium text-stone-800 dark:text-zinc-100" x-text="file.name"></p>
<p class="text-xs text-stone-500 dark:text-zinc-400" x-text="formatSize(file.size)"></p>
</div>
<button
type="button"
@click="removeFile(index)"
class="shrink-0 text-stone-500 hover:text-red-700 dark:text-zinc-400 dark:hover:text-red-400"
>
Remove
</button>
</li>
</template>
</ul>
</div>
<div x-show="files.length === 1" x-cloak>
<label for="title" class="block text-sm font-medium text-stone-700 dark:text-zinc-200">Title (optional)</label>
<input
id="title"
type="text"
name="title"
value="{{ old('title') }}"
placeholder="Leave blank to use embedded title or filename"
class="mt-2 w-full rounded border border-stone-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-teal-600 focus:outline-none focus:ring-1 focus:ring-teal-600 dark:border-zinc-600 dark:bg-zinc-900 dark:text-zinc-100"
>
</div>
<p x-show="notice" x-cloak class="text-sm text-amber-800 dark:text-amber-300" x-text="notice"></p>
<p x-show="error" x-cloak class="text-sm text-red-700 dark:text-red-300" x-text="error"></p>
<div class="flex items-center gap-3">
<button
type="submit"
:disabled="files.length === 0 || uploading"
class="rounded bg-teal-700 px-4 py-2 text-sm font-medium text-white hover:bg-teal-800 disabled:cursor-not-allowed disabled:opacity-50"
>
<span x-text="uploadLabel"></span>
</button>
<a href="{{ route('recordings.index') }}" class="text-sm text-stone-600 dark:text-zinc-400 hover:underline dark:text-zinc-400">Cancel</a>
</div>
</form>
@endsection
-146
View File
@@ -1,146 +0,0 @@
@extends('layouts.app')
@section('title', 'Recordings')
@section('content')
@php
$indexRows = $recordings->map(function ($recording) {
return [
'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_class' => match ($recording->transcription_status) {
'done' => 'bg-teal-50 text-teal-800 ring-teal-600/20 dark:bg-teal-950 dark:text-teal-200 dark:ring-teal-400/30',
'processing' => 'bg-amber-50 text-amber-800 ring-amber-600/20 dark:bg-amber-950 dark:text-amber-200 dark:ring-amber-400/30',
'pending' => 'bg-amber-50 text-amber-800 ring-amber-600/20 dark:bg-amber-950 dark:text-amber-200 dark:ring-amber-400/30',
'failed' => 'bg-red-50 text-red-800 ring-red-600/20 dark:bg-red-950 dark:text-red-200 dark:ring-red-400/30',
'cancelled' => 'bg-stone-100 text-stone-700 ring-stone-500/20 dark:bg-zinc-800 dark:text-zinc-300 dark:ring-zinc-500/30',
default => 'bg-stone-100 text-stone-700 ring-stone-500/20 dark:bg-zinc-800 dark:text-zinc-300 dark:ring-zinc-500/30',
},
];
})->values();
@endphp
<div
x-data="recordingsIndex(@js([
'recordings' => $indexRows,
'pendingCount' => $pendingCount ?? 0,
]))"
x-init="
start();
return () => destroy();
"
>
<div class="mb-8 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<h1 class="text-2xl font-semibold tracking-tight">Recordings</h1>
<p class="mt-1 text-sm text-stone-600 dark:text-zinc-400">Manage pocket-recorder audio and transcripts.</p>
</div>
<div class="flex flex-col gap-2 sm:items-end">
<form method="GET" action="{{ route('recordings.index') }}" class="flex gap-2">
<input
type="search"
name="q"
value="{{ $search ?? '' }}"
placeholder="Search title, artist, transcript…"
class="w-full min-w-[16rem] rounded border border-stone-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-teal-600 focus:outline-none focus:ring-1 focus:ring-teal-600 dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-100 dark:placeholder:text-zinc-500"
>
<button type="submit" class="rounded border border-stone-300 bg-white px-3 py-2 text-sm hover:bg-stone-50 dark:border-zinc-600 dark:bg-zinc-800 dark:hover:bg-zinc-700">
Search
</button>
</form>
<form
method="POST"
action="{{ route('recordings.transcribe-pending') }}"
x-show="pendingCount > 0"
x-cloak
>
@csrf
<button type="submit" class="text-sm font-medium text-teal-700 hover:underline dark:text-teal-300">
Queue <span x-text="pendingCount"></span> pending
<span x-text="pendingCount === 1 ? 'transcription' : 'transcriptions'"></span>
</button>
</form>
</div>
</div>
@if ($recordings->isEmpty())
<div class="rounded border border-dashed border-stone-300 bg-white px-6 py-16 text-center dark:border-zinc-600 dark:bg-zinc-800">
<p class="text-stone-600 dark:text-zinc-400">No recordings yet.</p>
<a href="{{ route('recordings.create') }}" class="mt-4 inline-block text-sm font-medium text-teal-700 hover:underline dark:text-teal-300">
Upload your first MP3
</a>
</div>
@else
<div class="overflow-hidden rounded border border-stone-200 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-800">
<table class="min-w-full divide-y divide-stone-200 text-sm dark:divide-zinc-700">
<thead class="bg-stone-50 text-left text-xs font-medium uppercase tracking-wide text-stone-500 dark:bg-zinc-900/50 dark:text-zinc-400">
<tr>
<th class="px-4 py-3">Title</th>
<th class="px-4 py-3">Duration</th>
<th class="px-4 py-3">Words</th>
<th class="px-4 py-3">Status</th>
<th class="px-4 py-3">Uploaded</th>
</tr>
</thead>
<tbody class="divide-y divide-stone-100 dark:divide-zinc-700">
@foreach ($recordings as $recording)
<tr class="hover:bg-stone-50 dark:hover:bg-zinc-700/50">
<td class="px-4 py-3">
<a href="{{ route('recordings.show', $recording) }}" class="font-medium text-teal-800 hover:underline dark:text-teal-300">
{{ $recording->title }}
</a>
@if ($recording->artist)
<div class="text-xs text-stone-500 dark:text-zinc-400">{{ $recording->artist }}</div>
@endif
@if ($snippet = $recording->transcriptSnippet($search ?: null))
<p class="mt-1 max-w-xl text-xs leading-relaxed text-stone-500 dark:text-zinc-400">
{{ $snippet }}
</p>
@endif
</td>
<td class="px-4 py-3 text-stone-600 dark:text-zinc-400">{{ $recording->duration_formatted }}</td>
<td
class="px-4 py-3 tabular-nums text-stone-600 dark:text-zinc-400"
x-text="row({{ $recording->id }}).word_count_display"
>
{{ $recording->word_count > 0 ? number_format($recording->word_count) : '—' }}
</td>
<td class="px-4 py-3">
<span
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ring-1 ring-inset"
:class="row({{ $recording->id }}).badge_class"
x-text="row({{ $recording->id }}).status_label"
>
{{ $recording->transcriptionStatusLabel() }}
</span>
<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>
</td>
<td class="px-4 py-3 text-stone-600 dark:text-zinc-400">{{ $recording->created_at?->format('Y-m-d H:i') }}</td>
</tr>
@endforeach
</tbody>
</table>
</div>
<div class="mt-6">
{{ $recordings->links() }}
</div>
@endif
</div>
@endsection
@@ -1,21 +0,0 @@
@php
$label = $label ?? match ($status) {
'pending' => 'Queued',
'processing' => 'Transcribing',
'done' => 'Done',
'failed' => 'Failed',
'cancelled' => 'Cancelled',
default => (string) $status,
};
$classes = match ($status) {
'done' => 'bg-teal-50 text-teal-800 ring-teal-600/20 dark:bg-teal-950 dark:text-teal-200 dark:ring-teal-400/30',
'processing' => 'bg-amber-50 text-amber-800 ring-amber-600/20 dark:bg-amber-950 dark:text-amber-200 dark:ring-amber-400/30',
'pending' => 'bg-amber-50 text-amber-800 ring-amber-600/20 dark:bg-amber-950 dark:text-amber-200 dark:ring-amber-400/30',
'failed' => 'bg-red-50 text-red-800 ring-red-600/20 dark:bg-red-950 dark:text-red-200 dark:ring-red-400/30',
'cancelled' => 'bg-stone-100 text-stone-700 ring-stone-500/20 dark:bg-zinc-800 dark:text-zinc-300 dark:ring-zinc-500/30',
default => 'bg-stone-100 text-stone-700 ring-stone-500/20 dark:bg-zinc-800 dark:text-zinc-300 dark:ring-zinc-500/30',
};
@endphp
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ring-1 ring-inset {{ $classes }}">
{{ $label }}
</span>
-179
View File
@@ -1,179 +0,0 @@
@extends('layouts.app')
@section('title', $recording->title)
@section('content')
<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>
<a href="{{ route('recordings.index') }}" class="text-sm text-stone-500 hover:text-stone-800 dark:text-zinc-400 dark:hover:text-zinc-200"> Recordings</a>
<h1 class="mt-2 text-2xl font-semibold tracking-tight">{{ $recording->title }}</h1>
<div class="mt-2 flex flex-wrap items-center gap-2 text-sm text-stone-600 dark:text-zinc-400">
<span
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ring-1 ring-inset"
:class="badgeClass"
x-text="status.status_label || status.status"
></span>
<template x-if="status.driver_label">
<span class="text-xs text-stone-500 dark:text-zinc-400" x-text="status.driver_label"></span>
</template>
</div>
</div>
<form method="POST" action="{{ route('recordings.destroy', $recording) }}" onsubmit="return confirm('Delete this recording and its file?')">
@csrf
@method('DELETE')
<button type="submit" class="rounded border border-red-200 bg-white px-3 py-1.5 text-sm text-red-700 hover:bg-red-50 dark:border-red-900 dark:bg-zinc-800 dark:text-red-300 dark:hover:bg-red-950">
Delete
</button>
</form>
</div>
<div class="grid gap-6 lg:grid-cols-2">
<section class="rounded border border-stone-200 bg-white p-6 shadow-sm dark:border-zinc-700 dark:bg-zinc-800">
<h2 class="text-sm font-semibold uppercase tracking-wide text-stone-500 dark:text-zinc-400">Metadata</h2>
<dl class="mt-4 space-y-3 text-sm">
<div class="flex justify-between gap-4">
<dt class="text-stone-500 dark:text-zinc-400">Original file</dt>
<dd class="text-right font-medium">{{ $recording->original_filename }}</dd>
</div>
<div class="flex justify-between gap-4">
<dt class="text-stone-500 dark:text-zinc-400">Duration</dt>
<dd class="font-medium">{{ $recording->duration_formatted }}</dd>
</div>
<div class="flex justify-between gap-4">
<dt class="text-stone-500 dark:text-zinc-400">Artist</dt>
<dd class="font-medium">{{ $recording->artist ?: '—' }}</dd>
</div>
<div class="flex justify-between gap-4">
<dt class="text-stone-500 dark:text-zinc-400">Album</dt>
<dd class="font-medium">{{ $recording->album ?: '—' }}</dd>
</div>
<div class="flex justify-between gap-4">
<dt class="text-stone-500 dark:text-zinc-400">Recorded</dt>
<dd class="font-medium">{{ $recording->recorded_at?->format('Y-m-d') ?: '—' }}</dd>
</div>
<div class="flex justify-between gap-4">
<dt class="text-stone-500 dark:text-zinc-400">Size</dt>
<dd class="font-medium">{{ number_format($recording->file_size_bytes / 1024, 1) }} KB</dd>
</div>
<div class="flex justify-between gap-4">
<dt class="text-stone-500 dark:text-zinc-400">Uploaded</dt>
<dd class="font-medium">{{ $recording->created_at?->format('Y-m-d H:i') }}</dd>
</div>
</dl>
</section>
<section class="rounded border border-stone-200 bg-white p-6 shadow-sm dark:border-zinc-700 dark:bg-zinc-800">
<h2 class="text-sm font-semibold uppercase tracking-wide text-stone-500 dark:text-zinc-400">Transcribe</h2>
<form method="POST" action="{{ route('recordings.transcribe', $recording) }}" class="mt-4">
@csrf
<button
type="submit"
class="rounded bg-teal-700 px-4 py-2 text-sm font-medium text-white hover:bg-teal-800 dark:bg-teal-600 dark:hover:bg-teal-500"
>
<span x-text="startButtonLabel"></span>
</button>
</form>
<form
method="POST"
action="{{ route('recordings.transcribe.cancel', $recording) }}"
x-show="status.is_active"
x-cloak
class="mt-3"
>
@csrf
<button
type="submit"
class="rounded border border-stone-300 bg-white px-4 py-2 text-sm font-medium text-stone-800 hover:bg-stone-50 dark:border-zinc-600 dark:bg-zinc-900 dark:text-zinc-100 dark:hover:bg-zinc-700"
>
Stop transcription
</button>
</form>
</section>
</div>
<section class="mt-6 rounded border border-stone-200 bg-white p-6 shadow-sm dark:border-zinc-700 dark:bg-zinc-800">
<div class="flex items-center justify-between gap-4">
<h2 class="text-sm font-semibold uppercase tracking-wide text-stone-500 dark:text-zinc-400">Transcript</h2>
<button
type="button"
x-show="!status.is_active && status.has_transcript"
x-cloak
@click="navigator.clipboard.writeText(status.transcript || '')"
class="text-sm text-teal-700 hover:underline dark:text-teal-300"
>
Copy
</button>
</div>
<div x-show="status.is_active" x-cloak class="mt-4 space-y-3 rounded border border-amber-200 bg-amber-50 p-4 dark:border-amber-900 dark:bg-amber-950/40">
<div class="flex flex-wrap items-center justify-between gap-2 text-sm">
<p class="font-medium text-amber-900 dark:text-amber-100" x-text="status.progress || 'Working…'"></p>
<p class="tabular-nums text-amber-800 dark:text-amber-200">
<span x-text="(status.percent ?? 0) + '%'"></span>
<span class="mx-1 text-amber-600 dark:text-amber-400">·</span>
<span x-text="'Elapsed ' + (status.elapsed_human || '0s')"></span>
</p>
</div>
<div class="h-2 overflow-hidden rounded-full bg-amber-100 dark:bg-amber-900/50">
<div
class="h-full rounded-full bg-amber-500 transition-all duration-500"
:style="`width: ${Math.max(status.percent || 5, 5)}%`"
></div>
</div>
<ul class="space-y-1 text-xs text-amber-900/80 dark:text-amber-200/80">
<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-amber-700 dark:text-amber-300">(longer files take longer)</span>
</li>
</template>
<li x-show="pollError" class="text-red-700 dark:text-red-300" x-text="pollError"></li>
</ul>
</div>
<div x-show="!status.is_active && status.status === 'cancelled'" x-cloak class="mt-4 rounded border border-stone-200 bg-stone-50 p-4 text-sm text-stone-700 dark:border-zinc-600 dark:bg-zinc-900 dark:text-zinc-300">
Transcription stopped. Choose an engine above to start again.
</div>
<div x-show="!status.is_active && status.status === 'failed'" x-cloak class="mt-4 space-y-2 rounded border border-red-200 bg-red-50 p-4 text-sm text-red-800 dark:border-red-900 dark:bg-red-950 dark:text-red-200">
<p class="font-medium">Transcription failed</p>
<p x-text="status.error || 'Check the logs and try again with another engine.'"></p>
</div>
<div x-show="!status.is_active && status.has_transcript" x-cloak>
<p class="mt-4 whitespace-pre-wrap text-sm leading-relaxed text-stone-800 dark:text-zinc-100" x-text="status.transcript"></p>
<p
class="mt-4 text-xs text-stone-500 dark:text-zinc-400"
x-show="status.transcribed_at"
x-text="status.transcribed_at ? ('Transcribed ' + formatTimestamp(status.transcribed_at)) : ''"
></p>
</div>
<p
x-show="!status.is_active && status.status !== 'failed' && status.status !== 'cancelled' && !status.has_transcript"
x-cloak
class="mt-4 text-sm text-stone-500 dark:text-zinc-400"
>
No transcript yet. Transcription starts automatically after upload, or use the button above.
</p>
</section>
</div>
@endsection