Compare commits
3
Commits
bfcfc12f58
...
c17f8fb506
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c17f8fb506 | ||
|
|
148ba91816 | ||
|
|
34ccf0c32b |
+9
-12
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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...
|
||||
],
|
||||
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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 *));
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import './echo';
|
||||
import { recordingsIndex, transcriptionMonitor } from './transcription';
|
||||
import { uploadDropzone } from './upload';
|
||||
|
||||
window.transcriptionMonitor = transcriptionMonitor;
|
||||
window.recordingsIndex = recordingsIndex;
|
||||
window.uploadDropzone = uploadDropzone;
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user