Compare commits
4
Commits
c17f8fb506
..
v1.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a66cf6f63 | ||
|
|
5f0f61995c | ||
|
|
b7c36b8b3b | ||
|
|
6bc5a20606 |
+8
-6
@@ -10,12 +10,13 @@ node_modules
|
||||
vendor
|
||||
public/build
|
||||
public/hot
|
||||
storage/app/private/**
|
||||
storage/app/public/**
|
||||
storage/logs/**
|
||||
storage/framework/cache/**
|
||||
storage/framework/sessions/**
|
||||
storage/framework/views/**
|
||||
# Exclude whole trees (not only /**) so Docker never tries to stat root-owned tmp dirs
|
||||
storage/app/private
|
||||
storage/app/public
|
||||
storage/logs
|
||||
storage/framework/cache
|
||||
storage/framework/sessions
|
||||
storage/framework/views
|
||||
database/*.sqlite*
|
||||
.env
|
||||
.env.*
|
||||
@@ -28,5 +29,6 @@ npm-debug.log
|
||||
yarn-error.log
|
||||
tests
|
||||
docs
|
||||
todo.txt
|
||||
*.md
|
||||
!README.md
|
||||
|
||||
+35
-10
@@ -1,10 +1,14 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Composer deps (runs in parallel with npm ci)
|
||||
# ---------------------------------------------------------------------------
|
||||
FROM composer:2 AS vendor
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY composer.json composer.lock ./
|
||||
|
||||
RUN composer install \
|
||||
--no-dev \
|
||||
--no-scripts \
|
||||
@@ -12,15 +16,29 @@ RUN composer install \
|
||||
--prefer-dist \
|
||||
--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
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
|
||||
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 resources ./resources
|
||||
COPY public ./public
|
||||
@@ -39,8 +57,12 @@ ENV VITE_APP_NAME=$VITE_APP_NAME \
|
||||
|
||||
RUN npm run build
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Runtime image
|
||||
# ---------------------------------------------------------------------------
|
||||
FROM dunglas/frankenphp:php8.5-bookworm
|
||||
|
||||
# Rarely changes — keep early for cache hits
|
||||
RUN install-php-extensions \
|
||||
pcntl \
|
||||
pdo_sqlite \
|
||||
@@ -50,20 +72,25 @@ RUN install-php-extensions \
|
||||
intl \
|
||||
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 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
|
||||
|
||||
# Dependency layer (invalidates when lockfiles / vendor change)
|
||||
COPY --from=vendor /app/vendor ./vendor
|
||||
COPY composer.json composer.lock ./
|
||||
|
||||
# Application source (.dockerignore excludes vendor, node_modules, public/build, storage uploads)
|
||||
COPY . .
|
||||
|
||||
# Built frontend assets
|
||||
COPY --from=assets /app/public/build ./public/build
|
||||
|
||||
RUN composer dump-autoload --optimize --no-dev \
|
||||
&& mkdir -p \
|
||||
# Framework/view cache paths must exist before package:discover runs during dump-autoload
|
||||
RUN mkdir -p \
|
||||
storage/app/private \
|
||||
storage/app/public \
|
||||
storage/framework/cache \
|
||||
@@ -72,11 +99,9 @@ RUN composer dump-autoload --optimize --no-dev \
|
||||
storage/logs \
|
||||
database \
|
||||
bootstrap/cache \
|
||||
&& composer dump-autoload --optimize --no-dev \
|
||||
&& 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
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
||||
|
||||
@@ -76,6 +76,8 @@ sed -i "s|^APP_KEY=.*|APP_KEY=${KEY}|" .env
|
||||
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:
|
||||
|
||||
- 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.
|
||||
|
||||
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
|
||||
|
||||
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):
|
||||
|
||||
@@ -5,6 +5,8 @@ namespace App\Livewire\Recordings;
|
||||
use App\Models\Recording;
|
||||
use Flux\Flux;
|
||||
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\Gate;
|
||||
use Livewire\Attributes\Layout;
|
||||
@@ -25,9 +27,26 @@ class Index extends Component
|
||||
#[Url(as: 'q', history: true)]
|
||||
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
|
||||
{
|
||||
$this->userId = (int) Auth::id();
|
||||
$this->normalizeSort();
|
||||
}
|
||||
|
||||
public function updatedSearch(): void
|
||||
@@ -35,6 +54,22 @@ class Index extends Component
|
||||
$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.
|
||||
*/
|
||||
@@ -127,7 +162,9 @@ class Index extends Component
|
||||
|
||||
$totalCount = $user->recordings()->count();
|
||||
|
||||
$query = $user->recordings()->latest();
|
||||
$this->normalizeSort();
|
||||
|
||||
$query = $user->recordings();
|
||||
|
||||
$search = trim($this->search);
|
||||
|
||||
@@ -135,6 +172,8 @@ class Index extends Component
|
||||
$query->search($search);
|
||||
}
|
||||
|
||||
$this->applySort($query);
|
||||
|
||||
$recordings = $query->paginate(20);
|
||||
|
||||
$pendingCount = $user->recordings()
|
||||
@@ -155,4 +194,34 @@ class Index extends Component
|
||||
'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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ use Flux\Flux;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Component;
|
||||
|
||||
#[Layout('layouts.app')]
|
||||
@@ -14,6 +15,8 @@ class Show extends Component
|
||||
{
|
||||
public Recording $recording;
|
||||
|
||||
public int $userId;
|
||||
|
||||
public function mount(Recording $recording): void
|
||||
{
|
||||
Gate::authorize('view', $recording);
|
||||
@@ -22,6 +25,22 @@ class Show extends Component
|
||||
$recording->refresh();
|
||||
|
||||
$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
|
||||
@@ -64,6 +83,10 @@ class Show extends Component
|
||||
|
||||
public function render(): View
|
||||
{
|
||||
if ($this->recording->isTranscribing()) {
|
||||
$this->recording->refresh();
|
||||
}
|
||||
|
||||
return view('livewire.recordings.show')
|
||||
->title($this->recording->title);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -39,7 +39,9 @@ services:
|
||||
image: node:22-bookworm
|
||||
container_name: andytranscribe-vite
|
||||
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:
|
||||
- "${VITE_HOST_PORT:-5173}:5173"
|
||||
environment:
|
||||
|
||||
+4
-18
@@ -70,17 +70,11 @@ services:
|
||||
condition: service_started
|
||||
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:
|
||||
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
|
||||
pull_policy: never
|
||||
container_name: andytranscribe-queue
|
||||
command:
|
||||
- php
|
||||
@@ -107,16 +101,8 @@ services:
|
||||
restart: unless-stopped
|
||||
|
||||
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
|
||||
pull_policy: never
|
||||
container_name: andytranscribe-reverb
|
||||
command:
|
||||
- php
|
||||
|
||||
@@ -66,12 +66,16 @@ function subscribeToRecording(recordingId, handler) {
|
||||
channel.listen('.RecordingTranscriptionUpdated', handler);
|
||||
|
||||
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.
|
||||
* Livewire also listens on the user recordings channel and polls while active.
|
||||
*/
|
||||
export function transcriptionMonitor({ statusUrl, initial }) {
|
||||
return {
|
||||
@@ -82,6 +86,7 @@ export function transcriptionMonitor({ statusUrl, initial }) {
|
||||
},
|
||||
pollError: null,
|
||||
tickTimer: null,
|
||||
hydrateTimer: null,
|
||||
leaveChannel: null,
|
||||
|
||||
get badgeColor() {
|
||||
@@ -108,11 +113,13 @@ export function transcriptionMonitor({ statusUrl, initial }) {
|
||||
if (this.status.is_active) {
|
||||
this.beginTick();
|
||||
this.hydrateOnce();
|
||||
this.beginHydratePoll();
|
||||
}
|
||||
},
|
||||
|
||||
destroy() {
|
||||
this.stopTick();
|
||||
this.stopHydratePoll();
|
||||
|
||||
if (this.leaveChannel) {
|
||||
this.leaveChannel();
|
||||
@@ -131,12 +138,14 @@ export function transcriptionMonitor({ statusUrl, initial }) {
|
||||
|
||||
if (this.status.is_active) {
|
||||
this.beginTick();
|
||||
this.beginHydratePoll();
|
||||
} else {
|
||||
this.stopTick();
|
||||
this.stopHydratePoll();
|
||||
}
|
||||
|
||||
if (wasActive && !this.status.is_active
|
||||
&& !this.status.has_transcript
|
||||
if (wasActive && ! this.status.is_active
|
||||
&& ! this.status.has_transcript
|
||||
&& this.status.status !== 'failed'
|
||||
&& this.status.status !== 'cancelled') {
|
||||
window.location.reload();
|
||||
@@ -158,26 +167,47 @@ export function transcriptionMonitor({ statusUrl, initial }) {
|
||||
}
|
||||
},
|
||||
|
||||
tickElapsed() {
|
||||
if (!this.status.is_active || this.status.elapsed_seconds == null) {
|
||||
beginHydratePoll() {
|
||||
if (this.hydrateTimer || ! this.statusUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.status.elapsed_seconds += 1;
|
||||
this.status.elapsed_human = formatElapsed(this.status.elapsed_seconds);
|
||||
this.hydrateTimer = setInterval(() => this.hydrateOnce(), 2000);
|
||||
},
|
||||
|
||||
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() {
|
||||
if (!this.statusUrl) {
|
||||
if (! this.statusUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(this.statusUrl, {
|
||||
headers: { Accept: 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (! response.ok) {
|
||||
throw new Error('Status request failed (' + response.status + ')');
|
||||
}
|
||||
|
||||
|
||||
@@ -92,24 +92,68 @@
|
||||
|
||||
<flux:table :paginate="$recordings">
|
||||
<flux:table.columns>
|
||||
<flux:table.column class="w-12"></flux:table.column>
|
||||
<flux:table.column>Title</flux:table.column>
|
||||
<flux:table.column>Duration</flux:table.column>
|
||||
<flux:table.column>Words</flux:table.column>
|
||||
<flux:table.column>Status</flux:table.column>
|
||||
<flux:table.column>Uploaded</flux:table.column>
|
||||
<flux:table.column
|
||||
sortable
|
||||
:sorted="$sortBy === 'title'"
|
||||
:direction="$sortDirection"
|
||||
wire:click="sort('title')"
|
||||
>
|
||||
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.columns>
|
||||
|
||||
<flux:table.rows>
|
||||
@foreach ($recordings as $recording)
|
||||
<flux:table.row wire:key="recording-{{ $recording->id }}-{{ $recording->transcription_status }}-{{ $recording->transcription_percent }}">
|
||||
<flux:table.cell>
|
||||
<flux:table.cell class="max-w-xl">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<flux:link
|
||||
href="{{ route('recordings.show', $recording) }}"
|
||||
wire:navigate
|
||||
class="shrink-0 font-medium"
|
||||
>
|
||||
{{ $recording->title }}
|
||||
</flux:link>
|
||||
<flux:button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
square
|
||||
class="shrink-0"
|
||||
data-audio-url="{{ route('recordings.audio', $recording) }}"
|
||||
x-bind:aria-label="isPlayingRow({{ $recording->id }}) ? 'Pause' : 'Play'"
|
||||
x-on:click="togglePlay({{ $recording->id }}, $el.dataset.audioUrl)"
|
||||
@@ -124,19 +168,12 @@
|
||||
x-cloak
|
||||
/>
|
||||
</flux:button>
|
||||
</flux:table.cell>
|
||||
<flux:table.cell class="whitespace-normal">
|
||||
<flux:link href="{{ route('recordings.show', $recording) }}" wire:navigate class="font-medium">
|
||||
{{ $recording->title }}
|
||||
</flux:link>
|
||||
@if ($recording->artist)
|
||||
<flux:text class="mt-0.5 text-xs">{{ $recording->artist }}</flux:text>
|
||||
@endif
|
||||
@if ($snippet = $recording->transcriptSnippet($search ?: null))
|
||||
<flux:text class="mt-1 max-w-xl text-xs leading-relaxed">
|
||||
{{ $snippet }}
|
||||
</flux:text>
|
||||
@if ($preview = $recording->transcriptFirstLine())
|
||||
<span class="min-w-0 truncate text-sm text-zinc-500 dark:text-zinc-400" title="{{ $preview }}">
|
||||
{{ $preview }}
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
</flux:table.cell>
|
||||
<flux:table.cell>{{ $recording->duration_formatted }}</flux:table.cell>
|
||||
<flux:table.cell>
|
||||
@@ -144,29 +181,11 @@
|
||||
{{ $recording->word_count > 0 ? number_format($recording->word_count) : '—' }}
|
||||
</span>
|
||||
</flux:table.cell>
|
||||
<flux:table.cell class="whitespace-normal py-2">
|
||||
<flux:table.cell class="w-36 whitespace-nowrap">
|
||||
<x-transcription-status-badge
|
||||
:status="$recording->transcription_status"
|
||||
:label="$recording->transcriptionStatusLabel()"
|
||||
/>
|
||||
@if ($recording->transcription_status === 'pending' && filled($recording->transcription_progress))
|
||||
<div
|
||||
class="mt-1 max-w-[14rem] truncate text-xs text-zinc-500 dark:text-zinc-400"
|
||||
title="{{ $recording->transcription_progress }}"
|
||||
>
|
||||
{{ $recording->transcription_progress }}
|
||||
</div>
|
||||
@elseif ($recording->transcription_status === 'processing' && filled($recording->transcription_progress))
|
||||
<div
|
||||
class="mt-1 max-w-[14rem] truncate text-xs text-amber-700 dark:text-amber-300"
|
||||
title="{{ $recording->transcription_progress }}"
|
||||
>
|
||||
@if ($recording->transcription_percent)
|
||||
{{ $recording->transcription_percent }}% ·
|
||||
@endif
|
||||
{{ $recording->transcription_progress }}
|
||||
</div>
|
||||
@endif
|
||||
</flux:table.cell>
|
||||
<flux:table.cell>
|
||||
{{ $recording->created_at?->format('Y-m-d H:i') }}
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
<div
|
||||
@if ($recording->isTranscribing())
|
||||
wire:poll.2s.visible
|
||||
@endif
|
||||
>
|
||||
<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(),
|
||||
@@ -7,7 +13,7 @@
|
||||
start();
|
||||
return () => destroy();
|
||||
"
|
||||
>
|
||||
>
|
||||
<div class="mb-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<flux:link href="{{ route('recordings.index') }}" wire:navigate class="text-sm">← Recordings</flux:link>
|
||||
@@ -222,4 +228,5 @@
|
||||
No transcript yet. Transcription starts automatically after upload, or use the button above.
|
||||
</flux:text>
|
||||
</flux:card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -63,7 +63,7 @@ class IndexTest extends TestCase
|
||||
'file_path' => 'recordings/office.mp3',
|
||||
'file_size_bytes' => 100,
|
||||
'transcription_status' => 'done',
|
||||
'transcript' => 'talking about the pocket recorder today',
|
||||
'transcript' => "talking about the pocket recorder today\nsecond line stays hidden",
|
||||
]);
|
||||
|
||||
Recording::query()->create([
|
||||
@@ -79,6 +79,8 @@ class IndexTest extends TestCase
|
||||
Livewire::test(Index::class)
|
||||
->set('search', 'pocket recorder')
|
||||
->assertSee('Office chat')
|
||||
->assertSee('talking about the pocket recorder today')
|
||||
->assertDontSee('second line stays hidden')
|
||||
->assertDontSee('Unrelated');
|
||||
}
|
||||
|
||||
@@ -123,8 +125,8 @@ class IndexTest extends TestCase
|
||||
|
||||
Livewire::test(Index::class)
|
||||
->assertSee('wire:poll', false)
|
||||
->assertSee('Transcribing locally…')
|
||||
->assertSee('40%')
|
||||
->assertSee('Transcribing')
|
||||
->assertDontSee('Transcribing locally…')
|
||||
->assertSeeHtml('bg-amber-400');
|
||||
}
|
||||
|
||||
@@ -149,7 +151,7 @@ class IndexTest extends TestCase
|
||||
Livewire::test(Index::class)
|
||||
->assertSee('Waiting in line')
|
||||
->assertSee('Queued')
|
||||
->assertSee('Queued — waiting to start…')
|
||||
->assertDontSee('Queued — waiting to start…')
|
||||
->assertDontSee('5%')
|
||||
->assertSeeHtml('bg-zinc-400/15')
|
||||
->assertDontSeeHtml('bg-amber-400');
|
||||
@@ -280,4 +282,59 @@ class IndexTest extends TestCase
|
||||
|
||||
$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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,4 +59,81 @@ class ShowTest extends TestCase
|
||||
|
||||
$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…');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user