Compare commits

4 Commits
Author SHA1 Message Date
ben 8a66cf6f63 Improve recordings list sorting and live show-page status updates.
Add sortable columns with a fixed status width, and keep the show page in sync via Echo, polling, and Alpine hydrate while transcription runs.
2026-08-12 21:27:03 +02:00
ben 5f0f61995c Fix Docker builds when livewire-tmp is root-owned.
Exclude whole storage trees from the build context, create cache dirs before dump-autoload, and drop Buildx-only cache mounts.
2026-08-12 20:47:58 +02:00
ben b7c36b8b3b Compact recordings list rows to title plus one transcript preview line. 2026-08-12 20:36:22 +02:00
ben 6bc5a20606 Speed up Docker builds with a single image and parallel deps.
Build app once for queue/reverb, run Composer and npm in parallel with cache mounts, and skip redundant vite npm ci on restart.
2026-08-12 20:20:06 +02:00
13 changed files with 444 additions and 115 deletions
+8 -6
View File
@@ -10,12 +10,13 @@ node_modules
vendor vendor
public/build public/build
public/hot public/hot
storage/app/private/** # Exclude whole trees (not only /**) so Docker never tries to stat root-owned tmp dirs
storage/app/public/** storage/app/private
storage/logs/** storage/app/public
storage/framework/cache/** storage/logs
storage/framework/sessions/** storage/framework/cache
storage/framework/views/** storage/framework/sessions
storage/framework/views
database/*.sqlite* database/*.sqlite*
.env .env
.env.* .env.*
@@ -28,5 +29,6 @@ npm-debug.log
yarn-error.log yarn-error.log
tests tests
docs docs
todo.txt
*.md *.md
!README.md !README.md
+35 -10
View File
@@ -1,10 +1,14 @@
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
# ---------------------------------------------------------------------------
# Composer deps (runs in parallel with npm ci)
# ---------------------------------------------------------------------------
FROM composer:2 AS vendor FROM composer:2 AS vendor
WORKDIR /app WORKDIR /app
COPY composer.json composer.lock ./ COPY composer.json composer.lock ./
RUN composer install \ RUN composer install \
--no-dev \ --no-dev \
--no-scripts \ --no-scripts \
@@ -12,15 +16,29 @@ RUN composer install \
--prefer-dist \ --prefer-dist \
--no-interaction --no-interaction
FROM node:22-bookworm AS assets # ---------------------------------------------------------------------------
# npm ci only (parallel with vendor when BuildKit is available)
# ---------------------------------------------------------------------------
FROM node:22-bookworm AS npm
WORKDIR /app WORKDIR /app
COPY package.json package-lock.json ./ COPY package.json package-lock.json ./
RUN npm ci RUN npm ci
COPY --from=vendor /app/vendor ./vendor # ---------------------------------------------------------------------------
COPY composer.json composer.lock ./ # Vite production build (needs Flux/Livewire + Laravel pagination views)
# ---------------------------------------------------------------------------
FROM node:22-bookworm AS assets
WORKDIR /app
COPY --from=npm /app/node_modules ./node_modules
COPY package.json package-lock.json ./
COPY --from=vendor /app/vendor/livewire ./vendor/livewire
COPY --from=vendor /app/vendor/laravel/framework/src/Illuminate/Pagination \
./vendor/laravel/framework/src/Illuminate/Pagination
COPY vite.config.js ./ COPY vite.config.js ./
COPY resources ./resources COPY resources ./resources
COPY public ./public COPY public ./public
@@ -39,8 +57,12 @@ ENV VITE_APP_NAME=$VITE_APP_NAME \
RUN npm run build RUN npm run build
# ---------------------------------------------------------------------------
# Runtime image
# ---------------------------------------------------------------------------
FROM dunglas/frankenphp:php8.5-bookworm FROM dunglas/frankenphp:php8.5-bookworm
# Rarely changes — keep early for cache hits
RUN install-php-extensions \ RUN install-php-extensions \
pcntl \ pcntl \
pdo_sqlite \ pdo_sqlite \
@@ -50,20 +72,25 @@ RUN install-php-extensions \
intl \ intl \
opcache opcache
COPY docker/php.ini /usr/local/etc/php/conf.d/99-uploads.ini
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
COPY docker/php.ini /usr/local/etc/php/conf.d/99-uploads.ini
COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
WORKDIR /app WORKDIR /app
# Dependency layer (invalidates when lockfiles / vendor change)
COPY --from=vendor /app/vendor ./vendor COPY --from=vendor /app/vendor ./vendor
COPY composer.json composer.lock ./ COPY composer.json composer.lock ./
# Application source (.dockerignore excludes vendor, node_modules, public/build, storage uploads)
COPY . . COPY . .
# Built frontend assets
COPY --from=assets /app/public/build ./public/build COPY --from=assets /app/public/build ./public/build
RUN composer dump-autoload --optimize --no-dev \ # Framework/view cache paths must exist before package:discover runs during dump-autoload
&& mkdir -p \ RUN mkdir -p \
storage/app/private \ storage/app/private \
storage/app/public \ storage/app/public \
storage/framework/cache \ storage/framework/cache \
@@ -72,11 +99,9 @@ RUN composer dump-autoload --optimize --no-dev \
storage/logs \ storage/logs \
database \ database \
bootstrap/cache \ bootstrap/cache \
&& composer dump-autoload --optimize --no-dev \
&& chown -R www-data:www-data storage bootstrap/cache database && chown -R www-data:www-data storage bootstrap/cache database
COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
EXPOSE 80 EXPOSE 80
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
+10
View File
@@ -76,6 +76,8 @@ sed -i "s|^APP_KEY=.*|APP_KEY=${KEY}|" .env
docker compose up --build -d docker compose up --build -d
``` ```
Compose builds the **app** image once; `queue` and `reverb` reuse `andytranscribe-app:latest` (no triple rebuild). With the [dev overlay](#local-development-hot-reload), skip `--build` for routine PHP/Blade/JS work — the repo is bind-mounted.
On first start the app container will: On first start the app container will:
- create `database/database.sqlite` if needed - create `database/database.sqlite` if needed
@@ -123,6 +125,14 @@ docker compose up -d
Data under `./database`, `./storage/app`, and `./storage/logs` is kept on the host. Data under `./database`, `./storage/app`, and `./storage/logs` is kept on the host.
If `docker compose build` fails with `can't stat .../storage/app/private/livewire-tmp`, a container created that directory as root. Fix ownership (or remove it), then rebuild:
```bash
sudo chown -R "$USER:$USER" storage
# or: sudo rm -rf storage/app/private/livewire-tmp
docker compose up --build -d
```
### Live reload while developing ### Live reload while developing
Default Compose uses the built image, so PHP/Blade/CSS/JS changes need a rebuild. For day-to-day work, use the dev overlay (bind-mounts the repo and runs Vite HMR): Default Compose uses the built image, so PHP/Blade/CSS/JS changes need a rebuild. For day-to-day work, use the dev overlay (bind-mounts the repo and runs Vite HMR):
+70 -1
View File
@@ -5,6 +5,8 @@ namespace App\Livewire\Recordings;
use App\Models\Recording; use App\Models\Recording;
use Flux\Flux; use Flux\Flux;
use Illuminate\Contracts\View\View; use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\HasMany;
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;
@@ -25,9 +27,26 @@ class Index extends Component
#[Url(as: 'q', history: true)] #[Url(as: 'q', history: true)]
public string $search = ''; public string $search = '';
#[Url(as: 'sort', history: true)]
public string $sortBy = 'uploaded';
#[Url(as: 'dir', history: true)]
public string $sortDirection = 'desc';
/**
* @var array<string, string>
*/
private const SORTABLE = [
'title' => 'title',
'duration' => 'duration_seconds',
'status' => 'transcription_status',
'uploaded' => 'created_at',
];
public function mount(): void public function mount(): void
{ {
$this->userId = (int) Auth::id(); $this->userId = (int) Auth::id();
$this->normalizeSort();
} }
public function updatedSearch(): void public function updatedSearch(): void
@@ -35,6 +54,22 @@ class Index extends Component
$this->resetPage(); $this->resetPage();
} }
public function sort(string $column): void
{
if ($column !== 'words' && ! array_key_exists($column, self::SORTABLE)) {
return;
}
if ($this->sortBy === $column) {
$this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc';
} else {
$this->sortBy = $column;
$this->sortDirection = $column === 'uploaded' ? 'desc' : 'asc';
}
$this->resetPage();
}
/** /**
* Re-render when any of this user's recordings broadcast a status change. * Re-render when any of this user's recordings broadcast a status change.
*/ */
@@ -127,7 +162,9 @@ class Index extends Component
$totalCount = $user->recordings()->count(); $totalCount = $user->recordings()->count();
$query = $user->recordings()->latest(); $this->normalizeSort();
$query = $user->recordings();
$search = trim($this->search); $search = trim($this->search);
@@ -135,6 +172,8 @@ class Index extends Component
$query->search($search); $query->search($search);
} }
$this->applySort($query);
$recordings = $query->paginate(20); $recordings = $query->paginate(20);
$pendingCount = $user->recordings() $pendingCount = $user->recordings()
@@ -155,4 +194,34 @@ class Index extends Component
'totalCount' => $totalCount, 'totalCount' => $totalCount,
]); ]);
} }
private function normalizeSort(): void
{
if ($this->sortBy !== 'words' && ! array_key_exists($this->sortBy, self::SORTABLE)) {
$this->sortBy = 'uploaded';
}
if (! in_array($this->sortDirection, ['asc', 'desc'], true)) {
$this->sortDirection = 'desc';
}
}
/**
* @param Builder<Recording>|HasMany<Recording, User> $query
*/
private function applySort(Builder|HasMany $query): void
{
$direction = $this->sortDirection === 'asc' ? 'asc' : 'desc';
if ($this->sortBy === 'words') {
$query->orderByRaw(
'CASE WHEN transcript IS NULL OR TRIM(transcript) = ? THEN 0 ELSE LENGTH(TRIM(transcript)) - LENGTH(REPLACE(TRIM(transcript), ?, ?)) + 1 END '.$direction,
['', ' ', ''],
);
} else {
$query->orderBy(self::SORTABLE[$this->sortBy], $direction);
}
$query->orderByDesc('id');
}
} }
+23
View File
@@ -7,6 +7,7 @@ use Flux\Flux;
use Illuminate\Contracts\View\View; use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Gate;
use Livewire\Attributes\Layout; use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component; use Livewire\Component;
#[Layout('layouts.app')] #[Layout('layouts.app')]
@@ -14,6 +15,8 @@ class Show extends Component
{ {
public Recording $recording; public Recording $recording;
public int $userId;
public function mount(Recording $recording): void public function mount(Recording $recording): void
{ {
Gate::authorize('view', $recording); Gate::authorize('view', $recording);
@@ -22,6 +25,22 @@ class Show extends Component
$recording->refresh(); $recording->refresh();
$this->recording = $recording; $this->recording = $recording;
$this->userId = (int) $recording->user_id;
}
/**
* Re-render when this recording broadcasts a status change (same channel as the index).
*
* @param array<string, mixed> $event
*/
#[On('echo-private:user.{userId}.recordings,.RecordingTranscriptionUpdated')]
public function onTranscriptionUpdated(array $event = []): void
{
if (isset($event['id']) && (int) $event['id'] !== (int) $this->recording->id) {
return;
}
$this->recording->refresh();
} }
public function startTranscription(): void public function startTranscription(): void
@@ -64,6 +83,10 @@ class Show extends Component
public function render(): View public function render(): View
{ {
if ($this->recording->isTranscribing()) {
$this->recording->refresh();
}
return view('livewire.recordings.show') return view('livewire.recordings.show')
->title($this->recording->title); ->title($this->recording->title);
} }
+22
View File
@@ -179,6 +179,28 @@ class Recording extends Model
}); });
} }
/**
* First line of the transcript, truncated for compact list rows.
*/
public function transcriptFirstLine(int $limit = 120): ?string
{
if (! filled($this->transcript)) {
return null;
}
$firstLine = Str::of($this->transcript)
->before("\n")
->replaceMatches('/\s+/', ' ')
->trim()
->toString();
if ($firstLine === '') {
return null;
}
return Str::limit($firstLine, $limit);
}
/** /**
* Short transcript excerpt, optionally centered on a search hit. * Short transcript excerpt, optionally centered on a search hit.
*/ */
+3 -1
View File
@@ -39,7 +39,9 @@ services:
image: node:22-bookworm image: node:22-bookworm
container_name: andytranscribe-vite container_name: andytranscribe-vite
working_dir: /app working_dir: /app
command: sh -c "npm ci && npm run dev -- --host 0.0.0.0 --port 5173" command: >
sh -c "if [ ! -x node_modules/.bin/vite ]; then npm ci; fi;
npm run dev -- --host 0.0.0.0 --port 5173"
ports: ports:
- "${VITE_HOST_PORT:-5173}:5173" - "${VITE_HOST_PORT:-5173}:5173"
environment: environment:
+4 -18
View File
@@ -70,17 +70,11 @@ services:
condition: service_started condition: service_started
restart: unless-stopped restart: unless-stopped
# Shares andytranscribe-app:latest — do not declare build: here (avoids rebuilding 3×).
# `docker compose up --build` builds `app` first, then starts these with the tagged image.
queue: queue:
build:
context: .
dockerfile: Dockerfile
args:
VITE_APP_NAME: ${VITE_APP_NAME:-AndyTranscribe}
VITE_REVERB_APP_KEY: ${REVERB_APP_KEY:-andytranscribe-key}
VITE_REVERB_HOST: ${VITE_REVERB_HOST:-localhost}
VITE_REVERB_PORT: ${REVERB_HOST_PORT:-8081}
VITE_REVERB_SCHEME: ${VITE_REVERB_SCHEME:-http}
image: andytranscribe-app:latest image: andytranscribe-app:latest
pull_policy: never
container_name: andytranscribe-queue container_name: andytranscribe-queue
command: command:
- php - php
@@ -107,16 +101,8 @@ services:
restart: unless-stopped restart: unless-stopped
reverb: reverb:
build:
context: .
dockerfile: Dockerfile
args:
VITE_APP_NAME: ${VITE_APP_NAME:-AndyTranscribe}
VITE_REVERB_APP_KEY: ${REVERB_APP_KEY:-andytranscribe-key}
VITE_REVERB_HOST: ${VITE_REVERB_HOST:-localhost}
VITE_REVERB_PORT: ${REVERB_HOST_PORT:-8081}
VITE_REVERB_SCHEME: ${VITE_REVERB_SCHEME:-http}
image: andytranscribe-app:latest image: andytranscribe-app:latest
pull_policy: never
container_name: andytranscribe-reverb container_name: andytranscribe-reverb
command: command:
- php - php
+39 -9
View File
@@ -66,12 +66,16 @@ function subscribeToRecording(recordingId, handler) {
channel.listen('.RecordingTranscriptionUpdated', handler); channel.listen('.RecordingTranscriptionUpdated', handler);
return () => { return () => {
window.Echo.leave(channelName); // Prefer stopListening over leave() so a remount does not drop other subscribers.
if (typeof channel.stopListening === 'function') {
channel.stopListening('.RecordingTranscriptionUpdated');
}
}; };
} }
/** /**
* Show-page Alpine component: Echo push + local elapsed tick + optional status hydrate. * Show-page Alpine component: Echo push + local elapsed tick + optional status hydrate.
* Livewire also listens on the user recordings channel and polls while active.
*/ */
export function transcriptionMonitor({ statusUrl, initial }) { export function transcriptionMonitor({ statusUrl, initial }) {
return { return {
@@ -82,6 +86,7 @@ export function transcriptionMonitor({ statusUrl, initial }) {
}, },
pollError: null, pollError: null,
tickTimer: null, tickTimer: null,
hydrateTimer: null,
leaveChannel: null, leaveChannel: null,
get badgeColor() { get badgeColor() {
@@ -108,11 +113,13 @@ export function transcriptionMonitor({ statusUrl, initial }) {
if (this.status.is_active) { if (this.status.is_active) {
this.beginTick(); this.beginTick();
this.hydrateOnce(); this.hydrateOnce();
this.beginHydratePoll();
} }
}, },
destroy() { destroy() {
this.stopTick(); this.stopTick();
this.stopHydratePoll();
if (this.leaveChannel) { if (this.leaveChannel) {
this.leaveChannel(); this.leaveChannel();
@@ -131,12 +138,14 @@ export function transcriptionMonitor({ statusUrl, initial }) {
if (this.status.is_active) { if (this.status.is_active) {
this.beginTick(); this.beginTick();
this.beginHydratePoll();
} else { } else {
this.stopTick(); this.stopTick();
this.stopHydratePoll();
} }
if (wasActive && !this.status.is_active if (wasActive && ! this.status.is_active
&& !this.status.has_transcript && ! this.status.has_transcript
&& this.status.status !== 'failed' && this.status.status !== 'failed'
&& this.status.status !== 'cancelled') { && this.status.status !== 'cancelled') {
window.location.reload(); window.location.reload();
@@ -158,26 +167,47 @@ export function transcriptionMonitor({ statusUrl, initial }) {
} }
}, },
tickElapsed() { beginHydratePoll() {
if (!this.status.is_active || this.status.elapsed_seconds == null) { if (this.hydrateTimer || ! this.statusUrl) {
return; return;
} }
this.status.elapsed_seconds += 1; this.hydrateTimer = setInterval(() => this.hydrateOnce(), 2000);
this.status.elapsed_human = formatElapsed(this.status.elapsed_seconds); },
stopHydratePoll() {
if (this.hydrateTimer) {
clearInterval(this.hydrateTimer);
this.hydrateTimer = null;
}
},
tickElapsed() {
if (! this.status.is_active || this.status.elapsed_seconds == null) {
return;
}
const next = this.status.elapsed_seconds + 1;
this.status = {
...this.status,
elapsed_seconds: next,
elapsed_human: formatElapsed(next),
};
}, },
async hydrateOnce() { async hydrateOnce() {
if (!this.statusUrl) { if (! this.statusUrl) {
return; return;
} }
try { try {
const response = await fetch(this.statusUrl, { const response = await fetch(this.statusUrl, {
headers: { Accept: 'application/json' }, headers: { Accept: 'application/json' },
credentials: 'same-origin',
}); });
if (!response.ok) { if (! response.ok) {
throw new Error('Status request failed (' + response.status + ')'); throw new Error('Status request failed (' + response.status + ')');
} }
@@ -92,51 +92,88 @@
<flux:table :paginate="$recordings"> <flux:table :paginate="$recordings">
<flux:table.columns> <flux:table.columns>
<flux:table.column class="w-12"></flux:table.column> <flux:table.column
<flux:table.column>Title</flux:table.column> sortable
<flux:table.column>Duration</flux:table.column> :sorted="$sortBy === 'title'"
<flux:table.column>Words</flux:table.column> :direction="$sortDirection"
<flux:table.column>Status</flux:table.column> wire:click="sort('title')"
<flux:table.column>Uploaded</flux:table.column> >
Title
</flux:table.column>
<flux:table.column
sortable
:sorted="$sortBy === 'duration'"
:direction="$sortDirection"
wire:click="sort('duration')"
>
Duration
</flux:table.column>
<flux:table.column
sortable
:sorted="$sortBy === 'words'"
:direction="$sortDirection"
wire:click="sort('words')"
>
Words
</flux:table.column>
<flux:table.column
class="w-36"
sortable
:sorted="$sortBy === 'status'"
:direction="$sortDirection"
wire:click="sort('status')"
>
Status
</flux:table.column>
<flux:table.column
sortable
:sorted="$sortBy === 'uploaded'"
:direction="$sortDirection"
wire:click="sort('uploaded')"
>
Uploaded
</flux:table.column>
<flux:table.column class="w-24"></flux:table.column> <flux:table.column class="w-24"></flux:table.column>
</flux:table.columns> </flux:table.columns>
<flux:table.rows> <flux:table.rows>
@foreach ($recordings as $recording) @foreach ($recordings as $recording)
<flux:table.row wire:key="recording-{{ $recording->id }}-{{ $recording->transcription_status }}-{{ $recording->transcription_percent }}"> <flux:table.row wire:key="recording-{{ $recording->id }}-{{ $recording->transcription_status }}-{{ $recording->transcription_percent }}">
<flux:table.cell> <flux:table.cell class="max-w-xl">
<flux:button <div class="flex min-w-0 items-center gap-2">
type="button" <flux:link
variant="ghost" href="{{ route('recordings.show', $recording) }}"
size="sm" wire:navigate
square class="shrink-0 font-medium"
data-audio-url="{{ route('recordings.audio', $recording) }}" >
x-bind:aria-label="isPlayingRow({{ $recording->id }}) ? 'Pause' : 'Play'" {{ $recording->title }}
x-on:click="togglePlay({{ $recording->id }}, $el.dataset.audioUrl)" </flux:link>
> <flux:button
<flux:icon.play type="button"
variant="micro" variant="ghost"
x-show="! isPlayingRow({{ $recording->id }})" size="sm"
/> square
<flux:icon.pause class="shrink-0"
variant="micro" data-audio-url="{{ route('recordings.audio', $recording) }}"
x-show="isPlayingRow({{ $recording->id }})" x-bind:aria-label="isPlayingRow({{ $recording->id }}) ? 'Pause' : 'Play'"
x-cloak x-on:click="togglePlay({{ $recording->id }}, $el.dataset.audioUrl)"
/> >
</flux:button> <flux:icon.play
</flux:table.cell> variant="micro"
<flux:table.cell class="whitespace-normal"> x-show="! isPlayingRow({{ $recording->id }})"
<flux:link href="{{ route('recordings.show', $recording) }}" wire:navigate class="font-medium"> />
{{ $recording->title }} <flux:icon.pause
</flux:link> variant="micro"
@if ($recording->artist) x-show="isPlayingRow({{ $recording->id }})"
<flux:text class="mt-0.5 text-xs">{{ $recording->artist }}</flux:text> x-cloak
@endif />
@if ($snippet = $recording->transcriptSnippet($search ?: null)) </flux:button>
<flux:text class="mt-1 max-w-xl text-xs leading-relaxed"> @if ($preview = $recording->transcriptFirstLine())
{{ $snippet }} <span class="min-w-0 truncate text-sm text-zinc-500 dark:text-zinc-400" title="{{ $preview }}">
</flux:text> {{ $preview }}
@endif </span>
@endif
</div>
</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>
@@ -144,29 +181,11 @@
{{ $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>
<flux:table.cell class="whitespace-normal py-2"> <flux:table.cell class="w-36 whitespace-nowrap">
<x-transcription-status-badge <x-transcription-status-badge
:status="$recording->transcription_status" :status="$recording->transcription_status"
:label="$recording->transcriptionStatusLabel()" :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>
<flux:table.cell> <flux:table.cell>
{{ $recording->created_at?->format('Y-m-d H:i') }} {{ $recording->created_at?->format('Y-m-d H:i') }}
@@ -1,13 +1,19 @@
<div <div
x-data="transcriptionMonitor(@js([ @if ($recording->isTranscribing())
'statusUrl' => route('recordings.transcription-status', $recording), wire:poll.2s.visible
'initial' => $recording->transcriptionStatusPayload(), @endif
]))"
x-init="
start();
return () => destroy();
"
> >
<div
wire:key="transcription-ui-{{ $recording->id }}-{{ $recording->transcription_status }}-{{ $recording->transcription_percent }}-{{ md5((string) $recording->transcription_progress) }}-{{ $recording->transcribed_at?->timestamp }}"
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 class="mb-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div> <div>
<flux:link href="{{ route('recordings.index') }}" wire:navigate class="text-sm"> Recordings</flux:link> <flux:link href="{{ route('recordings.index') }}" wire:navigate class="text-sm"> Recordings</flux:link>
@@ -222,4 +228,5 @@
No transcript yet. Transcription starts automatically after upload, or use the button above. No transcript yet. Transcription starts automatically after upload, or use the button above.
</flux:text> </flux:text>
</flux:card> </flux:card>
</div>
</div> </div>
+61 -4
View File
@@ -63,7 +63,7 @@ class IndexTest extends TestCase
'file_path' => 'recordings/office.mp3', 'file_path' => 'recordings/office.mp3',
'file_size_bytes' => 100, 'file_size_bytes' => 100,
'transcription_status' => 'done', 'transcription_status' => 'done',
'transcript' => 'talking about the pocket recorder today', 'transcript' => "talking about the pocket recorder today\nsecond line stays hidden",
]); ]);
Recording::query()->create([ Recording::query()->create([
@@ -79,6 +79,8 @@ class IndexTest extends TestCase
Livewire::test(Index::class) Livewire::test(Index::class)
->set('search', 'pocket recorder') ->set('search', 'pocket recorder')
->assertSee('Office chat') ->assertSee('Office chat')
->assertSee('talking about the pocket recorder today')
->assertDontSee('second line stays hidden')
->assertDontSee('Unrelated'); ->assertDontSee('Unrelated');
} }
@@ -123,8 +125,8 @@ class IndexTest extends TestCase
Livewire::test(Index::class) Livewire::test(Index::class)
->assertSee('wire:poll', false) ->assertSee('wire:poll', false)
->assertSee('Transcribing locally…') ->assertSee('Transcribing')
->assertSee('40%') ->assertDontSee('Transcribing locally…')
->assertSeeHtml('bg-amber-400'); ->assertSeeHtml('bg-amber-400');
} }
@@ -149,7 +151,7 @@ class IndexTest extends TestCase
Livewire::test(Index::class) Livewire::test(Index::class)
->assertSee('Waiting in line') ->assertSee('Waiting in line')
->assertSee('Queued') ->assertSee('Queued')
->assertSee('Queued — waiting to start…') ->assertDontSee('Queued — waiting to start…')
->assertDontSee('5%') ->assertDontSee('5%')
->assertSeeHtml('bg-zinc-400/15') ->assertSeeHtml('bg-zinc-400/15')
->assertDontSeeHtml('bg-amber-400'); ->assertDontSeeHtml('bg-amber-400');
@@ -280,4 +282,59 @@ class IndexTest extends TestCase
$this->assertDatabaseHas('recordings', ['id' => $recording->id]); $this->assertDatabaseHas('recordings', ['id' => $recording->id]);
} }
public function test_recordings_can_be_sorted_by_title(): void
{
$user = User::factory()->create();
$this->actingAs($user);
Recording::query()->create([
'user_id' => $user->id,
'title' => 'Zebra',
'original_filename' => 'z.mp3',
'file_path' => 'recordings/z.mp3',
'file_size_bytes' => 100,
'transcription_status' => 'done',
'created_at' => now()->subDay(),
]);
Recording::query()->create([
'user_id' => $user->id,
'title' => 'Alpha',
'original_filename' => 'a.mp3',
'file_path' => 'recordings/a.mp3',
'file_size_bytes' => 100,
'transcription_status' => 'done',
'created_at' => now(),
]);
Livewire::test(Index::class)
->call('sort', 'title')
->assertSet('sortBy', 'title')
->assertSet('sortDirection', 'asc')
->assertSeeInOrder(['Alpha', 'Zebra'])
->call('sort', 'title')
->assertSet('sortDirection', 'desc')
->assertSeeInOrder(['Zebra', 'Alpha']);
}
public function test_invalid_sort_column_is_ignored(): void
{
$user = User::factory()->create();
$this->actingAs($user);
Recording::query()->create([
'user_id' => $user->id,
'title' => 'Only one',
'original_filename' => 'one.mp3',
'file_path' => 'recordings/one.mp3',
'file_size_bytes' => 100,
'transcription_status' => 'done',
]);
Livewire::test(Index::class)
->call('sort', 'not_a_column')
->assertSet('sortBy', 'uploaded')
->assertSet('sortDirection', 'desc');
}
} }
+77
View File
@@ -59,4 +59,81 @@ class ShowTest extends TestCase
$this->assertDatabaseMissing('recordings', ['id' => $recording->id]); $this->assertDatabaseMissing('recordings', ['id' => $recording->id]);
} }
public function test_show_polls_while_transcription_is_active(): void
{
$user = User::factory()->create();
$this->actingAs($user);
$recording = 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' => 'Queued — waiting to start…',
'transcription_percent' => null,
'transcription_driver' => 'local',
'transcription_started_at' => now(),
]);
Livewire::test(Show::class, ['recording' => $recording])
->assertSee('wire:poll', false)
->assertSee('Queued — waiting to start…');
}
public function test_show_does_not_poll_when_transcription_is_idle(): void
{
$user = User::factory()->create();
$this->actingAs($user);
$recording = 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(Show::class, ['recording' => $recording])
->assertDontSee('wire:poll', false);
}
public function test_show_refreshes_recording_on_transcription_broadcast(): void
{
$user = User::factory()->create();
$this->actingAs($user);
$recording = Recording::query()->create([
'user_id' => $user->id,
'title' => 'Live update',
'original_filename' => 'live.mp3',
'file_path' => 'recordings/live.mp3',
'file_size_bytes' => 100,
'transcription_status' => 'pending',
'transcription_progress' => 'Queued — waiting to start…',
'transcription_driver' => 'local',
'transcription_started_at' => now(),
]);
$component = Livewire::test(Show::class, ['recording' => $recording]);
$recording->update([
'transcription_status' => 'processing',
'transcription_progress' => 'Transcribing locally…',
'transcription_percent' => 40,
]);
$component
->call('onTranscriptionUpdated', [
'id' => $recording->id,
'status' => 'processing',
])
->assertSet('recording.transcription_status', 'processing')
->assertSet('recording.transcription_percent', 40)
->assertSee('Transcribing locally…');
}
} }