diff --git a/.env.example b/.env.example
index b425773..679ef61 100644
--- a/.env.example
+++ b/.env.example
@@ -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
diff --git a/README.md b/README.md
index 4949eba..a26f8b7 100644
--- a/README.md
+++ b/README.md
@@ -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.
diff --git a/app/Events/RecordingTranscriptionUpdated.php b/app/Events/RecordingTranscriptionUpdated.php
index 166b441..d48bc79 100644
--- a/app/Events/RecordingTranscriptionUpdated.php
+++ b/app/Events/RecordingTranscriptionUpdated.php
@@ -27,6 +27,7 @@ class RecordingTranscriptionUpdated implements ShouldBroadcastNow
{
return [
new PrivateChannel('recording.'.$this->recording->id),
+ new PrivateChannel('user.'.$this->recording->user_id.'.recordings'),
];
}
diff --git a/app/Livewire/Recordings/Index.php b/app/Livewire/Recordings/Index.php
index e003ca1..6922be1 100644
--- a/app/Livewire/Recordings/Index.php
+++ b/app/Livewire/Recordings/Index.php
@@ -8,6 +8,7 @@ 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;
@@ -19,14 +20,30 @@ 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;
@@ -69,6 +86,36 @@ class Index extends Component
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();
@@ -78,6 +125,8 @@ class Index extends Component
->orderBy('id')
->each(fn (Recording $recording) => $recording->recoverOrphanedTranscription());
+ $totalCount = $user->recordings()->count();
+
$query = $user->recordings()->latest();
$search = trim($this->search);
@@ -94,10 +143,16 @@ class Index extends Component
->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,
]);
}
}
diff --git a/app/Livewire/UploadRecordings.php b/app/Livewire/UploadRecordings.php
index f49625d..e3ba8d2 100644
--- a/app/Livewire/UploadRecordings.php
+++ b/app/Livewire/UploadRecordings.php
@@ -19,10 +19,10 @@ class UploadRecordings extends Component
*/
public array $audio = [];
- public string $title = '';
-
public bool $saving = false;
+ public bool $showCancel = true;
+
public function updatedAudio(): void
{
if ($this->saving || $this->audio === []) {
@@ -40,49 +40,46 @@ class UploadRecordings extends Component
$this->saving = true;
- $store ??= app(StoreUploadedRecordings::class);
+ try {
+ $store ??= app(StoreUploadedRecordings::class);
- $this->validate([
- 'audio' => ['required', 'array', 'min:1', 'max:50'],
- 'audio.*' => [
- 'required',
- File::types(StoreRecordingRequest::AUDIO_EXTENSIONS)->max('2gb'),
- ],
- 'title' => ['nullable', 'string', 'max:255'],
- ], [
- '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.',
- ]);
+ $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,
- filled($this->title) ? $this->title : null,
- );
+ $result = $store->handle(Auth::user(), $this->audio);
- $this->audio = [];
- $this->title = '';
- $this->saving = false;
+ $this->audio = [];
- if ($result['recordings'] === []) {
- session()->flash('error', $result['message']);
+ if ($result['recordings'] === []) {
+ session()->flash('error', $result['message']);
- return $this->redirect(route('recordings.create'), navigate: true);
+ 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;
}
-
- 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);
}
public function render()
diff --git a/app/Models/Recording.php b/app/Models/Recording.php
index c0168e1..5b9a480 100644
--- a/app/Models/Recording.php
+++ b/app/Models/Recording.php
@@ -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.
diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php
index 62e06ad..d3f1e65 100755
--- a/database/seeders/DatabaseSeeder.php
+++ b/database/seeders/DatabaseSeeder.php
@@ -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]);
diff --git a/resources/css/app.css b/resources/css/app.css
index 406abdf..9981bdc 100644
--- a/resources/css/app.css
+++ b/resources/css/app.css
@@ -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 *));
diff --git a/resources/js/transcription.js b/resources/js/transcription.js
index e9855cd..78498b9 100644
--- a/resources/js/transcription.js
+++ b/resources/js/transcription.js
@@ -5,7 +5,7 @@
const BADGE_COLORS = {
done: 'teal',
processing: 'amber',
- pending: 'amber',
+ pending: 'zinc',
failed: 'red',
cancelled: 'zinc',
};
@@ -55,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 () => {};
@@ -76,14 +70,6 @@ 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.
*/
@@ -208,34 +194,19 @@ 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) {
@@ -282,41 +253,5 @@ export function recordingsIndex({ recordings, pendingCount }) {
this.isPlaying = false;
});
},
-
- applyPayload(payload) {
- const id = payload.id;
-
- if (!this.rows[id]) {
- 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_color: badgeColorFor(payload.status),
- };
-
- 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;
- }
- },
-
- row(id) {
- return this.rows[id] || {};
- },
};
}
diff --git a/resources/views/components/disk-space-bar.blade.php b/resources/views/components/disk-space-bar.blade.php
index a1e672e..b8e3be2 100644
--- a/resources/views/components/disk-space-bar.blade.php
+++ b/resources/views/components/disk-space-bar.blade.php
@@ -2,11 +2,11 @@
/** @var array{used_percent: float, free_percent: float, free_human: string, total_human: string, used_human: string} $disk */
@endphp
-
+
{{ $disk['free_human'] }} free
diff --git a/resources/views/components/transcription-status-badge.blade.php b/resources/views/components/transcription-status-badge.blade.php
index 9775da2..2393571 100644
--- a/resources/views/components/transcription-status-badge.blade.php
+++ b/resources/views/components/transcription-status-badge.blade.php
@@ -1,7 +1,7 @@
@props([
'status',
'label' => null,
- /** @var string|null Alpine expression that returns a row object with badge_color + status_label */
+ /** @var string|null Alpine expression that returns a row object with badge_color + status_label (+ status for spinner) */
'alpineRow' => null,
])
@@ -17,14 +17,17 @@
$color = match ($status) {
'done' => 'teal',
- 'processing', 'pending' => 'amber',
+ 'processing' => 'amber',
+ 'pending' => 'zinc',
'failed' => 'red',
default => 'zinc',
};
+
+ $icon = $status === 'processing' ? 'loading' : null;
@endphp
@if ($alpineRow)
- class('inline-flex') }}>
+ class('inline-flex items-center gap-1.5') }}>
@foreach (['teal', 'amber', 'red', 'zinc'] as $badgeColor)
@if ($badgeColor === $color)
{{ $label }}
@endif
@endforeach
+
@else
-
- {{ $label }}
-
+ class('inline-flex items-center gap-1.5') }}>
+
+ {{ $label }}
+
+
@endif
diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php
index a8f907e..a999509 100644
--- a/resources/views/layouts/app.blade.php
+++ b/resources/views/layouts/app.blade.php
@@ -1,52 +1,46 @@
-
+
@include('partials.head', ['title' => $title ?? null])
-
-
-
-
+
+
+
-
- AndyTranscribe
-
+
-
-
- {{ __('Recordings') }}
-
-
- {{ __('Upload') }}
-
-
+
+
+ {{ __('Recordings') }}
+
+
+ {{ __('Upload') }}
+
+
-
+
-
+
-
+
- @auth
-
- @endauth
-
+ @auth
+
+ @endauth
-
+
-
- AndyTranscribe
-
+
@@ -60,7 +54,7 @@
-
+
@if (session('success'))
{{ session('success') }}
@@ -86,7 +80,7 @@
@endif
{{ $slot }}
-
+
diff --git a/resources/views/layouts/app/header.blade.php b/resources/views/layouts/app/header.blade.php
index 8595eea..a44e2c4 100644
--- a/resources/views/layouts/app/header.blade.php
+++ b/resources/views/layouts/app/header.blade.php
@@ -1,27 +1,23 @@
-
+
@include('partials.head')
-
-
-
+
+
+
+
+
+
+ @auth
+
+ @endauth
-
+
{{ $slot }}
-
+
@fluxScripts
diff --git a/resources/views/layouts/auth/card.blade.php b/resources/views/layouts/auth/card.blade.php
index c5cf75a..666702a 100644
--- a/resources/views/layouts/auth/card.blade.php
+++ b/resources/views/layouts/auth/card.blade.php
@@ -1,21 +1,20 @@
-
+
@include('partials.head')
-
-
+
+
-
-
-
-
-
- {{ config('app.name', 'Laravel') }}
-
+
-
diff --git a/resources/views/layouts/auth/simple.blade.php b/resources/views/layouts/auth/simple.blade.php
index fd066e1..c80a2e6 100644
--- a/resources/views/layouts/auth/simple.blade.php
+++ b/resources/views/layouts/auth/simple.blade.php
@@ -1,21 +1,21 @@
-
+
@include('partials.head')
-
-
+
+
-
-
-
- AT
-
- {{ config('app.name', 'AndyTranscribe') }}
-
+
+
{{ $slot }}
diff --git a/resources/views/layouts/auth/split.blade.php b/resources/views/layouts/auth/split.blade.php
index a25d1d6..cb0075e 100644
--- a/resources/views/layouts/auth/split.blade.php
+++ b/resources/views/layouts/auth/split.blade.php
@@ -1,17 +1,14 @@
-
+
@include('partials.head')
-
+
-
-
+
diff --git a/resources/views/livewire/recordings/create.blade.php b/resources/views/livewire/recordings/create.blade.php
index 85d0c46..c0acbe8 100644
--- a/resources/views/livewire/recordings/create.blade.php
+++ b/resources/views/livewire/recordings/create.blade.php
@@ -1,10 +1,6 @@
Upload recordings
-
- Drop one or many pocket-recorder files. Embedded metadata is extracted when available.
- Duplicate files (same name and size, or identical content) are skipped.
-
diff --git a/resources/views/livewire/recordings/index.blade.php b/resources/views/livewire/recordings/index.blade.php
index a0fe5ba..9bcc816 100644
--- a/resources/views/livewire/recordings/index.blade.php
+++ b/resources/views/livewire/recordings/index.blade.php
@@ -1,25 +1,8 @@
-
-
- Queue pending
-
-
+
+ @if ($pendingCount > 0)
+
+ Queue {{ $pendingCount }} pending
+ {{ $pendingCount === 1 ? 'transcription' : 'transcriptions' }}
+
+ @endif
+
+ @if ($totalCount > 0)
+
+
+ Delete all
+
+
+ @endif
+ @if ($totalCount > 0)
+
+
+
+ Delete all recordings?
+
+ This permanently removes
+ {{ $totalCount === 1 ? 'your 1 recording' : "all {$totalCount} recordings" }}
+ and their audio files. This cannot be undone.
+
+
+
+
+ Cancel
+
+
+ Delete all
+
+
+
+
+ @endif
+
@if ($recordings->isEmpty())
-
- @if (filled($search))
+ @if (filled($search))
+
No recordings match “{{ $search }}”.
Clear search
- @else
- No recordings yet.
-
- Upload your first MP3
-
- @endif
-
+
+ @else
+
+ @endif
@else
@foreach ($recordings as $recording)
-
+
{{ $recording->duration_formatted }}
-
+
{{ $recording->word_count > 0 ? number_format($recording->word_count) : '—' }}
@@ -138,17 +148,25 @@
-
-
-
-
+ @if ($recording->transcription_status === 'pending' && filled($recording->transcription_progress))
+
+ {{ $recording->transcription_progress }}
+
+ @elseif ($recording->transcription_status === 'processing' && filled($recording->transcription_progress))
+
+ @if ($recording->transcription_percent)
+ {{ $recording->transcription_percent }}% ·
+ @endif
+ {{ $recording->transcription_progress }}
+
+ @endif
{{ $recording->created_at?->format('Y-m-d H:i') }}
diff --git a/resources/views/livewire/recordings/show.blade.php b/resources/views/livewire/recordings/show.blade.php
index 09b7577..3b7a589 100644
--- a/resources/views/livewire/recordings/show.blade.php
+++ b/resources/views/livewire/recordings/show.blade.php
@@ -145,16 +145,31 @@
-
+
+
+
+
-
- · Elapsed
+
+
+ ·
+
+ Elapsed
-
-
+
+
diff --git a/resources/views/livewire/upload-recordings.blade.php b/resources/views/livewire/upload-recordings.blade.php
index 0c30bb3..dc6e6d6 100644
--- a/resources/views/livewire/upload-recordings.blade.php
+++ b/resources/views/livewire/upload-recordings.blade.php
@@ -1,28 +1,59 @@
-
-
Audio files
+
+
@@ -57,11 +88,9 @@
@enderror
-
- Uploads start as soon as you drop or choose files. Duplicate files (same name and size, or identical content) are skipped.
-
-
-
- Cancel
-
+ @if ($showCancel)
+
+ Cancel
+
+ @endif
diff --git a/resources/views/partials/head.blade.php b/resources/views/partials/head.blade.php
index b23d7bd..31382b0 100644
--- a/resources/views/partials/head.blade.php
+++ b/resources/views/partials/head.blade.php
@@ -7,6 +7,8 @@
+
+
@vite(['resources/css/app.css', 'resources/js/app.js'])
@fluxAppearance
diff --git a/routes/channels.php b/routes/channels.php
index e9a5147..a22983e 100644
--- a/routes/channels.php
+++ b/routes/channels.php
@@ -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;
+});
diff --git a/tests/Feature/DemoUserSeederTest.php b/tests/Feature/DemoUserSeederTest.php
index 1e17743..d3dd74f 100644
--- a/tests/Feature/DemoUserSeederTest.php
+++ b/tests/Feature/DemoUserSeederTest.php
@@ -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
diff --git a/tests/Feature/RecordingDuplicateUploadTest.php b/tests/Feature/RecordingDuplicateUploadTest.php
index 339dccb..65134a7 100644
--- a/tests/Feature/RecordingDuplicateUploadTest.php
+++ b/tests/Feature/RecordingDuplicateUploadTest.php
@@ -70,22 +70,14 @@ class RecordingDuplicateUploadTest extends TestCase
Bus::assertDispatched(TranscribeRecording::class, 2);
}
- public function test_upload_page_mentions_duplicate_skipping(): 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()
->assertSeeLivewire('upload-recordings')
- ->assertSee('Duplicate files', false);
+ ->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
diff --git a/tests/Feature/RecordingUploadTest.php b/tests/Feature/RecordingUploadTest.php
index c7b28ec..8f189e0 100644
--- a/tests/Feature/RecordingUploadTest.php
+++ b/tests/Feature/RecordingUploadTest.php
@@ -59,16 +59,16 @@ class RecordingUploadTest extends TestCase
$file = UploadedFile::fake()->create('meeting.mp3', 500, 'audio/mpeg');
Livewire::test(UploadRecordings::class)
- ->set('title', 'Team meeting')
->set('audio', [$file])
->assertRedirect(route('recordings.show', Recording::query()->first()));
$recording = Recording::query()->first();
$this->assertNotNull($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);
@@ -132,11 +132,11 @@ class RecordingUploadTest extends TestCase
['talk.m4a', 'audio/mp4', 'm4a-bytes'],
] as [$name, $mime, $contents]) {
Livewire::test(UploadRecordings::class)
- ->set('title', $name)
->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}");
Storage::disk('local')->assertExists($recording->file_path);
diff --git a/tests/Feature/Recordings/IndexTest.php b/tests/Feature/Recordings/IndexTest.php
index cff7242..80494ad 100644
--- a/tests/Feature/Recordings/IndexTest.php
+++ b/tests/Feature/Recordings/IndexTest.php
@@ -38,6 +38,19 @@ class IndexTest extends TestCase
->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();
@@ -90,6 +103,77 @@ class IndexTest extends TestCase
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');
@@ -116,6 +200,57 @@ class IndexTest extends TestCase
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');
diff --git a/tests/Feature/TranscriptionBroadcastTest.php b/tests/Feature/TranscriptionBroadcastTest.php
index 8dd46ca..f841012 100644
--- a/tests/Feature/TranscriptionBroadcastTest.php
+++ b/tests/Feature/TranscriptionBroadcastTest.php
@@ -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);
}
}
diff --git a/tests/Feature/UploadRecordingsLivewireTest.php b/tests/Feature/UploadRecordingsLivewireTest.php
index 610f87f..09524dc 100644
--- a/tests/Feature/UploadRecordingsLivewireTest.php
+++ b/tests/Feature/UploadRecordingsLivewireTest.php
@@ -35,14 +35,13 @@ class UploadRecordingsLivewireTest extends TestCase
$file = UploadedFile::fake()->create('meeting.mp3', 500, 'audio/mpeg');
Livewire::test(UploadRecordings::class)
- ->set('title', 'Team meeting')
->set('audio', [$file])
->assertRedirect(route('recordings.show', Recording::query()->first()));
$recording = Recording::query()->first();
$this->assertNotNull($recording);
- $this->assertSame('Team meeting', $recording->title);
+ $this->assertSame('meeting', $recording->title);
$this->assertSame('pending', $recording->transcription_status);
Storage::disk('local')->assertExists($recording->file_path);
Bus::assertDispatched(TranscribeRecording::class);