Compare commits

...
3 Commits
Author SHA1 Message Date
ben c17f8fb506 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.
2026-08-12 20:14:35 +02:00
ben 148ba91816 Add delete button with confirm modal on each recordings list row. 2026-08-12 19:23:35 +02:00
ben 34ccf0c32b 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.
2026-08-12 19:17:52 +02:00
49 changed files with 1956 additions and 1279 deletions
+9 -12
View File
@@ -74,10 +74,16 @@ REVERB_SCHEME=http
REVERB_SERVER_HOST=0.0.0.0
REVERB_SERVER_PORT=8080
# Host ports for docker compose (FrankenPHP app, Reverb WS, Whisper)
APP_HOST_PORT=8080
REVERB_HOST_PORT=8081
WHISPER_HOST_PORT=8090
VITE_REVERB_APP_KEY="${REVERB_APP_KEY}"
VITE_REVERB_HOST="${REVERB_HOST}"
VITE_REVERB_PORT="${REVERB_PORT}"
VITE_REVERB_SCHEME="${REVERB_SCHEME}"
VITE_REVERB_HOST=localhost
# Browser WS port: use REVERB_HOST_PORT with Docker (8081), or REVERB_PORT for bare-metal reverb:start
VITE_REVERB_PORT="${REVERB_HOST_PORT}"
VITE_REVERB_SCHEME=http
# AndyTranscribe / local faster-whisper
LOCAL_WHISPER_URL=http://127.0.0.1:8090/v1
@@ -87,15 +93,6 @@ TRANSCRIPTION_TIMEOUT=600
# Must be greater than TRANSCRIPTION_TIMEOUT so long Whisper jobs are not re-queued mid-run
DB_QUEUE_RETRY_AFTER=660
# Host ports for docker compose (FrankenPHP app, Reverb WS, Whisper)
APP_HOST_PORT=8080
REVERB_HOST_PORT=8081
WHISPER_HOST_PORT=8090
# Browser-facing Reverb host/port (used at Vite build time in Docker)
VITE_REVERB_HOST=localhost
VITE_REVERB_SCHEME=http
# Demo user created on every container start (db:seed via entrypoint)
SEED_USER_NAME="Demo User"
SEED_USER_EMAIL=demo@example.com
+6 -6
View File
@@ -87,14 +87,14 @@ Whisper may take a minute or two while the model downloads.
### Demo login
Every container start runs `db:seed`, which ensures this user exists:
Every container start runs `db:seed`, which ensures these users exist:
| Field | Default |
| Email | Password |
| --- | --- |
| Email | `demo@example.com` |
| Password | `password` |
| `demo@example.com` | `password` |
| `admin@example.com` | `password` |
Override with `SEED_USER_NAME`, `SEED_USER_EMAIL`, and `SEED_USER_PASSWORD` in `.env`.
Override the demo user with `SEED_USER_NAME`, `SEED_USER_EMAIL`, and `SEED_USER_PASSWORD` in `.env`.
### 5. Open the app
@@ -197,7 +197,7 @@ Use the GPU Whisper service instead of the CPU `whisper` service when you have a
## Usage
1. Open the app and **Log in** with the demo user (`demo@example.com` / `password`), or **Register** a new account.
1. Open the app and **Log in** with `admin@example.com` / `password` (or `demo@example.com` / `password`), or **Register** a new account.
2. Open **Recordings → Upload** and drop one or many audio files.
3. Transcription starts automatically (the `queue` service must be running).
4. Watch live progress on the list or detail page; stop or restart anytime.
+130
View File
@@ -0,0 +1,130 @@
<?php
namespace App\Actions;
use App\Models\Recording;
use App\Models\User;
use App\Services\Mp3MetadataService;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
class StoreUploadedRecordings
{
public function __construct(private Mp3MetadataService $metadata) {}
/**
* Persist uploaded audio files and queue transcription.
*
* @param list<UploadedFile> $files
* @return array{
* recordings: list<Recording>,
* skipped_duplicates: int,
* message: string,
* }
*/
public function handle(User $user, array $files, ?string $titleOverride = null): array
{
$recordings = [];
$skippedDuplicates = 0;
$seenHashes = [];
foreach ($files as $file) {
if (! $file instanceof UploadedFile) {
continue;
}
$hash = hash_file('sha256', $file->getRealPath());
if ($hash === false) {
continue;
}
if (
isset($seenHashes[$hash])
|| $user->recordings()->where('content_hash', $hash)->exists()
|| $user->recordings()
->where('original_filename', $file->getClientOriginalName())
->where('file_size_bytes', $file->getSize() ?: 0)
->exists()
) {
$skippedDuplicates++;
continue;
}
$seenHashes[$hash] = true;
$title = count($files) === 1 && filled($titleOverride)
? $titleOverride
: null;
$recordings[] = $this->storeUploadedRecording($user, $file, $hash, $title);
}
$message = $this->message(count($recordings), $skippedDuplicates);
return [
'recordings' => $recordings,
'skipped_duplicates' => $skippedDuplicates,
'message' => $message,
];
}
private function storeUploadedRecording(
User $user,
UploadedFile $file,
string $contentHash,
?string $titleOverride = null,
): Recording {
$path = $file->store('recordings', 'local');
$absolutePath = Storage::disk('local')->path($path);
$tags = $this->metadata->extract($absolutePath);
$title = $titleOverride
?: ($tags['title'] ?? pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME));
$recording = Recording::query()->create([
'user_id' => $user->id,
'title' => $title,
'original_filename' => $file->getClientOriginalName(),
'file_path' => $path,
'duration_seconds' => $tags['duration_seconds'],
'recorded_at' => $tags['recorded_at'],
'artist' => $tags['artist'],
'album' => $tags['album'],
'file_size_bytes' => $file->getSize() ?: 0,
'content_hash' => $contentHash,
'transcription_status' => 'pending',
'transcription_driver' => 'local',
]);
$recording->queueLocalTranscription();
return $recording->fresh();
}
private function message(int $savedCount, int $skippedDuplicates): string
{
if ($savedCount === 0 && $skippedDuplicates > 0) {
return $skippedDuplicates === 1
? 'That file is already uploaded — nothing new was saved.'
: "All {$skippedDuplicates} files were duplicates — nothing new was saved.";
}
if ($savedCount === 0) {
return 'No valid audio files were uploaded.';
}
$message = $savedCount === 1
? 'Recording uploaded — transcription queued.'
: $savedCount.' recordings uploaded — transcription queued.';
if ($skippedDuplicates > 0) {
$message .= $skippedDuplicates === 1
? ' Skipped 1 duplicate.'
: " Skipped {$skippedDuplicates} duplicates.";
}
return $message;
}
}
@@ -27,6 +27,7 @@ class RecordingTranscriptionUpdated implements ShouldBroadcastNow
{
return [
new PrivateChannel('recording.'.$this->recording->id),
new PrivateChannel('user.'.$this->recording->user_id.'.recordings'),
];
}
@@ -1,26 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Models\Recording;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Gate;
class CancelTranscriptionController extends Controller
{
/**
* Stop an in-progress or queued transcription.
*/
public function __invoke(Recording $recording): RedirectResponse
{
Gate::authorize('transcribe', $recording);
if (! $recording->isTranscribing()) {
return back()->with('error', 'No transcription is currently running.');
}
$recording->cancelTranscription();
return back()->with('success', 'Transcription stopped.');
}
}
@@ -1,223 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\StoreRecordingRequest;
use App\Models\Recording;
use App\Services\Mp3MetadataService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Storage;
use Illuminate\View\View;
class RecordingController extends Controller
{
/**
* Display a listing of recordings.
*/
public function index(Request $request): View
{
$user = $request->user();
$user->recordings()
->whereIn('transcription_status', ['pending', 'processing'])
->orderBy('id')
->each(fn (Recording $recording) => $recording->recoverOrphanedTranscription());
$query = $user->recordings()->latest();
if ($search = $request->string('q')->trim()->toString()) {
$query->search($search);
}
$recordings = $query->paginate(20)->withQueryString();
$pendingCount = $user->recordings()
->whereIn('transcription_status', ['pending', 'failed', 'cancelled'])
->get()
->filter(fn (Recording $recording) => ! $recording->hasActiveTranscriptionJob())
->count();
return view('recordings.index', [
'recordings' => $recordings,
'search' => $search ?? '',
'pendingCount' => $pendingCount,
]);
}
/**
* Show the upload form.
*/
public function create(Request $request): View
{
$existingFingerprints = $request->user()->recordings()
->get(['original_filename', 'file_size_bytes'])
->map(fn (Recording $recording) => $this->uploadFingerprint(
$recording->original_filename,
(int) $recording->file_size_bytes,
))
->unique()
->values()
->all();
return view('recordings.create', [
'existingFingerprints' => $existingFingerprints,
]);
}
/**
* Store one or more uploaded recordings.
*/
public function store(StoreRecordingRequest $request, Mp3MetadataService $metadata): RedirectResponse
{
/** @var list<UploadedFile> $files */
$files = array_values(array_filter(
$request->file('audio', []),
fn ($file) => $file instanceof UploadedFile,
));
$user = $request->user();
$titleOverride = $request->string('title')->trim()->toString();
$recordings = [];
$skippedDuplicates = 0;
$seenHashes = [];
foreach ($files as $file) {
$hash = hash_file('sha256', $file->getRealPath());
if ($hash === false) {
continue;
}
if (
isset($seenHashes[$hash])
|| $user->recordings()->where('content_hash', $hash)->exists()
|| $user->recordings()
->where('original_filename', $file->getClientOriginalName())
->where('file_size_bytes', $file->getSize() ?: 0)
->exists()
) {
$skippedDuplicates++;
continue;
}
$seenHashes[$hash] = true;
$title = count($files) === 1 && $titleOverride !== ''
? $titleOverride
: null;
$recordings[] = $this->storeUploadedRecording($request, $file, $metadata, $hash, $title);
}
if ($recordings === [] && $skippedDuplicates > 0) {
return redirect()
->route('recordings.create')
->with('error', $skippedDuplicates === 1
? 'That file is already uploaded — nothing new was saved.'
: "All {$skippedDuplicates} files were duplicates — nothing new was saved.");
}
if ($recordings === []) {
return redirect()
->route('recordings.create')
->with('error', 'No valid audio files were uploaded.');
}
$message = count($recordings) === 1
? 'Recording uploaded — transcription queued.'
: count($recordings).' recordings uploaded — transcription queued.';
if ($skippedDuplicates > 0) {
$message .= $skippedDuplicates === 1
? ' Skipped 1 duplicate.'
: " Skipped {$skippedDuplicates} duplicates.";
}
if (count($recordings) === 1) {
return redirect()
->route('recordings.show', $recordings[0])
->with('success', $message);
}
return redirect()
->route('recordings.index')
->with('success', $message);
}
/**
* Display the specified recording.
*/
public function show(Recording $recording): View
{
Gate::authorize('view', $recording);
$recording->recoverOrphanedTranscription();
$recording->refresh();
return view('recordings.show', compact('recording'));
}
/**
* Remove the specified recording.
*/
public function destroy(Recording $recording): RedirectResponse
{
Gate::authorize('delete', $recording);
$recording->deleteFile();
$recording->delete();
return redirect()
->route('recordings.index')
->with('success', 'Recording deleted.');
}
/**
* Persist a single uploaded audio file as a recording.
*/
private function storeUploadedRecording(
Request $request,
UploadedFile $file,
Mp3MetadataService $metadata,
string $contentHash,
?string $titleOverride = null,
): Recording {
$path = $file->store('recordings', 'local');
$absolutePath = Storage::disk('local')->path($path);
$tags = $metadata->extract($absolutePath);
$title = $titleOverride
?: ($tags['title'] ?? pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME));
$recording = Recording::create([
'user_id' => $request->user()->id,
'title' => $title,
'original_filename' => $file->getClientOriginalName(),
'file_path' => $path,
'duration_seconds' => $tags['duration_seconds'],
'recorded_at' => $tags['recorded_at'],
'artist' => $tags['artist'],
'album' => $tags['album'],
'file_size_bytes' => $file->getSize() ?: 0,
'content_hash' => $contentHash,
'transcription_status' => 'pending',
'transcription_driver' => 'local',
]);
$recording->queueLocalTranscription();
return $recording->fresh();
}
/**
* Client-side fingerprint for name + size duplicate checks before upload.
*/
private function uploadFingerprint(string $filename, int $sizeBytes): string
{
return strtolower($filename).':'.$sizeBytes;
}
}
@@ -0,0 +1,34 @@
<?php
namespace App\Http\Controllers;
use App\Models\Recording;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\StreamedResponse;
class StreamRecordingController extends Controller
{
/**
* Stream the recording audio for in-browser playback.
*/
public function __invoke(Recording $recording): StreamedResponse
{
Gate::authorize('view', $recording);
abort_unless(
$recording->file_path && Storage::disk('local')->exists($recording->file_path),
404,
);
return Storage::disk('local')->response(
$recording->file_path,
$recording->original_filename,
[
'Content-Type' => $recording->audioMimeType(),
'Accept-Ranges' => 'bytes',
],
'inline',
);
}
}
@@ -1,25 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\TranscribeRecordingRequest;
use App\Models\Recording;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Gate;
class TranscribeController extends Controller
{
/**
* Queue local faster-whisper transcription for the recording.
*
* Always allowed: stops any current run first, then starts a new one.
*/
public function __invoke(TranscribeRecordingRequest $request, Recording $recording): RedirectResponse
{
Gate::authorize('transcribe', $recording);
$recording->queueLocalTranscription();
return back()->with('success', 'Transcription started. Progress updates below.');
}
}
@@ -1,41 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Models\Recording;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class TranscribePendingController extends Controller
{
/**
* Queue local transcription for recordings that still need a transcript.
*/
public function __invoke(Request $request): RedirectResponse
{
$queued = 0;
$request->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) {
return back()->with('error', 'No recordings need transcription right now.');
}
return back()->with(
'success',
$queued === 1
? 'Queued 1 recording for transcription.'
: "Queued {$queued} recordings for transcription.",
);
}
}
@@ -1,23 +0,0 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class TranscribeRecordingRequest extends FormRequest
{
public function authorize(): bool
{
$recording = $this->route('recording');
return $recording !== null && $this->user()?->can('transcribe', $recording) === true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [];
}
}
+18
View File
@@ -0,0 +1,18 @@
<?php
namespace App\Livewire\Recordings;
use Illuminate\Contracts\View\View;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Layout('layouts.app')]
#[Title('Upload recording')]
class Create extends Component
{
public function render(): View
{
return view('livewire.recordings.create');
}
}
+158
View File
@@ -0,0 +1,158 @@
<?php
namespace App\Livewire\Recordings;
use App\Models\Recording;
use Flux\Flux;
use Illuminate\Contracts\View\View;
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 = '';
public function mount(): void
{
$this->userId = (int) Auth::id();
}
public function updatedSearch(): void
{
$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();
$query = $user->recordings()->latest();
$search = trim($this->search);
if ($search !== '') {
$query->search($search);
}
$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,
]);
}
}
+70
View File
@@ -0,0 +1,70 @@
<?php
namespace App\Livewire\Recordings;
use App\Models\Recording;
use Flux\Flux;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Gate;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.app')]
class Show extends Component
{
public Recording $recording;
public function mount(Recording $recording): void
{
Gate::authorize('view', $recording);
$recording->recoverOrphanedTranscription();
$recording->refresh();
$this->recording = $recording;
}
public function startTranscription(): void
{
Gate::authorize('transcribe', $this->recording);
$this->recording->queueLocalTranscription();
$this->recording->refresh();
Flux::toast(text: 'Transcription started. Progress updates below.', variant: 'success');
}
public function cancelTranscription(): void
{
Gate::authorize('transcribe', $this->recording);
if (! $this->recording->isTranscribing()) {
Flux::toast(text: 'No transcription is currently running.', variant: 'danger');
return;
}
$this->recording->cancelTranscription();
$this->recording->refresh();
Flux::toast(text: 'Transcription stopped.', variant: 'success');
}
public function delete(): mixed
{
Gate::authorize('delete', $this->recording);
$this->recording->deleteFile();
$this->recording->delete();
session()->flash('success', 'Recording deleted.');
return $this->redirect(route('recordings.index'), navigate: true);
}
public function render(): View
{
return view('livewire.recordings.show')
->title($this->recording->title);
}
}
+89
View File
@@ -0,0 +1,89 @@
<?php
namespace App\Livewire;
use App\Actions\StoreUploadedRecordings;
use App\Http\Requests\StoreRecordingRequest;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\Rules\File;
use Livewire\Component;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
use Livewire\WithFileUploads;
class UploadRecordings extends Component
{
use WithFileUploads;
/**
* @var list<TemporaryUploadedFile>
*/
public array $audio = [];
public bool $saving = false;
public bool $showCancel = true;
public function updatedAudio(): void
{
if ($this->saving || $this->audio === []) {
return;
}
$this->save();
}
public function save(?StoreUploadedRecordings $store = null): mixed
{
if ($this->saving) {
return null;
}
$this->saving = true;
try {
$store ??= app(StoreUploadedRecordings::class);
$this->validate([
'audio' => ['required', 'array', 'min:1', 'max:50'],
'audio.*' => [
'required',
File::types(StoreRecordingRequest::AUDIO_EXTENSIONS)->max('2gb'),
],
], [
'audio.required' => 'Please choose at least one audio file to upload.',
'audio.min' => 'Please choose at least one audio file to upload.',
'audio.max' => 'You can upload at most 50 files at once.',
'audio.*' => 'Unsupported audio type. Use MP3, WAV, OGG, FLAC, M4A, AAC, WebM, WMA, or AIFF.',
'audio.*.max' => 'Each audio file may not be larger than 2 GB.',
]);
$result = $store->handle(Auth::user(), $this->audio);
$this->audio = [];
if ($result['recordings'] === []) {
session()->flash('error', $result['message']);
return $this->redirect(route('recordings.create'), navigate: true);
}
session()->flash('success', $result['message']);
if (count($result['recordings']) === 1) {
return $this->redirect(
route('recordings.show', $result['recordings'][0]),
navigate: true,
);
}
return $this->redirect(route('recordings.index'), navigate: true);
} finally {
$this->saving = false;
}
}
public function render()
{
return view('livewire.upload-recordings');
}
}
+21 -1
View File
@@ -135,7 +135,7 @@ class Recording extends Model
'ollama_url' => null,
'transcription_status' => 'pending',
'transcription_progress' => 'Queued — waiting to start…',
'transcription_percent' => 5,
'transcription_percent' => null,
'transcription_started_at' => now(),
'transcription_error' => null,
// Keep the previous transcript until a new run succeeds.
@@ -444,6 +444,26 @@ class Recording extends Model
return Storage::disk('local')->path($this->file_path);
}
/**
* MIME type for browser audio playback based on the original filename.
*/
public function audioMimeType(): string
{
$extension = strtolower(pathinfo((string) $this->original_filename, PATHINFO_EXTENSION));
return match ($extension) {
'mp3', 'mpga', 'mpeg' => 'audio/mpeg',
'wav' => 'audio/wav',
'ogg', 'oga' => 'audio/ogg',
'flac' => 'audio/flac',
'm4a', 'mp4', 'aac' => 'audio/mp4',
'webm' => 'audio/webm',
'wma' => 'audio/x-ms-wma',
'aiff', 'aif' => 'audio/aiff',
default => 'application/octet-stream',
};
}
/**
* Delete the audio file from storage.
*/
+5 -3
View File
@@ -44,7 +44,7 @@ return [
|
*/
'component_layout' => 'layouts::app.header',
'component_layout' => 'layouts.app',
/*
|---------------------------------------------------------------------------
@@ -130,15 +130,17 @@ return [
'temporary_file_upload' => [
'disk' => env('LIVEWIRE_TEMPORARY_FILE_UPLOAD_DISK'), // Example: 'local', 's3' | Default: 'default'
'rules' => null, // Example: ['file', 'mimes:png,jpg'] | Default: ['required', 'file', 'max:12288'] (12MB)
// Pocket-recorder audio can be large; max is kilobytes (2 GiB).
'rules' => ['required', 'file', 'max:2097152'],
'directory' => null, // Example: 'tmp' | Default: 'livewire-tmp'
'middleware' => null, // Example: 'throttle:5,1' | Default: 'throttle:60,1'
'preview_mimes' => [ // Supported file types for temporary pre-signed file URLs...
'png', 'gif', 'bmp', 'svg', 'wav', 'mp4',
'mov', 'avi', 'wmv', 'mp3', 'm4a',
'jpg', 'jpeg', 'mpga', 'webp', 'wma',
'ogg', 'oga', 'flac', 'aac', 'webm', 'aiff', 'aif',
],
'max_upload_time' => 5, // Max duration (in minutes) before an upload is invalidated...
'max_upload_time' => 60, // Max duration (in minutes) before an upload is invalidated...
'cleanup' => true, // Should cleanup temporary uploads older than 24 hrs...
],
+9
View File
@@ -29,6 +29,15 @@ class DatabaseSeeder extends Seeder
],
);
User::query()->updateOrCreate(
['email' => 'admin@example.com'],
[
'name' => 'Admin',
'password' => 'password',
'email_verified_at' => now(),
],
);
Recording::query()
->whereNull('user_id')
->update(['user_id' => $user->id]);
+2 -5
View File
@@ -8,11 +8,8 @@
@source '../js';
@theme {
--font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
'Segoe UI Symbol', 'Noto Color Emoji';
--color-accent: var(--color-teal-700);
--color-accent-content: var(--color-teal-700);
--color-accent-foreground: var(--color-white);
--font-sans: Inter, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
'Noto Color Emoji';
}
@custom-variant dark (&:where(.dark, .dark *));
-2
View File
@@ -1,7 +1,5 @@
import './echo';
import { recordingsIndex, transcriptionMonitor } from './transcription';
import { uploadDropzone } from './upload';
window.transcriptionMonitor = transcriptionMonitor;
window.recordingsIndex = recordingsIndex;
window.uploadDropzone = uploadDropzone;
+64 -69
View File
@@ -2,16 +2,21 @@
* Shared helpers and Alpine components for live transcription updates via Reverb.
*/
const BADGE_CLASSES = {
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',
const BADGE_COLORS = {
done: 'teal',
processing: 'amber',
pending: 'zinc',
failed: 'red',
cancelled: 'zinc',
};
export function badgeColorFor(status) {
return BADGE_COLORS[status] || 'zinc';
}
/** @deprecated Use badgeColorFor — kept for any leftover callers */
export function badgeClassFor(status) {
return BADGE_CLASSES[status] || 'bg-stone-100 text-stone-700 ring-stone-500/20 dark:bg-zinc-800 dark:text-zinc-300 dark:ring-zinc-500/30';
return badgeColorFor(status);
}
export function formatElapsed(seconds) {
@@ -50,12 +55,6 @@ export function formatTimestamp(value) {
+ ':' + pad(date.getMinutes());
}
function formatWordCount(count) {
const n = Number(count) || 0;
return n > 0 ? n.toLocaleString() : '—';
}
function subscribeToRecording(recordingId, handler) {
if (!window.Echo) {
return () => {};
@@ -71,27 +70,22 @@ function subscribeToRecording(recordingId, handler) {
};
}
function subscribeToRecordings(recordingIds, handler) {
const leaveFns = recordingIds.map((id) => subscribeToRecording(id, handler));
return () => {
leaveFns.forEach((leave) => leave());
};
}
/**
* Show-page Alpine component: Echo push + local elapsed tick + optional status hydrate.
*/
export function transcriptionMonitor({ statusUrl, initial }) {
return {
statusUrl,
status: initial,
status: {
...initial,
badge_color: badgeColorFor(initial.status),
},
pollError: null,
tickTimer: null,
leaveChannel: null,
get badgeClass() {
return badgeClassFor(this.status.status);
get badgeColor() {
return badgeColorFor(this.status.status);
},
get startButtonLabel() {
@@ -128,7 +122,11 @@ export function transcriptionMonitor({ statusUrl, initial }) {
applyPayload(payload) {
const wasActive = this.status.is_active;
this.status = { ...this.status, ...payload };
this.status = {
...this.status,
...payload,
badge_color: badgeColorFor(payload.status ?? this.status.status),
};
this.pollError = null;
if (this.status.is_active) {
@@ -196,67 +194,64 @@ export function transcriptionMonitor({ statusUrl, initial }) {
}
/**
* Index-page Alpine component: patch row status from Reverb events.
* Index-page Alpine component: inline audio player only.
* Status/progress refresh via Livewire Echo + wire:poll.
*/
export function recordingsIndex({ recordings, pendingCount }) {
const byId = {};
for (const row of recordings) {
byId[row.id] = row;
}
export function recordingsIndex() {
return {
rows: byId,
pendingCount: Number(pendingCount) || 0,
leaveChannel: null,
playingId: null,
isPlaying: false,
start() {
this.leaveChannel = subscribeToRecordings(Object.keys(this.rows), (event) => {
this.applyPayload(event);
});
//
},
destroy() {
if (this.leaveChannel) {
this.leaveChannel();
this.leaveChannel = null;
const player = this.$refs.player;
if (player) {
player.pause();
player.removeAttribute('src');
player.load();
}
},
applyPayload(payload) {
const id = payload.id;
syncPlayer() {
const player = this.$refs.player;
if (!this.rows[id]) {
this.isPlaying = Boolean(player && !player.paused && !player.ended);
if (player?.ended) {
this.playingId = null;
}
},
isPlayingRow(id) {
return this.playingId === id && this.isPlaying;
},
togglePlay(id, url) {
const player = this.$refs.player;
if (!player) {
return;
}
const previousStatus = this.rows[id].status;
const next = {
...this.rows[id],
status: payload.status,
status_label: payload.status_label,
progress: payload.progress,
percent: payload.percent,
is_active: payload.is_active,
word_count: payload.word_count ?? this.rows[id].word_count,
word_count_display: formatWordCount(payload.word_count ?? this.rows[id].word_count),
badge_class: badgeClassFor(payload.status),
};
if (this.playingId === id && this.isPlaying) {
player.pause();
this.rows[id] = next;
const wasQueueable = ['pending', 'failed', 'cancelled'].includes(previousStatus);
const isQueueable = ['pending', 'failed', 'cancelled'].includes(payload.status);
if (wasQueueable && !isQueueable) {
this.pendingCount = Math.max(0, this.pendingCount - 1);
} else if (!wasQueueable && isQueueable) {
this.pendingCount += 1;
return;
}
},
row(id) {
return this.rows[id] || {};
if (this.playingId !== id) {
player.src = url;
this.playingId = id;
}
player.play().catch(() => {
this.playingId = null;
this.isPlaying = false;
});
},
};
}
-165
View File
@@ -1,165 +0,0 @@
/**
* Upload dropzone: discard duplicate files in the selection (and known server fingerprints)
* before submitting the form.
*/
const MAX_FILE_BYTES = 2 * 1024 * 1024 * 1024;
export function uploadDropzone({ existingFingerprints = [] } = {}) {
const acceptExt = ['.mp3', '.wav', '.ogg', '.oga', '.flac', '.m4a', '.mp4', '.aac', '.webm', '.wma', '.aiff', '.aif'];
const known = new Set(existingFingerprints);
return {
files: [],
dragging: false,
uploading: false,
error: null,
notice: null,
get uploadLabel() {
if (this.uploading) {
return 'Uploading…';
}
if (this.files.length <= 1) {
return 'Upload';
}
return 'Upload ' + this.files.length + ' files';
},
onBrowse(event) {
this.addFiles(Array.from(event.target.files || []));
},
onDrop(event) {
this.dragging = false;
this.addFiles(Array.from(event.dataTransfer?.files || []));
},
addFiles(incoming) {
this.error = null;
this.notice = null;
const accepted = [];
let skippedUnsupported = 0;
let skippedTooLarge = 0;
let skippedDuplicates = 0;
for (const file of incoming) {
if (! this.isAccepted(file)) {
skippedUnsupported++;
continue;
}
if (file.size > MAX_FILE_BYTES) {
skippedTooLarge++;
continue;
}
const fingerprint = this.fingerprint(file);
if (known.has(fingerprint) || this.files.some((existing) => this.fingerprint(existing) === fingerprint)) {
skippedDuplicates++;
continue;
}
if (accepted.some((existing) => this.fingerprint(existing) === fingerprint)) {
skippedDuplicates++;
continue;
}
accepted.push(file);
}
this.files = [...this.files, ...accepted];
if (this.files.length > 50) {
this.error = 'You can upload at most 50 files at once.';
this.files = this.files.slice(0, 50);
}
if (skippedUnsupported > 0) {
this.error = 'Skipped unsupported file type. Use common audio formats only.';
} else if (skippedTooLarge > 0) {
this.error = 'Skipped a file larger than 2 GB.';
}
if (skippedDuplicates > 0) {
this.notice = skippedDuplicates === 1
? 'Skipped 1 duplicate file.'
: `Skipped ${skippedDuplicates} duplicate files.`;
}
this.syncInput();
},
isAccepted(file) {
const name = (file.name || '').toLowerCase();
if (acceptExt.some((ext) => name.endsWith(ext))) {
return true;
}
return (file.type || '').startsWith('audio/');
},
fingerprint(file) {
return `${String(file.name || '').toLowerCase()}:${Number(file.size) || 0}`;
},
fileListKey(file, index = 0) {
return `${this.fingerprint(file)}:${index}`;
},
removeFile(index) {
this.files.splice(index, 1);
this.syncInput();
},
clearFiles() {
this.files = [];
this.notice = null;
this.syncInput();
},
syncInput() {
const input = this.$refs.fileInput;
if (! input) {
return;
}
const transfer = new DataTransfer();
this.files.forEach((file) => transfer.items.add(file));
input.files = transfer.files;
},
ensureFilesSelected(event) {
if (this.files.length === 0) {
event.preventDefault();
this.error = 'Drop or choose at least one audio file.';
return;
}
this.uploading = true;
this.error = null;
},
formatSize(bytes) {
if (bytes < 1024) {
return bytes + ' B';
}
if (bytes < 1024 * 1024) {
return (bytes / 1024).toFixed(1) + ' KB';
}
if (bytes < 1024 * 1024 * 1024) {
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}
return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
},
};
}
@@ -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-zinc-200 bg-zinc-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-zinc-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-zinc-600 sm:inline dark:text-zinc-300">
{{ $disk['free_human'] }} free
</span>
</div>
@@ -0,0 +1,62 @@
@props([
'status',
'label' => null,
/** @var string|null Alpine expression that returns a row object with badge_color + status_label (+ status for spinner) */
'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' => 'amber',
'pending' => 'zinc',
'failed' => 'red',
default => 'zinc',
};
$icon = $status === 'processing' ? 'loading' : null;
@endphp
@if ($alpineRow)
<span {{ $attributes->class('inline-flex items-center gap-1.5') }}>
@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
<flux:icon.loading
variant="micro"
class="size-3 text-amber-600 dark:text-amber-400"
x-show="{{ $alpineRow }}.status === 'processing'"
x-cloak
/>
</span>
@else
<span {{ $attributes->class('inline-flex items-center gap-1.5') }}>
<flux:badge size="sm" :color="$color" :icon="$icon">
{{ $label }}
</flux:badge>
</span>
@endif
+52 -52
View File
@@ -1,88 +1,88 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="dark">
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<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 />
<body class="min-h-screen bg-white text-zinc-800 antialiased dark:bg-zinc-900 dark:text-zinc-100">
<flux:header container class="border-b border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900">
<flux:sidebar.toggle class="lg:hidden" icon="bars-2" inset="left" />
<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" />
<flux:brand href="{{ route('recordings.index') }}" name="AndyTranscribe" wire:navigate class="max-lg:hidden" />
<a href="{{ route('recordings.index') }}" class="text-lg font-semibold tracking-tight text-teal-800 dark:text-teal-300">
AndyTranscribe
</a>
<flux:navbar class="-mb-px max-lg:hidden">
<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>
</flux:navbar>
<flux:navbar class="-mb-px max-lg:hidden">
<flux:navbar.item
:href="route('recordings.index')"
:current="request()->routeIs('recordings.index', 'recordings.show')"
>
{{ __('Recordings') }}
</flux:navbar.item>
<flux:navbar.item
:href="route('recordings.create')"
:current="request()->routeIs('recordings.create')"
>
{{ __('Upload') }}
</flux:navbar.item>
</flux:navbar>
<flux:spacer />
<flux:spacer />
<x-disk-space-bar />
<x-appearance-toggle />
<x-appearance-toggle />
@auth
<x-desktop-user-menu />
@endauth
</div>
@auth
<x-desktop-user-menu />
@endauth
</flux:header>
<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 collapsible="mobile" sticky class="lg:hidden">
<flux:sidebar.header>
<a href="{{ route('recordings.index') }}" class="text-base font-semibold text-teal-800 dark:text-teal-300">
AndyTranscribe
</a>
<flux:sidebar.brand href="{{ route('recordings.index') }}" name="AndyTranscribe" wire:navigate />
<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>
</flux:sidebar>
<main class="mx-auto max-w-5xl px-4 py-8 sm:px-6">
<flux:main container>
@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')
</main>
{{ $slot }}
</flux:main>
<flux:toast />
@fluxScripts
</body>
+12 -17
View File
@@ -1,28 +1,23 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="dark">
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
@include('partials.head')
<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-appearance-toggle />
@auth
<x-desktop-user-menu />
@endauth
</div>
<body class="min-h-screen bg-white text-zinc-800 antialiased dark:bg-zinc-900 dark:text-zinc-100">
<flux:header container class="border-b border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900">
<flux:brand href="{{ route('recordings.index') }}" name="AndyTranscribe" wire:navigate />
<flux:spacer />
<x-disk-space-bar />
<x-appearance-toggle />
@auth
<x-desktop-user-menu />
@endauth
</flux:header>
<main class="mx-auto max-w-5xl px-4 py-8 sm:px-6">
<flux:main container>
{{ $slot }}
</main>
</flux:main>
@fluxScripts
</body>
+10 -11
View File
@@ -1,21 +1,20 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="dark">
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
@include('partials.head')
</head>
<body class="min-h-screen bg-neutral-100 antialiased dark:bg-linear-to-b dark:from-neutral-950 dark:to-neutral-900">
<div class="bg-muted flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
<body class="min-h-screen bg-zinc-50 antialiased dark:bg-zinc-900">
<div class="flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
<div class="flex w-full max-w-md flex-col gap-6">
<a href="{{ route('home') }}" class="flex flex-col items-center gap-2 font-medium" wire:navigate>
<span class="flex h-9 w-9 items-center justify-center rounded-md">
<x-app-logo-icon class="size-9 fill-current text-black dark:text-white" />
</span>
<span class="sr-only">{{ config('app.name', 'Laravel') }}</span>
</a>
<flux:brand
class="justify-center"
name="{{ config('app.name', 'AndyTranscribe') }}"
href="{{ route('home') }}"
wire:navigate
/>
<div class="flex flex-col gap-6">
<div class="rounded-xl border bg-white dark:bg-stone-950 dark:border-stone-800 text-stone-800 shadow-xs">
<div class="rounded-xl border border-zinc-200 bg-white text-zinc-800 shadow-xs dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100">
<div class="px-10 py-8">{{ $slot }}</div>
</div>
</div>
+10 -10
View File
@@ -1,21 +1,21 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="dark">
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
@include('partials.head')
<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">
<div class="relative flex min-h-svh flex-col items-center justify-center gap-6 bg-stone-100 p-6 md:p-10 dark:bg-zinc-900">
<body class="min-h-screen bg-white text-zinc-800 antialiased dark:bg-zinc-900 dark:text-zinc-100">
<div class="relative flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
<div class="absolute end-4 top-4">
<x-appearance-toggle />
</div>
<div class="flex w-full max-w-sm flex-col gap-2">
<a href="{{ url('/') }}" class="mb-1 flex flex-col items-center gap-2 font-medium">
<span class="flex h-9 w-9 items-center justify-center rounded-md bg-teal-700 text-sm font-semibold text-white dark:bg-teal-600">
AT
</span>
<span class="text-lg font-semibold tracking-tight text-teal-800 dark:text-teal-300">{{ config('app.name', 'AndyTranscribe') }}</span>
</a>
<div class="flex w-full max-w-sm flex-col gap-6">
<flux:brand
class="justify-center"
name="{{ config('app.name', 'AndyTranscribe') }}"
href="{{ url('/') }}"
wire:navigate
/>
<div class="flex flex-col gap-6">
{{ $slot }}
</div>
+11 -15
View File
@@ -1,17 +1,14 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="dark">
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
@include('partials.head')
</head>
<body class="min-h-screen bg-white antialiased dark:bg-linear-to-b dark:from-neutral-950 dark:to-neutral-900">
<body class="min-h-screen bg-white antialiased dark:bg-zinc-900">
<div class="relative grid h-dvh flex-col items-center justify-center px-8 sm:px-0 lg:max-w-none lg:grid-cols-2 lg:px-0">
<div class="bg-muted relative hidden h-full flex-col p-10 text-white lg:flex dark:border-e dark:border-neutral-800">
<div class="absolute inset-0 bg-neutral-900"></div>
<div class="relative hidden h-full flex-col p-10 text-white lg:flex dark:border-e dark:border-zinc-800">
<div class="absolute inset-0 bg-zinc-900"></div>
<a href="{{ route('home') }}" class="relative z-20 flex items-center text-lg font-medium" wire:navigate>
<span class="flex h-10 w-10 items-center justify-center rounded-md">
<x-app-logo-icon class="me-2 h-7 fill-current text-white" />
</span>
{{ config('app.name', 'Laravel') }}
{{ config('app.name', 'AndyTranscribe') }}
</a>
@php
@@ -27,13 +24,12 @@
</div>
<div class="w-full lg:p-8">
<div class="mx-auto flex w-full flex-col justify-center space-y-6 sm:w-[350px]">
<a href="{{ route('home') }}" class="z-20 flex flex-col items-center gap-2 font-medium lg:hidden" wire:navigate>
<span class="flex h-9 w-9 items-center justify-center rounded-md">
<x-app-logo-icon class="size-9 fill-current text-black dark:text-white" />
</span>
<span class="sr-only">{{ config('app.name', 'Laravel') }}</span>
</a>
<flux:brand
class="z-20 justify-center lg:hidden"
name="{{ config('app.name', 'AndyTranscribe') }}"
href="{{ route('home') }}"
wire:navigate
/>
{{ $slot }}
</div>
</div>
@@ -0,0 +1,7 @@
<div>
<div class="mb-8">
<flux:heading size="xl">Upload recordings</flux:heading>
</div>
<livewire:upload-recordings />
</div>
@@ -0,0 +1,215 @@
<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 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.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>
<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">
{{ $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()"
/>
@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') }}
</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>
@@ -0,0 +1,225 @@
<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 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-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" 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">
<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,96 @@
<div
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"
>
<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
role="button"
tabindex="0"
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" />
</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>
@if ($showCancel)
<div class="flex items-center gap-3">
<flux:link href="{{ route('recordings.index') }}" wire:navigate>Cancel</flux:link>
</div>
@endif
</div>
+2
View File
@@ -7,6 +7,8 @@
</title>
<link rel="icon" href="/favicon.ico" sizes="any">
<link rel="preconnect" href="https://fonts.bunny.net">
<link href="https://fonts.bunny.net/css?family=inter:400,500,600&display=swap" rel="stylesheet" />
@vite(['resources/css/app.css', 'resources/js/app.js'])
@fluxAppearance
-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
+4
View File
@@ -10,3 +10,7 @@ Broadcast::channel('recording.{recordingId}', function (User $user, int $recordi
->where('user_id', $user->id)
->exists();
});
Broadcast::channel('user.{userId}.recordings', function (User $user, int $userId): bool {
return (int) $user->id === $userId;
});
+9 -11
View File
@@ -1,22 +1,20 @@
<?php
use App\Http\Controllers\CancelTranscriptionController;
use App\Http\Controllers\RecordingController;
use App\Http\Controllers\TranscribeController;
use App\Http\Controllers\TranscribePendingController;
use App\Http\Controllers\StreamRecordingController;
use App\Http\Controllers\TranscriptionStatusController;
use App\Livewire\Recordings\Create;
use App\Livewire\Recordings\Index;
use App\Livewire\Recordings\Show;
use Illuminate\Support\Facades\Route;
Route::redirect('/', '/recordings')->name('home');
Route::middleware('auth')->group(function (): void {
Route::resource('recordings', RecordingController::class)->except(['edit', 'update']);
Route::post('recordings/transcribe-pending', TranscribePendingController::class)
->name('recordings.transcribe-pending');
Route::post('recordings/{recording}/transcribe', TranscribeController::class)
->name('recordings.transcribe');
Route::post('recordings/{recording}/transcribe/cancel', CancelTranscriptionController::class)
->name('recordings.transcribe.cancel');
Route::get('recordings', Index::class)->name('recordings.index');
Route::get('recordings/create', Create::class)->name('recordings.create');
Route::get('recordings/{recording}', Show::class)->name('recordings.show');
Route::get('recordings/{recording}/audio', StreamRecordingController::class)
->name('recordings.audio');
Route::get('recordings/{recording}/transcription-status', TranscriptionStatusController::class)
->name('recordings.transcription-status');
});
+12
View File
@@ -24,12 +24,24 @@ class DemoUserSeederTest extends TestCase
$this->assertTrue(Hash::check('password', $user->password));
}
public function test_seeder_creates_admin_user(): void
{
$this->seed(DatabaseSeeder::class);
$user = User::query()->where('email', 'admin@example.com')->first();
$this->assertNotNull($user);
$this->assertSame('Admin', $user->name);
$this->assertTrue(Hash::check('password', $user->password));
}
public function test_seeder_is_idempotent(): void
{
$this->seed(DatabaseSeeder::class);
$this->seed(DatabaseSeeder::class);
$this->assertSame(1, User::query()->where('email', 'demo@example.com')->count());
$this->assertSame(1, User::query()->where('email', 'admin@example.com')->count());
}
public function test_seeder_assigns_orphaned_recordings_to_demo_user(): void
+1 -1
View File
@@ -52,7 +52,7 @@ class DiskSpaceTest extends TestCase
$this->get(route('recordings.index'))
->assertOk()
->assertSee('Disk space')
->assertSee('Disk space used', false)
->assertSee('free')
->assertSee('role="progressbar"', false);
}
+115
View File
@@ -0,0 +1,115 @@
<?php
namespace Tests\Feature;
use App\Models\Recording;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
class RecordingAudioStreamTest extends TestCase
{
use RefreshDatabase;
public function test_owner_can_stream_recording_audio(): void
{
Storage::fake('local');
Storage::disk('local')->put('recordings/note.mp3', 'fake-audio-bytes');
$user = User::factory()->create();
$recording = Recording::query()->create([
'user_id' => $user->id,
'title' => 'Note',
'original_filename' => 'note.mp3',
'file_path' => 'recordings/note.mp3',
'file_size_bytes' => 16,
'transcription_status' => 'pending',
]);
$this->actingAs($user)
->get(route('recordings.audio', $recording))
->assertOk()
->assertHeader('content-type', 'audio/mpeg')
->assertHeader('content-disposition', 'inline; filename=note.mp3');
}
public function test_show_page_includes_audio_player(): void
{
$user = User::factory()->create();
$recording = Recording::query()->create([
'user_id' => $user->id,
'title' => 'Playable',
'original_filename' => 'playable.mp3',
'file_path' => 'recordings/playable.mp3',
'file_size_bytes' => 10,
'transcription_status' => 'pending',
]);
$this->actingAs($user)
->get(route('recordings.show', $recording))
->assertOk()
->assertSee('Play', false)
->assertSee(route('recordings.audio', $recording), false)
->assertSee('<audio', false);
}
public function test_index_page_links_to_recording_show(): void
{
$user = User::factory()->create();
$recording = Recording::query()->create([
'user_id' => $user->id,
'title' => 'List playable',
'original_filename' => 'list.mp3',
'file_path' => 'recordings/list.mp3',
'file_size_bytes' => 10,
'transcription_status' => 'pending',
]);
$this->actingAs($user)
->get(route('recordings.index'))
->assertOk()
->assertSee('List playable')
->assertSee(route('recordings.show', $recording), false);
}
public function test_other_user_cannot_stream_recording_audio(): void
{
Storage::fake('local');
Storage::disk('local')->put('recordings/secret.mp3', 'fake-audio-bytes');
$owner = User::factory()->create();
$intruder = User::factory()->create();
$recording = Recording::query()->create([
'user_id' => $owner->id,
'title' => 'Secret',
'original_filename' => 'secret.mp3',
'file_path' => 'recordings/secret.mp3',
'file_size_bytes' => 16,
'transcription_status' => 'pending',
]);
$this->actingAs($intruder)
->get(route('recordings.audio', $recording))
->assertForbidden();
}
public function test_missing_audio_file_returns_not_found(): void
{
Storage::fake('local');
$user = User::factory()->create();
$recording = Recording::query()->create([
'user_id' => $user->id,
'title' => 'Gone',
'original_filename' => 'gone.mp3',
'file_path' => 'recordings/gone.mp3',
'file_size_bytes' => 10,
'transcription_status' => 'pending',
]);
$this->actingAs($user)
->get(route('recordings.audio', $recording))
->assertNotFound();
}
}
+25 -34
View File
@@ -3,12 +3,14 @@
namespace Tests\Feature;
use App\Jobs\TranscribeRecording;
use App\Livewire\UploadRecordings;
use App\Models\Recording;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Storage;
use Livewire\Livewire;
use Tests\TestCase;
class RecordingDuplicateUploadTest extends TestCase
@@ -33,19 +35,18 @@ class RecordingDuplicateUploadTest extends TestCase
$first = UploadedFile::fake()->createWithContent('meeting.mp3', 'identical-audio-bytes');
$duplicate = UploadedFile::fake()->createWithContent('meeting-copy.mp3', 'identical-audio-bytes');
$this->post(route('recordings.store'), [
'audio' => [$first],
])->assertRedirect();
Livewire::test(UploadRecordings::class)
->set('audio', [$first])
->assertRedirect();
$this->assertSame(1, Recording::query()->count());
Bus::assertDispatched(TranscribeRecording::class, 1);
$response = $this->post(route('recordings.store'), [
'audio' => [$duplicate],
]);
Livewire::test(UploadRecordings::class)
->set('audio', [$duplicate])
->assertRedirect(route('recordings.create'))
->assertSessionHas('error');
$response->assertRedirect(route('recordings.create'));
$response->assertSessionHas('error');
$this->assertSame(1, Recording::query()->count());
Bus::assertDispatched(TranscribeRecording::class, 1);
}
@@ -59,33 +60,24 @@ class RecordingDuplicateUploadTest extends TestCase
$two = UploadedFile::fake()->createWithContent('two.mp3', 'same-bytes');
$three = UploadedFile::fake()->createWithContent('three.mp3', 'different-bytes');
$response = $this->post(route('recordings.store'), [
'audio' => [$one, $two, $three],
]);
Livewire::test(UploadRecordings::class)
->set('audio', [$one, $two, $three])
->assertRedirect(route('recordings.index'))
->assertSessionHas('success');
$response->assertRedirect(route('recordings.index'));
$response->assertSessionHas('success');
$this->assertStringContainsString('Skipped 1 duplicate', session('success'));
$this->assertSame(2, Recording::query()->count());
Bus::assertDispatched(TranscribeRecording::class, 2);
}
public function test_upload_page_includes_existing_fingerprints_for_client_dedupe(): void
public function test_upload_page_shows_dropzone(): void
{
Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Existing',
'original_filename' => 'note.mp3',
'file_path' => 'recordings/note.mp3',
'file_size_bytes' => 2048,
'content_hash' => str_repeat('a', 64),
'transcription_status' => 'done',
]);
$this->get(route('recordings.create'))
->assertOk()
->assertSee('note.mp3:2048', false)
->assertSee('Duplicate files', false);
->assertSeeLivewire('upload-recordings')
->assertSee('Drop audio files here or click to browse')
->assertDontSee('Title (optional)')
->assertDontSee('Uploads start as soon as you drop');
}
public function test_duplicate_filename_and_size_is_skipped_without_content_hash(): void
@@ -103,12 +95,11 @@ class RecordingDuplicateUploadTest extends TestCase
'transcription_status' => 'done',
]);
$response = $this->post(route('recordings.store'), [
'audio' => [UploadedFile::fake()->createWithContent('legacy.mp3', 'legacy-audio')],
]);
Livewire::test(UploadRecordings::class)
->set('audio', [UploadedFile::fake()->createWithContent('legacy.mp3', 'legacy-audio')])
->assertRedirect(route('recordings.create'))
->assertSessionHas('error');
$response->assertRedirect(route('recordings.create'));
$response->assertSessionHas('error');
$this->assertSame(1, Recording::query()->count());
Bus::assertNothingDispatched();
}
@@ -120,9 +111,9 @@ class RecordingDuplicateUploadTest extends TestCase
$file = UploadedFile::fake()->createWithContent('hash-me.mp3', 'payload-for-hash');
$this->post(route('recordings.store'), [
'audio' => [$file],
])->assertRedirect();
Livewire::test(UploadRecordings::class)
->set('audio', [$file])
->assertRedirect();
$recording = Recording::query()->first();
$this->assertNotNull($recording);
+5 -2
View File
@@ -2,9 +2,11 @@
namespace Tests\Feature;
use App\Livewire\Recordings\Show;
use App\Models\Recording;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Tests\TestCase;
class RecordingOwnershipTest extends TestCase
@@ -74,8 +76,9 @@ class RecordingOwnershipTest extends TestCase
'transcription_status' => 'done',
]);
$this->actingAs($intruder)
->delete(route('recordings.destroy', $recording))
$this->actingAs($intruder);
Livewire::test(Show::class, ['recording' => $recording])
->assertForbidden();
$this->assertDatabaseHas('recordings', ['id' => $recording->id]);
+38 -47
View File
@@ -4,6 +4,9 @@ namespace Tests\Feature;
use App\Http\Requests\StoreRecordingRequest;
use App\Jobs\TranscribeRecording;
use App\Livewire\Recordings\Index;
use App\Livewire\Recordings\Show;
use App\Livewire\UploadRecordings;
use App\Models\Recording;
use App\Models\User;
use App\Services\TranscriptionService;
@@ -12,6 +15,7 @@ use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Storage;
use Laravel\Ai\Transcription;
use Livewire\Livewire;
use Tests\TestCase;
class RecordingUploadTest extends TestCase
@@ -54,18 +58,17 @@ class RecordingUploadTest extends TestCase
$file = UploadedFile::fake()->create('meeting.mp3', 500, 'audio/mpeg');
$response = $this->post(route('recordings.store'), [
'audio' => [$file],
'title' => 'Team meeting',
]);
Livewire::test(UploadRecordings::class)
->set('audio', [$file])
->assertRedirect(route('recordings.show', Recording::query()->first()));
$recording = Recording::query()->first();
$this->assertNotNull($recording);
$response->assertRedirect(route('recordings.show', $recording));
$this->assertSame('Team meeting', $recording->title);
$this->assertSame('meeting', $recording->title);
$this->assertSame('pending', $recording->transcription_status);
$this->assertSame('local', $recording->transcription_driver);
$this->assertNull($recording->transcription_percent);
$this->assertNotNull($recording->transcription_started_at);
Storage::disk('local')->assertExists($recording->file_path);
Bus::assertDispatched(TranscribeRecording::class);
@@ -76,16 +79,13 @@ class RecordingUploadTest extends TestCase
Storage::fake('local');
Bus::fake();
$response = $this->post(route('recordings.store'), [
'audio' => [
Livewire::test(UploadRecordings::class)
->set('audio', [
UploadedFile::fake()->createWithContent('one.mp3', str_repeat('a', 400)),
UploadedFile::fake()->createWithContent('two.wav', str_repeat('b', 400)),
UploadedFile::fake()->createWithContent('three.ogg', str_repeat('c', 400)),
],
]);
$response->assertRedirect(route('recordings.index'));
$response->assertSessionHas('success');
])
->assertRedirect(route('recordings.index'));
$this->assertSame(3, Recording::query()->count());
Bus::assertDispatched(TranscribeRecording::class, 3);
@@ -99,9 +99,9 @@ class RecordingUploadTest extends TestCase
{
$this->get(route('recordings.create'))
->assertOk()
->assertSee('Drop audio files here')
->assertSee('Drop audio files here or click to browse')
->assertSee('max 2 GB each', false)
->assertSee('name="audio[]"', false);
->assertSeeLivewire('upload-recordings');
}
public function test_files_larger_than_two_gigabytes_are_rejected(): void
@@ -113,11 +113,9 @@ class RecordingUploadTest extends TestCase
->create('huge.mp3', 10, 'audio/mpeg')
->size(StoreRecordingRequest::MAX_AUDIO_KILOBYTES + 1);
$this->from(route('recordings.create'))
->post(route('recordings.store'), [
'audio' => [$file],
])
->assertSessionHasErrors(['audio.0']);
Livewire::test(UploadRecordings::class)
->set('audio', [$file])
->assertHasErrors(['audio.0']);
$this->assertSame(0, Recording::query()->count());
Bus::assertNothingDispatched();
@@ -133,15 +131,14 @@ class RecordingUploadTest extends TestCase
['clip.ogg', 'audio/ogg', 'ogg-bytes'],
['talk.m4a', 'audio/mp4', 'm4a-bytes'],
] as [$name, $mime, $contents]) {
$response = $this->post(route('recordings.store'), [
'audio' => [UploadedFile::fake()->createWithContent($name, $contents)],
'title' => $name,
]);
Livewire::test(UploadRecordings::class)
->set('audio', [UploadedFile::fake()->createWithContent($name, $contents)])
->assertRedirect();
$recording = Recording::query()->where('title', $name)->first();
$expectedTitle = pathinfo($name, PATHINFO_FILENAME);
$recording = Recording::query()->where('title', $expectedTitle)->first();
$this->assertNotNull($recording, "Failed uploading {$name}");
$response->assertRedirect(route('recordings.show', $recording));
Storage::disk('local')->assertExists($recording->file_path);
}
@@ -152,11 +149,9 @@ class RecordingUploadTest extends TestCase
{
Storage::fake('local');
$this->from(route('recordings.create'))
->post(route('recordings.store'), [
'audio' => [UploadedFile::fake()->create('notes.txt', 10, 'text/plain')],
])
->assertSessionHasErrors(['audio.0']);
Livewire::test(UploadRecordings::class)
->set('audio', [UploadedFile::fake()->create('notes.txt', 10, 'text/plain')])
->assertHasErrors(['audio.0']);
}
public function test_user_can_queue_local_transcription(): void
@@ -173,8 +168,8 @@ class RecordingUploadTest extends TestCase
// Fake AI so the afterResponse job (sync) does not call a real provider.
Transcription::fake(['Queued transcription text.']);
$this->post(route('recordings.transcribe', $recording))
->assertRedirect();
Livewire::test(Show::class, ['recording' => $recording])
->call('startTranscription');
$recording->refresh();
$this->assertSame('local', $recording->transcription_driver);
@@ -332,8 +327,8 @@ class RecordingUploadTest extends TestCase
'updated_at' => now()->subMinutes(10),
]);
$this->post(route('recordings.transcribe', $recording))
->assertRedirect();
Livewire::test(Show::class, ['recording' => $recording])
->call('startTranscription');
$recording->refresh();
$this->assertSame('local', $recording->transcription_driver);
@@ -355,9 +350,8 @@ class RecordingUploadTest extends TestCase
'transcription_started_at' => now(),
]);
$this->post(route('recordings.transcribe.cancel', $recording))
->assertRedirect()
->assertSessionHas('success');
Livewire::test(Show::class, ['recording' => $recording])
->call('cancelTranscription');
$recording->refresh();
$this->assertSame('cancelled', $recording->transcription_status);
@@ -380,9 +374,8 @@ class RecordingUploadTest extends TestCase
'transcription_started_at' => now()->subMinute(),
]);
$this->post(route('recordings.transcribe', $recording))
->assertRedirect()
->assertSessionHas('success');
Livewire::test(Show::class, ['recording' => $recording])
->call('startTranscription');
$recording->refresh();
$this->assertSame('local', $recording->transcription_driver);
@@ -464,8 +457,8 @@ class RecordingUploadTest extends TestCase
'transcribed_at' => now()->subHour(),
]);
$this->post(route('recordings.transcribe', $recording))
->assertRedirect();
Livewire::test(Show::class, ['recording' => $recording])
->call('startTranscription');
$recording->refresh();
$this->assertSame('Old transcript text.', $recording->transcript);
@@ -497,10 +490,8 @@ class RecordingUploadTest extends TestCase
'transcript' => 'Finished text',
]);
$this->from(route('recordings.index'))
->post(route('recordings.transcribe-pending'))
->assertRedirect(route('recordings.index'))
->assertSessionHas('success');
Livewire::test(Index::class)
->call('queuePending');
Bus::assertDispatched(TranscribeRecording::class, 1);
}
+283
View File
@@ -0,0 +1,283 @@
<?php
namespace Tests\Feature\Recordings;
use App\Jobs\TranscribeRecording;
use App\Livewire\Recordings\Index;
use App\Models\Recording;
use App\Models\User;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Storage;
use Livewire\Livewire;
use Tests\TestCase;
class IndexTest extends TestCase
{
use RefreshDatabase;
public function test_index_page_renders_as_livewire(): void
{
$user = User::factory()->create();
Recording::query()->create([
'user_id' => $user->id,
'title' => 'Pocket note',
'original_filename' => 'note.mp3',
'file_path' => 'recordings/note.mp3',
'file_size_bytes' => 1024,
'transcription_status' => 'done',
'transcript' => 'hello world',
]);
$this->actingAs($user)
->get(route('recordings.index'))
->assertOk()
->assertSeeLivewire(Index::class)
->assertSee('Pocket note');
}
public function test_empty_index_shows_upload_dropzone(): void
{
$user = User::factory()->create();
$this->actingAs($user)
->get(route('recordings.index'))
->assertOk()
->assertSeeLivewire('upload-recordings')
->assertSee('Drop audio files here or click to browse')
->assertDontSee('No recordings yet.')
->assertDontSee('Upload your first MP3');
}
public function test_search_filters_recordings(): void
{
$user = User::factory()->create();
$this->actingAs($user);
Recording::query()->create([
'user_id' => $user->id,
'title' => 'Office chat',
'original_filename' => 'office.mp3',
'file_path' => 'recordings/office.mp3',
'file_size_bytes' => 100,
'transcription_status' => 'done',
'transcript' => 'talking about the pocket recorder today',
]);
Recording::query()->create([
'user_id' => $user->id,
'title' => 'Unrelated',
'original_filename' => 'other.mp3',
'file_path' => 'recordings/other.mp3',
'file_size_bytes' => 100,
'transcription_status' => 'done',
'transcript' => 'nothing useful',
]);
Livewire::test(Index::class)
->set('search', 'pocket recorder')
->assertSee('Office chat')
->assertDontSee('Unrelated');
}
public function test_queue_pending_dispatches_jobs(): void
{
Bus::fake();
$user = User::factory()->create();
$this->actingAs($user);
Recording::query()->create([
'user_id' => $user->id,
'title' => 'Needs work',
'original_filename' => 'needs.mp3',
'file_path' => 'recordings/needs.mp3',
'file_size_bytes' => 100,
'transcription_status' => 'pending',
]);
Livewire::test(Index::class)
->call('queuePending');
Bus::assertDispatched(TranscribeRecording::class, 1);
}
public function test_index_polls_while_transcriptions_are_active(): void
{
$user = User::factory()->create();
$this->actingAs($user);
Recording::query()->create([
'user_id' => $user->id,
'title' => 'In progress',
'original_filename' => 'active.mp3',
'file_path' => 'recordings/active.mp3',
'file_size_bytes' => 100,
'transcription_status' => 'processing',
'transcription_progress' => 'Transcribing locally…',
'transcription_percent' => 40,
'transcription_driver' => 'local',
'transcription_started_at' => now(),
]);
Livewire::test(Index::class)
->assertSee('wire:poll', false)
->assertSee('Transcribing locally…')
->assertSee('40%')
->assertSeeHtml('bg-amber-400');
}
public function test_queued_recordings_do_not_show_fake_percent(): void
{
$user = User::factory()->create();
$this->actingAs($user);
Recording::query()->create([
'user_id' => $user->id,
'title' => 'Waiting in line',
'original_filename' => 'queued.mp3',
'file_path' => 'recordings/queued.mp3',
'file_size_bytes' => 100,
'transcription_status' => 'pending',
'transcription_progress' => 'Queued — waiting to start…',
'transcription_percent' => null,
'transcription_driver' => 'local',
'transcription_started_at' => now(),
]);
Livewire::test(Index::class)
->assertSee('Waiting in line')
->assertSee('Queued')
->assertSee('Queued — waiting to start…')
->assertDontSee('5%')
->assertSeeHtml('bg-zinc-400/15')
->assertDontSeeHtml('bg-amber-400');
}
public function test_index_does_not_poll_when_all_transcriptions_are_idle(): void
{
$user = User::factory()->create();
$this->actingAs($user);
Recording::query()->create([
'user_id' => $user->id,
'title' => 'Finished',
'original_filename' => 'done.mp3',
'file_path' => 'recordings/done.mp3',
'file_size_bytes' => 100,
'transcription_status' => 'done',
'transcript' => 'all done',
]);
Livewire::test(Index::class)
->assertDontSee('wire:poll', false);
}
public function test_user_can_delete_recording_from_index(): void
{
Storage::fake('local');
Storage::disk('local')->put('recordings/delete-me.mp3', 'bytes');
$user = User::factory()->create();
$this->actingAs($user);
$recording = Recording::query()->create([
'user_id' => $user->id,
'title' => 'Delete from list',
'original_filename' => 'delete-me.mp3',
'file_path' => 'recordings/delete-me.mp3',
'file_size_bytes' => 5,
'transcription_status' => 'done',
]);
Livewire::test(Index::class)
->assertSee('Delete from list')
->call('delete', $recording->id)
->assertDontSee('Delete from list');
$this->assertDatabaseMissing('recordings', ['id' => $recording->id]);
Storage::disk('local')->assertMissing('recordings/delete-me.mp3');
}
public function test_user_can_delete_all_recordings_from_index(): void
{
Storage::fake('local');
Storage::disk('local')->put('recordings/one.mp3', 'one');
Storage::disk('local')->put('recordings/two.mp3', 'two');
Storage::disk('local')->put('recordings/other.mp3', 'other');
$user = User::factory()->create();
$other = User::factory()->create();
$this->actingAs($user);
Recording::query()->create([
'user_id' => $user->id,
'title' => 'Mine one',
'original_filename' => 'one.mp3',
'file_path' => 'recordings/one.mp3',
'file_size_bytes' => 3,
'transcription_status' => 'done',
]);
Recording::query()->create([
'user_id' => $user->id,
'title' => 'Mine two',
'original_filename' => 'two.mp3',
'file_path' => 'recordings/two.mp3',
'file_size_bytes' => 3,
'transcription_status' => 'pending',
]);
Recording::query()->create([
'user_id' => $other->id,
'title' => 'Someone else',
'original_filename' => 'other.mp3',
'file_path' => 'recordings/other.mp3',
'file_size_bytes' => 5,
'transcription_status' => 'done',
]);
Livewire::test(Index::class)
->assertSee('Delete all')
->call('deleteAll')
->assertDontSee('Mine one')
->assertDontSee('Mine two');
$this->assertDatabaseMissing('recordings', ['user_id' => $user->id]);
$this->assertDatabaseHas('recordings', ['user_id' => $other->id, 'title' => 'Someone else']);
Storage::disk('local')->assertMissing('recordings/one.mp3');
Storage::disk('local')->assertMissing('recordings/two.mp3');
Storage::disk('local')->assertExists('recordings/other.mp3');
}
public function test_user_cannot_delete_another_users_recording_from_index(): void
{
Storage::fake('local');
$owner = User::factory()->create();
$intruder = User::factory()->create();
$recording = Recording::query()->create([
'user_id' => $owner->id,
'title' => 'Keep me',
'original_filename' => 'keep.mp3',
'file_path' => 'recordings/keep.mp3',
'file_size_bytes' => 10,
'transcription_status' => 'done',
]);
$this->actingAs($intruder);
try {
Livewire::test(Index::class)
->call('delete', $recording->id);
$this->fail('Expected deleting another user\'s recording to fail.');
} catch (ModelNotFoundException) {
// Owned-query findOrFail hides other users' recordings.
}
$this->assertDatabaseHas('recordings', ['id' => $recording->id]);
}
}
+62
View File
@@ -0,0 +1,62 @@
<?php
namespace Tests\Feature\Recordings;
use App\Livewire\Recordings\Show;
use App\Models\Recording;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use Livewire\Livewire;
use Tests\TestCase;
class ShowTest extends TestCase
{
use RefreshDatabase;
public function test_show_page_renders_as_livewire(): void
{
$user = User::factory()->create();
$recording = Recording::query()->create([
'user_id' => $user->id,
'title' => 'Show me',
'original_filename' => 'show.mp3',
'file_path' => 'recordings/show.mp3',
'file_size_bytes' => 100,
'transcription_status' => 'done',
'transcript' => 'Finished text',
]);
$this->actingAs($user)
->get(route('recordings.show', $recording))
->assertOk()
->assertSeeLivewire(Show::class)
->assertSee('Show me')
->assertSee('Play', false);
}
public function test_user_can_delete_own_recording(): void
{
Storage::fake('local');
Storage::disk('local')->put('recordings/delete-me.mp3', 'bytes');
$user = User::factory()->create();
$this->actingAs($user);
$recording = Recording::query()->create([
'user_id' => $user->id,
'title' => 'Delete me',
'original_filename' => 'delete-me.mp3',
'file_path' => 'recordings/delete-me.mp3',
'file_size_bytes' => 5,
'transcription_status' => 'done',
]);
Livewire::test(Show::class, ['recording' => $recording])
->call('delete')
->assertRedirect(route('recordings.index'));
$this->assertDatabaseMissing('recordings', ['id' => $recording->id]);
}
}
@@ -158,5 +158,6 @@ class TranscriptionBroadcastTest extends TestCase
$this->assertSame('RecordingTranscriptionUpdated', $event->broadcastAs());
$this->assertSame('private-recording.'.$recording->id, $event->broadcastOn()[0]->name);
$this->assertSame('private-user.'.$this->user->id.'.recordings', $event->broadcastOn()[1]->name);
}
}
@@ -0,0 +1,65 @@
<?php
namespace Tests\Feature;
use App\Jobs\TranscribeRecording;
use App\Livewire\UploadRecordings;
use App\Models\Recording;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Storage;
use Livewire\Livewire;
use Tests\TestCase;
class UploadRecordingsLivewireTest extends TestCase
{
use RefreshDatabase;
protected User $user;
protected function setUp(): void
{
parent::setUp();
$this->user = User::factory()->create();
$this->actingAs($this->user);
}
public function test_dropping_files_saves_and_queues_transcription(): void
{
Storage::fake('local');
Bus::fake();
$file = UploadedFile::fake()->create('meeting.mp3', 500, 'audio/mpeg');
Livewire::test(UploadRecordings::class)
->set('audio', [$file])
->assertRedirect(route('recordings.show', Recording::query()->first()));
$recording = Recording::query()->first();
$this->assertNotNull($recording);
$this->assertSame('meeting', $recording->title);
$this->assertSame('pending', $recording->transcription_status);
Storage::disk('local')->assertExists($recording->file_path);
Bus::assertDispatched(TranscribeRecording::class);
}
public function test_batch_upload_redirects_to_index(): void
{
Storage::fake('local');
Bus::fake();
Livewire::test(UploadRecordings::class)
->set('audio', [
UploadedFile::fake()->createWithContent('one.mp3', str_repeat('a', 400)),
UploadedFile::fake()->createWithContent('two.wav', str_repeat('b', 400)),
])
->assertRedirect(route('recordings.index'));
$this->assertSame(2, Recording::query()->count());
Bus::assertDispatched(TranscribeRecording::class, 2);
}
}