Improve live recordings UI, upload flow, and default Flux theme.
Refresh list status over Reverb with polling fallback, clarify queued vs processing, streamline upload empty states, and seed an admin login.
This commit is contained in:
+9
-12
@@ -74,10 +74,16 @@ REVERB_SCHEME=http
|
|||||||
REVERB_SERVER_HOST=0.0.0.0
|
REVERB_SERVER_HOST=0.0.0.0
|
||||||
REVERB_SERVER_PORT=8080
|
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_APP_KEY="${REVERB_APP_KEY}"
|
||||||
VITE_REVERB_HOST="${REVERB_HOST}"
|
VITE_REVERB_HOST=localhost
|
||||||
VITE_REVERB_PORT="${REVERB_PORT}"
|
# Browser WS port: use REVERB_HOST_PORT with Docker (8081), or REVERB_PORT for bare-metal reverb:start
|
||||||
VITE_REVERB_SCHEME="${REVERB_SCHEME}"
|
VITE_REVERB_PORT="${REVERB_HOST_PORT}"
|
||||||
|
VITE_REVERB_SCHEME=http
|
||||||
|
|
||||||
# AndyTranscribe / local faster-whisper
|
# AndyTranscribe / local faster-whisper
|
||||||
LOCAL_WHISPER_URL=http://127.0.0.1:8090/v1
|
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
|
# Must be greater than TRANSCRIPTION_TIMEOUT so long Whisper jobs are not re-queued mid-run
|
||||||
DB_QUEUE_RETRY_AFTER=660
|
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)
|
# Demo user created on every container start (db:seed via entrypoint)
|
||||||
SEED_USER_NAME="Demo User"
|
SEED_USER_NAME="Demo User"
|
||||||
SEED_USER_EMAIL=demo@example.com
|
SEED_USER_EMAIL=demo@example.com
|
||||||
|
|||||||
@@ -87,14 +87,14 @@ Whisper may take a minute or two while the model downloads.
|
|||||||
|
|
||||||
### Demo login
|
### 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` |
|
| `demo@example.com` | `password` |
|
||||||
| Password | `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
|
### 5. Open the app
|
||||||
|
|
||||||
@@ -197,7 +197,7 @@ Use the GPU Whisper service instead of the CPU `whisper` service when you have a
|
|||||||
|
|
||||||
## Usage
|
## 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.
|
2. Open **Recordings → Upload** and drop one or many audio files.
|
||||||
3. Transcription starts automatically (the `queue` service must be running).
|
3. Transcription starts automatically (the `queue` service must be running).
|
||||||
4. Watch live progress on the list or detail page; stop or restart anytime.
|
4. Watch live progress on the list or detail page; stop or restart anytime.
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ class RecordingTranscriptionUpdated implements ShouldBroadcastNow
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
new PrivateChannel('recording.'.$this->recording->id),
|
new PrivateChannel('recording.'.$this->recording->id),
|
||||||
|
new PrivateChannel('user.'.$this->recording->user_id.'.recordings'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use Illuminate\Contracts\View\View;
|
|||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\Gate;
|
use Illuminate\Support\Facades\Gate;
|
||||||
use Livewire\Attributes\Layout;
|
use Livewire\Attributes\Layout;
|
||||||
|
use Livewire\Attributes\On;
|
||||||
use Livewire\Attributes\Title;
|
use Livewire\Attributes\Title;
|
||||||
use Livewire\Attributes\Url;
|
use Livewire\Attributes\Url;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
@@ -19,14 +20,30 @@ class Index extends Component
|
|||||||
{
|
{
|
||||||
use WithPagination;
|
use WithPagination;
|
||||||
|
|
||||||
|
public int $userId;
|
||||||
|
|
||||||
#[Url(as: 'q', history: true)]
|
#[Url(as: 'q', history: true)]
|
||||||
public string $search = '';
|
public string $search = '';
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$this->userId = (int) Auth::id();
|
||||||
|
}
|
||||||
|
|
||||||
public function updatedSearch(): void
|
public function updatedSearch(): void
|
||||||
{
|
{
|
||||||
$this->resetPage();
|
$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
|
public function queuePending(): void
|
||||||
{
|
{
|
||||||
$queued = 0;
|
$queued = 0;
|
||||||
@@ -69,6 +86,36 @@ class Index extends Component
|
|||||||
Flux::toast(text: 'Recording deleted.', variant: 'success');
|
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
|
public function render(): View
|
||||||
{
|
{
|
||||||
$user = Auth::user();
|
$user = Auth::user();
|
||||||
@@ -78,6 +125,8 @@ class Index extends Component
|
|||||||
->orderBy('id')
|
->orderBy('id')
|
||||||
->each(fn (Recording $recording) => $recording->recoverOrphanedTranscription());
|
->each(fn (Recording $recording) => $recording->recoverOrphanedTranscription());
|
||||||
|
|
||||||
|
$totalCount = $user->recordings()->count();
|
||||||
|
|
||||||
$query = $user->recordings()->latest();
|
$query = $user->recordings()->latest();
|
||||||
|
|
||||||
$search = trim($this->search);
|
$search = trim($this->search);
|
||||||
@@ -94,10 +143,16 @@ class Index extends Component
|
|||||||
->filter(fn (Recording $recording) => ! $recording->hasActiveTranscriptionJob())
|
->filter(fn (Recording $recording) => ! $recording->hasActiveTranscriptionJob())
|
||||||
->count();
|
->count();
|
||||||
|
|
||||||
|
$hasActiveTranscriptions = $user->recordings()
|
||||||
|
->whereIn('transcription_status', ['pending', 'processing'])
|
||||||
|
->exists();
|
||||||
|
|
||||||
return view('livewire.recordings.index', [
|
return view('livewire.recordings.index', [
|
||||||
'recordings' => $recordings,
|
'recordings' => $recordings,
|
||||||
'search' => $search,
|
'search' => $search,
|
||||||
'pendingCount' => $pendingCount,
|
'pendingCount' => $pendingCount,
|
||||||
|
'hasActiveTranscriptions' => $hasActiveTranscriptions,
|
||||||
|
'totalCount' => $totalCount,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,10 +19,10 @@ class UploadRecordings extends Component
|
|||||||
*/
|
*/
|
||||||
public array $audio = [];
|
public array $audio = [];
|
||||||
|
|
||||||
public string $title = '';
|
|
||||||
|
|
||||||
public bool $saving = false;
|
public bool $saving = false;
|
||||||
|
|
||||||
|
public bool $showCancel = true;
|
||||||
|
|
||||||
public function updatedAudio(): void
|
public function updatedAudio(): void
|
||||||
{
|
{
|
||||||
if ($this->saving || $this->audio === []) {
|
if ($this->saving || $this->audio === []) {
|
||||||
@@ -40,49 +40,46 @@ class UploadRecordings extends Component
|
|||||||
|
|
||||||
$this->saving = true;
|
$this->saving = true;
|
||||||
|
|
||||||
$store ??= app(StoreUploadedRecordings::class);
|
try {
|
||||||
|
$store ??= app(StoreUploadedRecordings::class);
|
||||||
|
|
||||||
$this->validate([
|
$this->validate([
|
||||||
'audio' => ['required', 'array', 'min:1', 'max:50'],
|
'audio' => ['required', 'array', 'min:1', 'max:50'],
|
||||||
'audio.*' => [
|
'audio.*' => [
|
||||||
'required',
|
'required',
|
||||||
File::types(StoreRecordingRequest::AUDIO_EXTENSIONS)->max('2gb'),
|
File::types(StoreRecordingRequest::AUDIO_EXTENSIONS)->max('2gb'),
|
||||||
],
|
],
|
||||||
'title' => ['nullable', 'string', 'max:255'],
|
], [
|
||||||
], [
|
'audio.required' => 'Please choose at least one audio file to upload.',
|
||||||
'audio.required' => 'Please choose at least one audio file to upload.',
|
'audio.min' => '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.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.*' => '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.',
|
||||||
'audio.*.max' => 'Each audio file may not be larger than 2 GB.',
|
]);
|
||||||
]);
|
|
||||||
|
|
||||||
$result = $store->handle(
|
$result = $store->handle(Auth::user(), $this->audio);
|
||||||
Auth::user(),
|
|
||||||
$this->audio,
|
|
||||||
filled($this->title) ? $this->title : null,
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->audio = [];
|
$this->audio = [];
|
||||||
$this->title = '';
|
|
||||||
$this->saving = false;
|
|
||||||
|
|
||||||
if ($result['recordings'] === []) {
|
if ($result['recordings'] === []) {
|
||||||
session()->flash('error', $result['message']);
|
session()->flash('error', $result['message']);
|
||||||
|
|
||||||
return $this->redirect(route('recordings.create'), navigate: true);
|
return $this->redirect(route('recordings.create'), navigate: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
session()->flash('success', $result['message']);
|
||||||
|
|
||||||
|
if (count($result['recordings']) === 1) {
|
||||||
|
return $this->redirect(
|
||||||
|
route('recordings.show', $result['recordings'][0]),
|
||||||
|
navigate: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->redirect(route('recordings.index'), navigate: true);
|
||||||
|
} finally {
|
||||||
|
$this->saving = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
session()->flash('success', $result['message']);
|
|
||||||
|
|
||||||
if (count($result['recordings']) === 1) {
|
|
||||||
return $this->redirect(
|
|
||||||
route('recordings.show', $result['recordings'][0]),
|
|
||||||
navigate: true,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->redirect(route('recordings.index'), navigate: true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function render()
|
public function render()
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ class Recording extends Model
|
|||||||
'ollama_url' => null,
|
'ollama_url' => null,
|
||||||
'transcription_status' => 'pending',
|
'transcription_status' => 'pending',
|
||||||
'transcription_progress' => 'Queued — waiting to start…',
|
'transcription_progress' => 'Queued — waiting to start…',
|
||||||
'transcription_percent' => 5,
|
'transcription_percent' => null,
|
||||||
'transcription_started_at' => now(),
|
'transcription_started_at' => now(),
|
||||||
'transcription_error' => null,
|
'transcription_error' => null,
|
||||||
// Keep the previous transcript until a new run succeeds.
|
// Keep the previous transcript until a new run succeeds.
|
||||||
|
|||||||
@@ -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()
|
Recording::query()
|
||||||
->whereNull('user_id')
|
->whereNull('user_id')
|
||||||
->update(['user_id' => $user->id]);
|
->update(['user_id' => $user->id]);
|
||||||
|
|||||||
@@ -8,11 +8,8 @@
|
|||||||
@source '../js';
|
@source '../js';
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
--font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
|
--font-sans: Inter, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
|
||||||
'Segoe UI Symbol', 'Noto Color Emoji';
|
'Noto Color Emoji';
|
||||||
--color-accent: var(--color-teal-700);
|
|
||||||
--color-accent-content: var(--color-teal-700);
|
|
||||||
--color-accent-foreground: var(--color-white);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@custom-variant dark (&:where(.dark, .dark *));
|
@custom-variant dark (&:where(.dark, .dark *));
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
const BADGE_COLORS = {
|
const BADGE_COLORS = {
|
||||||
done: 'teal',
|
done: 'teal',
|
||||||
processing: 'amber',
|
processing: 'amber',
|
||||||
pending: 'amber',
|
pending: 'zinc',
|
||||||
failed: 'red',
|
failed: 'red',
|
||||||
cancelled: 'zinc',
|
cancelled: 'zinc',
|
||||||
};
|
};
|
||||||
@@ -55,12 +55,6 @@ export function formatTimestamp(value) {
|
|||||||
+ ':' + pad(date.getMinutes());
|
+ ':' + pad(date.getMinutes());
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatWordCount(count) {
|
|
||||||
const n = Number(count) || 0;
|
|
||||||
|
|
||||||
return n > 0 ? n.toLocaleString() : '—';
|
|
||||||
}
|
|
||||||
|
|
||||||
function subscribeToRecording(recordingId, handler) {
|
function subscribeToRecording(recordingId, handler) {
|
||||||
if (!window.Echo) {
|
if (!window.Echo) {
|
||||||
return () => {};
|
return () => {};
|
||||||
@@ -76,14 +70,6 @@ function subscribeToRecording(recordingId, handler) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function subscribeToRecordings(recordingIds, handler) {
|
|
||||||
const leaveFns = recordingIds.map((id) => subscribeToRecording(id, handler));
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
leaveFns.forEach((leave) => leave());
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Show-page Alpine component: Echo push + local elapsed tick + optional status hydrate.
|
* Show-page Alpine component: Echo push + local elapsed tick + optional status hydrate.
|
||||||
*/
|
*/
|
||||||
@@ -208,34 +194,19 @@ export function transcriptionMonitor({ statusUrl, initial }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Index-page Alpine component: patch row status from Reverb events.
|
* Index-page Alpine component: inline audio player only.
|
||||||
|
* Status/progress refresh via Livewire Echo + wire:poll.
|
||||||
*/
|
*/
|
||||||
export function recordingsIndex({ recordings, pendingCount }) {
|
export function recordingsIndex() {
|
||||||
const byId = {};
|
|
||||||
|
|
||||||
for (const row of recordings) {
|
|
||||||
byId[row.id] = row;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rows: byId,
|
|
||||||
pendingCount: Number(pendingCount) || 0,
|
|
||||||
leaveChannel: null,
|
|
||||||
playingId: null,
|
playingId: null,
|
||||||
isPlaying: false,
|
isPlaying: false,
|
||||||
|
|
||||||
start() {
|
start() {
|
||||||
this.leaveChannel = subscribeToRecordings(Object.keys(this.rows), (event) => {
|
//
|
||||||
this.applyPayload(event);
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
|
|
||||||
destroy() {
|
destroy() {
|
||||||
if (this.leaveChannel) {
|
|
||||||
this.leaveChannel();
|
|
||||||
this.leaveChannel = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const player = this.$refs.player;
|
const player = this.$refs.player;
|
||||||
|
|
||||||
if (player) {
|
if (player) {
|
||||||
@@ -282,41 +253,5 @@ export function recordingsIndex({ recordings, pendingCount }) {
|
|||||||
this.isPlaying = false;
|
this.isPlaying = false;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
applyPayload(payload) {
|
|
||||||
const id = payload.id;
|
|
||||||
|
|
||||||
if (!this.rows[id]) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const previousStatus = this.rows[id].status;
|
|
||||||
const next = {
|
|
||||||
...this.rows[id],
|
|
||||||
status: payload.status,
|
|
||||||
status_label: payload.status_label,
|
|
||||||
progress: payload.progress,
|
|
||||||
percent: payload.percent,
|
|
||||||
is_active: payload.is_active,
|
|
||||||
word_count: payload.word_count ?? this.rows[id].word_count,
|
|
||||||
word_count_display: formatWordCount(payload.word_count ?? this.rows[id].word_count),
|
|
||||||
badge_color: badgeColorFor(payload.status),
|
|
||||||
};
|
|
||||||
|
|
||||||
this.rows[id] = next;
|
|
||||||
|
|
||||||
const wasQueueable = ['pending', 'failed', 'cancelled'].includes(previousStatus);
|
|
||||||
const isQueueable = ['pending', 'failed', 'cancelled'].includes(payload.status);
|
|
||||||
|
|
||||||
if (wasQueueable && !isQueueable) {
|
|
||||||
this.pendingCount = Math.max(0, this.pendingCount - 1);
|
|
||||||
} else if (!wasQueueable && isQueueable) {
|
|
||||||
this.pendingCount += 1;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
row(id) {
|
|
||||||
return this.rows[id] || {};
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,11 @@
|
|||||||
/** @var array{used_percent: float, free_percent: float, free_human: string, total_human: string, used_human: string} $disk */
|
/** @var array{used_percent: float, free_percent: float, free_human: string, total_human: string, used_human: string} $disk */
|
||||||
@endphp
|
@endphp
|
||||||
<div
|
<div
|
||||||
class="flex items-center gap-2 rounded-lg border border-stone-200 bg-stone-50 px-2.5 py-1.5 dark:border-zinc-600 dark:bg-zinc-900/50"
|
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"
|
title="{{ $disk['free_human'] }} free of {{ $disk['total_human'] }} · {{ $disk['used_percent'] }}% used"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="h-1.5 w-14 shrink-0 overflow-hidden rounded-full bg-stone-200 dark:bg-zinc-700"
|
class="h-1.5 w-14 shrink-0 overflow-hidden rounded-full bg-zinc-200 dark:bg-zinc-700"
|
||||||
role="progressbar"
|
role="progressbar"
|
||||||
aria-valuemin="0"
|
aria-valuemin="0"
|
||||||
aria-valuemax="100"
|
aria-valuemax="100"
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
style="width: {{ min(100, max(0, $disk['used_percent'])) }}%"
|
style="width: {{ min(100, max(0, $disk['used_percent'])) }}%"
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
<span class="hidden text-xs font-medium tabular-nums text-stone-600 sm:inline dark:text-zinc-300">
|
<span class="hidden text-xs font-medium tabular-nums text-zinc-600 sm:inline dark:text-zinc-300">
|
||||||
{{ $disk['free_human'] }} free
|
{{ $disk['free_human'] }} free
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
@props([
|
@props([
|
||||||
'status',
|
'status',
|
||||||
'label' => null,
|
'label' => null,
|
||||||
/** @var string|null Alpine expression that returns a row object with badge_color + status_label */
|
/** @var string|null Alpine expression that returns a row object with badge_color + status_label (+ status for spinner) */
|
||||||
'alpineRow' => null,
|
'alpineRow' => null,
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -17,14 +17,17 @@
|
|||||||
|
|
||||||
$color = match ($status) {
|
$color = match ($status) {
|
||||||
'done' => 'teal',
|
'done' => 'teal',
|
||||||
'processing', 'pending' => 'amber',
|
'processing' => 'amber',
|
||||||
|
'pending' => 'zinc',
|
||||||
'failed' => 'red',
|
'failed' => 'red',
|
||||||
default => 'zinc',
|
default => 'zinc',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
$icon = $status === 'processing' ? 'loading' : null;
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
@if ($alpineRow)
|
@if ($alpineRow)
|
||||||
<span {{ $attributes->class('inline-flex') }}>
|
<span {{ $attributes->class('inline-flex items-center gap-1.5') }}>
|
||||||
@foreach (['teal', 'amber', 'red', 'zinc'] as $badgeColor)
|
@foreach (['teal', 'amber', 'red', 'zinc'] as $badgeColor)
|
||||||
@if ($badgeColor === $color)
|
@if ($badgeColor === $color)
|
||||||
<flux:badge
|
<flux:badge
|
||||||
@@ -43,9 +46,17 @@
|
|||||||
>{{ $label }}</flux:badge>
|
>{{ $label }}</flux:badge>
|
||||||
@endif
|
@endif
|
||||||
@endforeach
|
@endforeach
|
||||||
|
<flux:icon.loading
|
||||||
|
variant="micro"
|
||||||
|
class="size-3 text-amber-600 dark:text-amber-400"
|
||||||
|
x-show="{{ $alpineRow }}.status === 'processing'"
|
||||||
|
x-cloak
|
||||||
|
/>
|
||||||
</span>
|
</span>
|
||||||
@else
|
@else
|
||||||
<flux:badge size="sm" :color="$color" {{ $attributes }}>
|
<span {{ $attributes->class('inline-flex items-center gap-1.5') }}>
|
||||||
{{ $label }}
|
<flux:badge size="sm" :color="$color" :icon="$icon">
|
||||||
</flux:badge>
|
{{ $label }}
|
||||||
|
</flux:badge>
|
||||||
|
</span>
|
||||||
@endif
|
@endif
|
||||||
|
|||||||
@@ -1,52 +1,46 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="dark">
|
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
||||||
<head>
|
<head>
|
||||||
@include('partials.head', ['title' => $title ?? null])
|
@include('partials.head', ['title' => $title ?? null])
|
||||||
<style>[x-cloak]{display:none!important}</style>
|
<style>[x-cloak]{display:none!important}</style>
|
||||||
</head>
|
</head>
|
||||||
<body class="min-h-screen bg-stone-100 text-stone-900 antialiased dark:bg-zinc-900 dark:text-zinc-100">
|
<body class="min-h-screen bg-white text-zinc-800 antialiased dark:bg-zinc-900 dark:text-zinc-100">
|
||||||
<flux:header class="border-b border-stone-200 bg-white dark:border-zinc-700 dark:bg-zinc-800">
|
<flux:header container class="border-b border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900">
|
||||||
<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:sidebar.toggle class="lg:hidden" icon="bars-2" inset="left" />
|
|
||||||
|
|
||||||
<a href="{{ route('recordings.index') }}" wire:navigate class="text-lg font-semibold tracking-tight text-teal-800 dark:text-teal-300">
|
<flux:brand href="{{ route('recordings.index') }}" name="AndyTranscribe" wire:navigate class="max-lg:hidden" />
|
||||||
AndyTranscribe
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<flux:navbar class="-mb-px max-lg:hidden">
|
<flux:navbar class="-mb-px max-lg:hidden">
|
||||||
<flux:navbar.item
|
<flux:navbar.item
|
||||||
:href="route('recordings.index')"
|
:href="route('recordings.index')"
|
||||||
:current="request()->routeIs('recordings.index', 'recordings.show')"
|
:current="request()->routeIs('recordings.index', 'recordings.show')"
|
||||||
wire:navigate
|
wire:navigate
|
||||||
>
|
>
|
||||||
{{ __('Recordings') }}
|
{{ __('Recordings') }}
|
||||||
</flux:navbar.item>
|
</flux:navbar.item>
|
||||||
<flux:navbar.item
|
<flux:navbar.item
|
||||||
:href="route('recordings.create')"
|
:href="route('recordings.create')"
|
||||||
:current="request()->routeIs('recordings.create')"
|
:current="request()->routeIs('recordings.create')"
|
||||||
wire:navigate
|
wire:navigate
|
||||||
>
|
>
|
||||||
{{ __('Upload') }}
|
{{ __('Upload') }}
|
||||||
</flux:navbar.item>
|
</flux:navbar.item>
|
||||||
</flux:navbar>
|
</flux:navbar>
|
||||||
|
|
||||||
<flux:spacer />
|
<flux:spacer />
|
||||||
|
|
||||||
<x-disk-space-bar />
|
<x-disk-space-bar />
|
||||||
|
|
||||||
<x-appearance-toggle />
|
<x-appearance-toggle />
|
||||||
|
|
||||||
@auth
|
@auth
|
||||||
<x-desktop-user-menu />
|
<x-desktop-user-menu />
|
||||||
@endauth
|
@endauth
|
||||||
</div>
|
|
||||||
</flux:header>
|
</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>
|
<flux:sidebar.header>
|
||||||
<a href="{{ route('recordings.index') }}" wire:navigate class="text-base font-semibold text-teal-800 dark:text-teal-300">
|
<flux:sidebar.brand href="{{ route('recordings.index') }}" name="AndyTranscribe" wire:navigate />
|
||||||
AndyTranscribe
|
|
||||||
</a>
|
|
||||||
<flux:sidebar.collapse />
|
<flux:sidebar.collapse />
|
||||||
</flux:sidebar.header>
|
</flux:sidebar.header>
|
||||||
|
|
||||||
@@ -60,7 +54,7 @@
|
|||||||
</flux:sidebar.nav>
|
</flux:sidebar.nav>
|
||||||
</flux:sidebar>
|
</flux:sidebar>
|
||||||
|
|
||||||
<main class="mx-auto max-w-5xl px-4 py-8 sm:px-6">
|
<flux:main container>
|
||||||
@if (session('success'))
|
@if (session('success'))
|
||||||
<flux:callout variant="success" icon="check-circle" class="mb-6">
|
<flux:callout variant="success" icon="check-circle" class="mb-6">
|
||||||
<flux:callout.text>{{ session('success') }}</flux:callout.text>
|
<flux:callout.text>{{ session('success') }}</flux:callout.text>
|
||||||
@@ -86,7 +80,7 @@
|
|||||||
@endif
|
@endif
|
||||||
|
|
||||||
{{ $slot }}
|
{{ $slot }}
|
||||||
</main>
|
</flux:main>
|
||||||
|
|
||||||
<flux:toast />
|
<flux:toast />
|
||||||
|
|
||||||
|
|||||||
@@ -1,27 +1,23 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="dark">
|
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
||||||
<head>
|
<head>
|
||||||
@include('partials.head')
|
@include('partials.head')
|
||||||
<style>[x-cloak]{display:none!important}</style>
|
<style>[x-cloak]{display:none!important}</style>
|
||||||
</head>
|
</head>
|
||||||
<body class="min-h-screen bg-stone-100 text-stone-900 antialiased dark:bg-zinc-900 dark:text-zinc-100">
|
<body class="min-h-screen bg-white text-zinc-800 antialiased dark:bg-zinc-900 dark:text-zinc-100">
|
||||||
<flux:header class="border-b border-stone-200 bg-white dark:border-zinc-700 dark:bg-zinc-800">
|
<flux:header container class="border-b border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900">
|
||||||
<div class="mx-auto flex w-full max-w-5xl items-center gap-4 px-4 sm:px-6">
|
<flux:brand href="{{ route('recordings.index') }}" name="AndyTranscribe" wire:navigate />
|
||||||
<a href="{{ route('recordings.index') }}" class="text-lg font-semibold tracking-tight text-teal-800 dark:text-teal-300">
|
<flux:spacer />
|
||||||
AndyTranscribe
|
<x-disk-space-bar />
|
||||||
</a>
|
<x-appearance-toggle />
|
||||||
<flux:spacer />
|
@auth
|
||||||
<x-disk-space-bar />
|
<x-desktop-user-menu />
|
||||||
<x-appearance-toggle />
|
@endauth
|
||||||
@auth
|
|
||||||
<x-desktop-user-menu />
|
|
||||||
@endauth
|
|
||||||
</div>
|
|
||||||
</flux:header>
|
</flux:header>
|
||||||
|
|
||||||
<main class="mx-auto max-w-5xl px-4 py-8 sm:px-6">
|
<flux:main container>
|
||||||
{{ $slot }}
|
{{ $slot }}
|
||||||
</main>
|
</flux:main>
|
||||||
|
|
||||||
@fluxScripts
|
@fluxScripts
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -1,21 +1,20 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="dark">
|
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
||||||
<head>
|
<head>
|
||||||
@include('partials.head')
|
@include('partials.head')
|
||||||
</head>
|
</head>
|
||||||
<body class="min-h-screen bg-neutral-100 antialiased dark:bg-linear-to-b dark:from-neutral-950 dark:to-neutral-900">
|
<body class="min-h-screen bg-zinc-50 antialiased dark:bg-zinc-900">
|
||||||
<div class="bg-muted flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
|
<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">
|
<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>
|
<flux:brand
|
||||||
<span class="flex h-9 w-9 items-center justify-center rounded-md">
|
class="justify-center"
|
||||||
<x-app-logo-icon class="size-9 fill-current text-black dark:text-white" />
|
name="{{ config('app.name', 'AndyTranscribe') }}"
|
||||||
</span>
|
href="{{ route('home') }}"
|
||||||
|
wire:navigate
|
||||||
<span class="sr-only">{{ config('app.name', 'Laravel') }}</span>
|
/>
|
||||||
</a>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-6">
|
<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 class="px-10 py-8">{{ $slot }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="dark">
|
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
||||||
<head>
|
<head>
|
||||||
@include('partials.head')
|
@include('partials.head')
|
||||||
<style>[x-cloak]{display:none!important}</style>
|
<style>[x-cloak]{display:none!important}</style>
|
||||||
</head>
|
</head>
|
||||||
<body class="min-h-screen bg-stone-100 text-stone-900 antialiased dark:bg-zinc-900 dark:text-zinc-100">
|
<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 bg-stone-100 p-6 md:p-10 dark:bg-zinc-900">
|
<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">
|
<div class="absolute end-4 top-4">
|
||||||
<x-appearance-toggle />
|
<x-appearance-toggle />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex w-full max-w-sm flex-col gap-2">
|
<div class="flex w-full max-w-sm flex-col gap-6">
|
||||||
<a href="{{ url('/') }}" class="mb-1 flex flex-col items-center gap-2 font-medium">
|
<flux:brand
|
||||||
<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">
|
class="justify-center"
|
||||||
AT
|
name="{{ config('app.name', 'AndyTranscribe') }}"
|
||||||
</span>
|
href="{{ url('/') }}"
|
||||||
<span class="text-lg font-semibold tracking-tight text-teal-800 dark:text-teal-300">{{ config('app.name', 'AndyTranscribe') }}</span>
|
wire:navigate
|
||||||
</a>
|
/>
|
||||||
<div class="flex flex-col gap-6">
|
<div class="flex flex-col gap-6">
|
||||||
{{ $slot }}
|
{{ $slot }}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,17 +1,14 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="dark">
|
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
||||||
<head>
|
<head>
|
||||||
@include('partials.head')
|
@include('partials.head')
|
||||||
</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="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="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-neutral-900"></div>
|
<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>
|
<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">
|
{{ config('app.name', 'AndyTranscribe') }}
|
||||||
<x-app-logo-icon class="me-2 h-7 fill-current text-white" />
|
|
||||||
</span>
|
|
||||||
{{ config('app.name', 'Laravel') }}
|
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
@php
|
@php
|
||||||
@@ -27,13 +24,12 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="w-full lg:p-8">
|
<div class="w-full lg:p-8">
|
||||||
<div class="mx-auto flex w-full flex-col justify-center space-y-6 sm:w-[350px]">
|
<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>
|
<flux:brand
|
||||||
<span class="flex h-9 w-9 items-center justify-center rounded-md">
|
class="z-20 justify-center lg:hidden"
|
||||||
<x-app-logo-icon class="size-9 fill-current text-black dark:text-white" />
|
name="{{ config('app.name', 'AndyTranscribe') }}"
|
||||||
</span>
|
href="{{ route('home') }}"
|
||||||
|
wire:navigate
|
||||||
<span class="sr-only">{{ config('app.name', 'Laravel') }}</span>
|
/>
|
||||||
</a>
|
|
||||||
{{ $slot }}
|
{{ $slot }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
<div>
|
<div>
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<flux:heading size="xl">Upload recordings</flux:heading>
|
<flux:heading size="xl">Upload recordings</flux:heading>
|
||||||
<flux:text class="mt-1">
|
|
||||||
Drop one or many pocket-recorder files. Embedded metadata is extracted when available.
|
|
||||||
Duplicate files (same name and size, or identical content) are skipped.
|
|
||||||
</flux:text>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<livewire:upload-recordings />
|
<livewire:upload-recordings />
|
||||||
|
|||||||
@@ -1,25 +1,8 @@
|
|||||||
<div
|
<div
|
||||||
x-data="recordingsIndex(@js([
|
@if ($hasActiveTranscriptions)
|
||||||
'recordings' => $recordings->map(fn ($recording) => [
|
wire:poll.2s.visible
|
||||||
'id' => $recording->id,
|
@endif
|
||||||
'status' => $recording->transcription_status,
|
x-data="recordingsIndex()"
|
||||||
'status_label' => $recording->transcriptionStatusLabel(),
|
|
||||||
'progress' => $recording->transcription_progress,
|
|
||||||
'percent' => $recording->transcription_percent,
|
|
||||||
'is_active' => $recording->isTranscribing(),
|
|
||||||
'word_count' => $recording->word_count,
|
|
||||||
'word_count_display' => $recording->word_count > 0
|
|
||||||
? number_format($recording->word_count)
|
|
||||||
: '—',
|
|
||||||
'badge_color' => match ($recording->transcription_status) {
|
|
||||||
'done' => 'teal',
|
|
||||||
'processing', 'pending' => 'amber',
|
|
||||||
'failed' => 'red',
|
|
||||||
default => 'zinc',
|
|
||||||
},
|
|
||||||
])->values(),
|
|
||||||
'pendingCount' => $pendingCount,
|
|
||||||
]))"
|
|
||||||
x-init="
|
x-init="
|
||||||
start();
|
start();
|
||||||
return () => destroy();
|
return () => destroy();
|
||||||
@@ -39,34 +22,64 @@
|
|||||||
class="min-w-[16rem]"
|
class="min-w-[16rem]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div x-show="pendingCount > 0" x-cloak>
|
<div class="flex flex-wrap items-center justify-end gap-2">
|
||||||
<flux:button
|
@if ($pendingCount > 0)
|
||||||
type="button"
|
<flux:button
|
||||||
variant="ghost"
|
type="button"
|
||||||
size="sm"
|
variant="ghost"
|
||||||
wire:click="queuePending"
|
size="sm"
|
||||||
>
|
wire:click="queuePending"
|
||||||
Queue <span x-text="pendingCount"></span> pending
|
>
|
||||||
<span x-text="pendingCount === 1 ? 'transcription' : 'transcriptions'"></span>
|
Queue {{ $pendingCount }} pending
|
||||||
</flux:button>
|
{{ $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>
|
</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 ($recordings->isEmpty())
|
||||||
<flux:card class="border-dashed py-16 text-center">
|
@if (filled($search))
|
||||||
@if (filled($search))
|
<flux:card class="border-dashed py-16 text-center">
|
||||||
<flux:text>No recordings match “{{ $search }}”.</flux:text>
|
<flux:text>No recordings match “{{ $search }}”.</flux:text>
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<flux:button variant="ghost" wire:click="$set('search', '')">Clear search</flux:button>
|
<flux:button variant="ghost" wire:click="$set('search', '')">Clear search</flux:button>
|
||||||
</div>
|
</div>
|
||||||
@else
|
</flux:card>
|
||||||
<flux:text>No recordings yet.</flux:text>
|
@else
|
||||||
<div class="mt-4">
|
<livewire:upload-recordings :show-cancel="false" />
|
||||||
<flux:link href="{{ route('recordings.create') }}" wire:navigate>Upload your first MP3</flux:link>
|
@endif
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
</flux:card>
|
|
||||||
@else
|
@else
|
||||||
<audio
|
<audio
|
||||||
x-ref="player"
|
x-ref="player"
|
||||||
@@ -90,7 +103,7 @@
|
|||||||
|
|
||||||
<flux:table.rows>
|
<flux:table.rows>
|
||||||
@foreach ($recordings as $recording)
|
@foreach ($recordings as $recording)
|
||||||
<flux:table.row wire:key="recording-{{ $recording->id }}">
|
<flux:table.row wire:key="recording-{{ $recording->id }}-{{ $recording->transcription_status }}-{{ $recording->transcription_percent }}">
|
||||||
<flux:table.cell>
|
<flux:table.cell>
|
||||||
<flux:button
|
<flux:button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -127,10 +140,7 @@
|
|||||||
</flux:table.cell>
|
</flux:table.cell>
|
||||||
<flux:table.cell>{{ $recording->duration_formatted }}</flux:table.cell>
|
<flux:table.cell>{{ $recording->duration_formatted }}</flux:table.cell>
|
||||||
<flux:table.cell>
|
<flux:table.cell>
|
||||||
<span
|
<span class="tabular-nums">
|
||||||
class="tabular-nums"
|
|
||||||
x-text="row({{ $recording->id }}).word_count_display"
|
|
||||||
>
|
|
||||||
{{ $recording->word_count > 0 ? number_format($recording->word_count) : '—' }}
|
{{ $recording->word_count > 0 ? number_format($recording->word_count) : '—' }}
|
||||||
</span>
|
</span>
|
||||||
</flux:table.cell>
|
</flux:table.cell>
|
||||||
@@ -138,17 +148,25 @@
|
|||||||
<x-transcription-status-badge
|
<x-transcription-status-badge
|
||||||
:status="$recording->transcription_status"
|
:status="$recording->transcription_status"
|
||||||
:label="$recording->transcriptionStatusLabel()"
|
:label="$recording->transcriptionStatusLabel()"
|
||||||
:alpine-row="'row('.$recording->id.')'"
|
|
||||||
/>
|
/>
|
||||||
<div
|
@if ($recording->transcription_status === 'pending' && filled($recording->transcription_progress))
|
||||||
class="mt-1 max-w-[14rem] truncate text-xs text-amber-700 dark:text-amber-300"
|
<div
|
||||||
x-show="row({{ $recording->id }}).is_active && row({{ $recording->id }}).progress"
|
class="mt-1 max-w-[14rem] truncate text-xs text-zinc-500 dark:text-zinc-400"
|
||||||
x-cloak
|
title="{{ $recording->transcription_progress }}"
|
||||||
:title="row({{ $recording->id }}).progress"
|
>
|
||||||
>
|
{{ $recording->transcription_progress }}
|
||||||
<span x-text="row({{ $recording->id }}).percent ? (row({{ $recording->id }}).percent + '% · ') : ''"></span>
|
</div>
|
||||||
<span x-text="row({{ $recording->id }}).progress"></span>
|
@elseif ($recording->transcription_status === 'processing' && filled($recording->transcription_progress))
|
||||||
</div>
|
<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>
|
||||||
<flux:table.cell>
|
<flux:table.cell>
|
||||||
{{ $recording->created_at?->format('Y-m-d H:i') }}
|
{{ $recording->created_at?->format('Y-m-d H:i') }}
|
||||||
|
|||||||
@@ -145,16 +145,31 @@
|
|||||||
<div x-show="status.is_active" x-cloak class="mt-4">
|
<div x-show="status.is_active" x-cloak class="mt-4">
|
||||||
<flux:callout variant="warning" icon="arrow-path">
|
<flux:callout variant="warning" icon="arrow-path">
|
||||||
<flux:callout.heading>
|
<flux:callout.heading>
|
||||||
<span x-text="status.progress || 'Working…'"></span>
|
<span class="inline-flex items-center gap-2">
|
||||||
|
<flux:icon.loading
|
||||||
|
variant="micro"
|
||||||
|
class="size-4"
|
||||||
|
x-show="status.status === 'processing'"
|
||||||
|
x-cloak
|
||||||
|
/>
|
||||||
|
<span x-text="status.progress || 'Working…'"></span>
|
||||||
|
</span>
|
||||||
</flux:callout.heading>
|
</flux:callout.heading>
|
||||||
<flux:callout.text>
|
<flux:callout.text>
|
||||||
<span class="tabular-nums" x-text="(status.percent ?? 0) + '%'"></span>
|
<span
|
||||||
· Elapsed <span x-text="status.elapsed_human || '0s'"></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.text>
|
||||||
</flux:callout>
|
</flux:callout>
|
||||||
|
|
||||||
<div class="mt-3">
|
<div class="mt-3" x-show="status.status === 'processing'" x-cloak>
|
||||||
<flux:progress color="amber" x-bind:value="Math.max(status.percent || 5, 5)" />
|
<flux:progress color="amber" x-bind:value="status.percent || 0" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul class="mt-3 space-y-1 text-xs text-zinc-600 dark:text-zinc-400">
|
<ul class="mt-3 space-y-1 text-xs text-zinc-600 dark:text-zinc-400">
|
||||||
|
|||||||
@@ -1,28 +1,59 @@
|
|||||||
<div
|
<div
|
||||||
class="max-w-2xl space-y-6 rounded border border-stone-200 bg-white p-6 shadow-sm dark:border-zinc-700 dark:bg-zinc-800"
|
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 }"
|
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-start="uploading = true; progress = 0"
|
||||||
x-on:livewire-upload-finish="uploading = false; progress = 100"
|
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-error="uploading = false"
|
||||||
x-on:livewire-upload-progress="progress = $event.detail.progress"
|
x-on:livewire-upload-progress="progress = $event.detail.progress"
|
||||||
>
|
>
|
||||||
<flux:input
|
|
||||||
wire:model="title"
|
|
||||||
label="Title (optional)"
|
|
||||||
description="Used when you upload a single file. Leave blank to use embedded title or filename."
|
|
||||||
placeholder="Leave blank to use embedded title or filename"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<flux:label>Audio files</flux:label>
|
<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
|
<div
|
||||||
wire:drop.file="$upload('audio', { multiple: true, accept: '.mp3,.wav,.ogg,.oga,.flac,.m4a,.mp4,.aac,.webm,.wma,.aiff,.aif,audio/*' })"
|
|
||||||
wire:click="$upload('audio', { multiple: true, accept: '.mp3,.wav,.ogg,.oga,.flac,.m4a,.mp4,.aac,.webm,.wma,.aiff,.aif,audio/*' })"
|
|
||||||
role="button"
|
role="button"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
wire:keydown.enter="$upload('audio', { multiple: true, accept: '.mp3,.wav,.ogg,.oga,.flac,.m4a,.mp4,.aac,.webm,.wma,.aiff,.aif,audio/*' })"
|
x-on:click="openPicker()"
|
||||||
class="mt-2 flex cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed border-zinc-300 bg-zinc-50 px-6 py-10 text-center transition-colors data-dragging:border-accent data-dragging:bg-accent/5 dark:border-white/20 dark:bg-white/5 dark:data-dragging:border-accent dark:data-dragging:bg-accent/10"
|
x-on: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">
|
<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" />
|
<flux:icon.cloud-arrow-up class="size-6 text-zinc-500 dark:text-zinc-400" />
|
||||||
@@ -57,11 +88,9 @@
|
|||||||
@enderror
|
@enderror
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<flux:text class="text-sm text-zinc-500 dark:text-zinc-400">
|
@if ($showCancel)
|
||||||
Uploads start as soon as you drop or choose files. Duplicate files (same name and size, or identical content) are skipped.
|
<div class="flex items-center gap-3">
|
||||||
</flux:text>
|
<flux:link href="{{ route('recordings.index') }}" wire:navigate>Cancel</flux:link>
|
||||||
|
</div>
|
||||||
<div class="flex items-center gap-3">
|
@endif
|
||||||
<flux:link href="{{ route('recordings.index') }}">Cancel</flux:link>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,6 +7,8 @@
|
|||||||
</title>
|
</title>
|
||||||
|
|
||||||
<link rel="icon" href="/favicon.ico" sizes="any">
|
<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'])
|
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||||
@fluxAppearance
|
@fluxAppearance
|
||||||
|
|||||||
@@ -10,3 +10,7 @@ Broadcast::channel('recording.{recordingId}', function (User $user, int $recordi
|
|||||||
->where('user_id', $user->id)
|
->where('user_id', $user->id)
|
||||||
->exists();
|
->exists();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Broadcast::channel('user.{userId}.recordings', function (User $user, int $userId): bool {
|
||||||
|
return (int) $user->id === $userId;
|
||||||
|
});
|
||||||
|
|||||||
@@ -24,12 +24,24 @@ class DemoUserSeederTest extends TestCase
|
|||||||
$this->assertTrue(Hash::check('password', $user->password));
|
$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
|
public function test_seeder_is_idempotent(): void
|
||||||
{
|
{
|
||||||
$this->seed(DatabaseSeeder::class);
|
$this->seed(DatabaseSeeder::class);
|
||||||
$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', '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
|
public function test_seeder_assigns_orphaned_recordings_to_demo_user(): void
|
||||||
|
|||||||
@@ -70,22 +70,14 @@ class RecordingDuplicateUploadTest extends TestCase
|
|||||||
Bus::assertDispatched(TranscribeRecording::class, 2);
|
Bus::assertDispatched(TranscribeRecording::class, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_upload_page_mentions_duplicate_skipping(): void
|
public function test_upload_page_shows_dropzone(): void
|
||||||
{
|
{
|
||||||
Recording::query()->create([
|
|
||||||
'user_id' => $this->user->id,
|
|
||||||
'title' => 'Existing',
|
|
||||||
'original_filename' => 'note.mp3',
|
|
||||||
'file_path' => 'recordings/note.mp3',
|
|
||||||
'file_size_bytes' => 2048,
|
|
||||||
'content_hash' => str_repeat('a', 64),
|
|
||||||
'transcription_status' => 'done',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->get(route('recordings.create'))
|
$this->get(route('recordings.create'))
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSeeLivewire('upload-recordings')
|
->assertSeeLivewire('upload-recordings')
|
||||||
->assertSee('Duplicate files', false);
|
->assertSee('Drop audio files here or click to browse')
|
||||||
|
->assertDontSee('Title (optional)')
|
||||||
|
->assertDontSee('Uploads start as soon as you drop');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_duplicate_filename_and_size_is_skipped_without_content_hash(): void
|
public function test_duplicate_filename_and_size_is_skipped_without_content_hash(): void
|
||||||
|
|||||||
@@ -59,16 +59,16 @@ class RecordingUploadTest extends TestCase
|
|||||||
$file = UploadedFile::fake()->create('meeting.mp3', 500, 'audio/mpeg');
|
$file = UploadedFile::fake()->create('meeting.mp3', 500, 'audio/mpeg');
|
||||||
|
|
||||||
Livewire::test(UploadRecordings::class)
|
Livewire::test(UploadRecordings::class)
|
||||||
->set('title', 'Team meeting')
|
|
||||||
->set('audio', [$file])
|
->set('audio', [$file])
|
||||||
->assertRedirect(route('recordings.show', Recording::query()->first()));
|
->assertRedirect(route('recordings.show', Recording::query()->first()));
|
||||||
|
|
||||||
$recording = Recording::query()->first();
|
$recording = Recording::query()->first();
|
||||||
|
|
||||||
$this->assertNotNull($recording);
|
$this->assertNotNull($recording);
|
||||||
$this->assertSame('Team meeting', $recording->title);
|
$this->assertSame('meeting', $recording->title);
|
||||||
$this->assertSame('pending', $recording->transcription_status);
|
$this->assertSame('pending', $recording->transcription_status);
|
||||||
$this->assertSame('local', $recording->transcription_driver);
|
$this->assertSame('local', $recording->transcription_driver);
|
||||||
|
$this->assertNull($recording->transcription_percent);
|
||||||
$this->assertNotNull($recording->transcription_started_at);
|
$this->assertNotNull($recording->transcription_started_at);
|
||||||
Storage::disk('local')->assertExists($recording->file_path);
|
Storage::disk('local')->assertExists($recording->file_path);
|
||||||
Bus::assertDispatched(TranscribeRecording::class);
|
Bus::assertDispatched(TranscribeRecording::class);
|
||||||
@@ -132,11 +132,11 @@ class RecordingUploadTest extends TestCase
|
|||||||
['talk.m4a', 'audio/mp4', 'm4a-bytes'],
|
['talk.m4a', 'audio/mp4', 'm4a-bytes'],
|
||||||
] as [$name, $mime, $contents]) {
|
] as [$name, $mime, $contents]) {
|
||||||
Livewire::test(UploadRecordings::class)
|
Livewire::test(UploadRecordings::class)
|
||||||
->set('title', $name)
|
|
||||||
->set('audio', [UploadedFile::fake()->createWithContent($name, $contents)])
|
->set('audio', [UploadedFile::fake()->createWithContent($name, $contents)])
|
||||||
->assertRedirect();
|
->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}");
|
$this->assertNotNull($recording, "Failed uploading {$name}");
|
||||||
Storage::disk('local')->assertExists($recording->file_path);
|
Storage::disk('local')->assertExists($recording->file_path);
|
||||||
|
|||||||
@@ -38,6 +38,19 @@ class IndexTest extends TestCase
|
|||||||
->assertSee('Pocket note');
|
->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
|
public function test_search_filters_recordings(): void
|
||||||
{
|
{
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
@@ -90,6 +103,77 @@ class IndexTest extends TestCase
|
|||||||
Bus::assertDispatched(TranscribeRecording::class, 1);
|
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
|
public function test_user_can_delete_recording_from_index(): void
|
||||||
{
|
{
|
||||||
Storage::fake('local');
|
Storage::fake('local');
|
||||||
@@ -116,6 +200,57 @@ class IndexTest extends TestCase
|
|||||||
Storage::disk('local')->assertMissing('recordings/delete-me.mp3');
|
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
|
public function test_user_cannot_delete_another_users_recording_from_index(): void
|
||||||
{
|
{
|
||||||
Storage::fake('local');
|
Storage::fake('local');
|
||||||
|
|||||||
@@ -158,5 +158,6 @@ class TranscriptionBroadcastTest extends TestCase
|
|||||||
|
|
||||||
$this->assertSame('RecordingTranscriptionUpdated', $event->broadcastAs());
|
$this->assertSame('RecordingTranscriptionUpdated', $event->broadcastAs());
|
||||||
$this->assertSame('private-recording.'.$recording->id, $event->broadcastOn()[0]->name);
|
$this->assertSame('private-recording.'.$recording->id, $event->broadcastOn()[0]->name);
|
||||||
|
$this->assertSame('private-user.'.$this->user->id.'.recordings', $event->broadcastOn()[1]->name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,14 +35,13 @@ class UploadRecordingsLivewireTest extends TestCase
|
|||||||
$file = UploadedFile::fake()->create('meeting.mp3', 500, 'audio/mpeg');
|
$file = UploadedFile::fake()->create('meeting.mp3', 500, 'audio/mpeg');
|
||||||
|
|
||||||
Livewire::test(UploadRecordings::class)
|
Livewire::test(UploadRecordings::class)
|
||||||
->set('title', 'Team meeting')
|
|
||||||
->set('audio', [$file])
|
->set('audio', [$file])
|
||||||
->assertRedirect(route('recordings.show', Recording::query()->first()));
|
->assertRedirect(route('recordings.show', Recording::query()->first()));
|
||||||
|
|
||||||
$recording = Recording::query()->first();
|
$recording = Recording::query()->first();
|
||||||
|
|
||||||
$this->assertNotNull($recording);
|
$this->assertNotNull($recording);
|
||||||
$this->assertSame('Team meeting', $recording->title);
|
$this->assertSame('meeting', $recording->title);
|
||||||
$this->assertSame('pending', $recording->transcription_status);
|
$this->assertSame('pending', $recording->transcription_status);
|
||||||
Storage::disk('local')->assertExists($recording->file_path);
|
Storage::disk('local')->assertExists($recording->file_path);
|
||||||
Bus::assertDispatched(TranscribeRecording::class);
|
Bus::assertDispatched(TranscribeRecording::class);
|
||||||
|
|||||||
Reference in New Issue
Block a user