Add Fortify auth, Flux dark mode, demo seed, and Docker hot reload.

Protect recordings per user, seed a demo login on container start, and bind-mount the app with Vite HMR for local Compose development.
This commit is contained in:
ben
2026-08-12 18:38:53 +02:00
parent accb721811
commit bfcfc12f58
78 changed files with 3942 additions and 186 deletions
+9
View File
@@ -96,3 +96,12 @@ WHISPER_HOST_PORT=8090
VITE_REVERB_HOST=localhost VITE_REVERB_HOST=localhost
VITE_REVERB_SCHEME=http VITE_REVERB_SCHEME=http
# Demo user created on every container start (db:seed via entrypoint)
SEED_USER_NAME="Demo User"
SEED_USER_EMAIL=demo@example.com
SEED_USER_PASSWORD=password
# Uncomment for Docker bind mounts + Vite HMR (no rebuild for PHP/Blade/CSS/JS):
# COMPOSE_FILE=docker-compose.yml:docker-compose.dev.yml
# VITE_HOST_PORT=5173
+15 -6
View File
@@ -1,5 +1,17 @@
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
FROM composer:2 AS vendor
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install \
--no-dev \
--no-scripts \
--no-autoloader \
--prefer-dist \
--no-interaction
FROM node:22-bookworm AS assets FROM node:22-bookworm AS assets
WORKDIR /app WORKDIR /app
@@ -7,6 +19,8 @@ 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 ./
COPY vite.config.js ./ COPY vite.config.js ./
COPY resources ./resources COPY resources ./resources
COPY public ./public COPY public ./public
@@ -42,13 +56,8 @@ COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
WORKDIR /app WORKDIR /app
COPY --from=vendor /app/vendor ./vendor
COPY composer.json composer.lock ./ COPY composer.json composer.lock ./
RUN composer install \
--no-dev \
--no-scripts \
--no-autoloader \
--prefer-dist \
--no-interaction
COPY . . COPY . .
COPY --from=assets /app/public/build ./public/build COPY --from=assets /app/public/build ./public/build
+44 -6
View File
@@ -2,10 +2,11 @@
Upload pocket-recorder audio (MP3, WAV, OGG, and more), extract embedded metadata, and transcribe locally with [faster-whisper-server](https://github.com/fedirz/faster-whisper-server). Audio never leaves your machine. Upload pocket-recorder audio (MP3, WAV, OGG, and more), extract embedded metadata, and transcribe locally with [faster-whisper-server](https://github.com/fedirz/faster-whisper-server). Audio never leaves your machine.
Built with Laravel 13, Blade, Alpine.js, Tailwind CSS 4, [Laravel Reverb](https://laravel.com/docs/reverb), FrankenPHP, and [Laravel AI](https://github.com/laravel/ai). Built with Laravel 13, Blade, Livewire, Flux UI, Alpine.js, Tailwind CSS 4, [Laravel Reverb](https://laravel.com/docs/reverb), FrankenPHP, and [Laravel AI](https://github.com/laravel/ai).
## Features ## Features
- User accounts with login and open registration (each user only sees their own recordings)
- Upload common audio formats (MP3, WAV, OGG, FLAC, M4A, AAC, WebM, WMA, AIFF — up to 2 GB) - Upload common audio formats (MP3, WAV, OGG, FLAC, M4A, AAC, WebM, WMA, AIFF — up to 2 GB)
- Automatic metadata extraction when tags are present (title, artist, album, duration, recorded date) - Automatic metadata extraction when tags are present (title, artist, album, duration, recorded date)
- Search recordings by title, artist, or transcript - Search recordings by title, artist, or transcript
@@ -79,10 +80,22 @@ On first start the app container will:
- create `database/database.sqlite` if needed - create `database/database.sqlite` if needed
- run migrations - run migrations
- seed a demo user (see below)
- start FrankenPHP on port **8080** - start FrankenPHP on port **8080**
Whisper may take a minute or two while the model downloads. Whisper may take a minute or two while the model downloads.
### Demo login
Every container start runs `db:seed`, which ensures this user exists:
| Field | Default |
| --- | --- |
| Email | `demo@example.com` |
| Password | `password` |
Override with `SEED_USER_NAME`, `SEED_USER_EMAIL`, and `SEED_USER_PASSWORD` in `.env`.
### 5. Open the app ### 5. Open the app
| URL | Purpose | | URL | Purpose |
@@ -110,6 +123,27 @@ 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.
### 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):
```bash
docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build -d
```
Or set once in `.env`:
```env
COMPOSE_FILE=docker-compose.yml:docker-compose.dev.yml
```
Then a normal `docker compose up -d` enables:
- host source mounted at `/app` (PHP, Blade, routes, etc. without rebuild)
- `vite` on port **5173** for CSS/JS hot reload and Blade refresh
- `queue:listen` so worker code picks up changes between jobs
Open [http://localhost:8080](http://localhost:8080) as usual. After changing Composer packages, run `docker compose exec app composer install`.
## Services and ports ## Services and ports
| Service | Host port | Role | | Service | Host port | Role |
@@ -118,6 +152,7 @@ Data under `./database`, `./storage/app`, and `./storage/logs` is kept on the ho
| `reverb` | `8081` | WebSockets for live transcription status | | `reverb` | `8081` | WebSockets for live transcription status |
| `whisper` | `8090` | faster-whisper HTTP API | | `whisper` | `8090` | faster-whisper HTTP API |
| `queue` | — | `php artisan queue:work` for transcription jobs | | `queue` | — | `php artisan queue:work` for transcription jobs |
| `vite` | `5173` | Vite HMR (dev overlay only) |
### Persistent data ### Persistent data
@@ -162,11 +197,14 @@ Use the GPU Whisper service instead of the CPU `whisper` service when you have a
## Usage ## Usage
1. Open **Recordings → Upload** and drop one or many audio files. 1. Open the app and **Log in** with the demo user (`demo@example.com` / `password`), or **Register** a new account.
2. Transcription starts automatically (the `queue` service must be running). 2. Open **Recordings → Upload** and drop one or many audio files.
3. Watch live progress on the list or detail page; stop or restart anytime. 3. Transcription starts automatically (the `queue` service must be running).
4. Search by title, artist, or transcript text. 4. Watch live progress on the list or detail page; stop or restart anytime.
5. For older uploads still **Queued** with no progress, use **Queue pending transcriptions** on the recordings list. 5. Search by title, artist, or transcript text.
6. For older uploads still **Queued** with no progress, use **Queue pending transcriptions** on the recordings list.
The demo user is re-seeded on every container start. Any recordings with no owner are assigned to that demo user. Later registered users only see their own uploads.
Finished transcripts are stored on each recording and are included in search. Finished transcripts are stored on each recording and are included in search.
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace App\Actions\Fortify;
use App\Concerns\PasswordValidationRules;
use App\Concerns\ProfileValidationRules;
use App\Models\User;
use Illuminate\Support\Facades\Validator;
use Laravel\Fortify\Contracts\CreatesNewUsers;
class CreateNewUser implements CreatesNewUsers
{
use PasswordValidationRules, ProfileValidationRules;
/**
* Validate and create a newly registered user.
*
* @param array<string, string> $input
*/
public function create(array $input): User
{
Validator::make($input, [
...$this->profileRules(),
'password' => $this->passwordRules(),
])->validate();
return User::create([
'name' => $input['name'],
'email' => $input['email'],
'password' => $input['password'],
]);
}
}
@@ -0,0 +1,19 @@
<?php
namespace App\Actions\Fortify;
use Illuminate\Contracts\Validation\Rule;
use Illuminate\Validation\Rules\Password;
trait PasswordValidationRules
{
/**
* Get the validation rules used to validate passwords.
*
* @return array<int, Rule|array<mixed>|string>
*/
protected function passwordRules(): array
{
return ['required', 'string', Password::default(), 'confirmed'];
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace App\Actions\Fortify;
use App\Concerns\PasswordValidationRules;
use App\Models\User;
use Illuminate\Support\Facades\Validator;
use Laravel\Fortify\Contracts\ResetsUserPasswords;
class ResetUserPassword implements ResetsUserPasswords
{
use PasswordValidationRules;
/**
* Validate and reset the user's forgotten password.
*
* @param array<string, string> $input
*/
public function reset(User $user, array $input): void
{
Validator::make($input, [
'password' => $this->passwordRules(),
])->validate();
$user->forceFill([
'password' => $input['password'],
])->save();
}
}
@@ -0,0 +1,35 @@
<?php
namespace App\Actions\Fortify;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
use Laravel\Fortify\Contracts\UpdatesUserPasswords;
class UpdateUserPassword implements UpdatesUserPasswords
{
use PasswordValidationRules;
/**
* Validate and update the user's password.
*
* @param array<string, string> $input
*
* @throws ValidationException
*/
public function update(User $user, array $input): void
{
Validator::make($input, [
'current_password' => ['required', 'string', 'current_password:web'],
'password' => $this->passwordRules(),
], [
'current_password.current_password' => __('The provided password does not match your current password.'),
])->validateWithBag('updatePassword');
$user->forceFill([
'password' => Hash::make($input['password']),
])->save();
}
}
@@ -0,0 +1,61 @@
<?php
namespace App\Actions\Fortify;
use App\Models\User;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Laravel\Fortify\Contracts\UpdatesUserProfileInformation;
class UpdateUserProfileInformation implements UpdatesUserProfileInformation
{
/**
* Validate and update the given user's profile information.
*
* @param array<string, string> $input
*
* @throws ValidationException
*/
public function update(User $user, array $input): void
{
Validator::make($input, [
'name' => ['required', 'string', 'max:255'],
'email' => [
'required',
'string',
'email',
'max:255',
Rule::unique('users')->ignore($user->id),
],
])->validateWithBag('updateProfileInformation');
if ($input['email'] !== $user->email &&
$user instanceof MustVerifyEmail) {
$this->updateVerifiedUser($user, $input);
} else {
$user->forceFill([
'name' => $input['name'],
'email' => $input['email'],
])->save();
}
}
/**
* Update the given verified user's profile information.
*
* @param array<string, string> $input
*/
protected function updateVerifiedUser(User $user, array $input): void
{
$user->forceFill([
'name' => $input['name'],
'email' => $input['email'],
'email_verified_at' => null,
])->save();
$user->sendEmailVerificationNotification();
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace App\Concerns;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Validation\Rules\Password;
trait PasswordValidationRules
{
/**
* Get the validation rules used to validate passwords.
*
* @return array<int, Password|ValidationRule|array<mixed>|string>
*/
protected function passwordRules(): array
{
return ['required', 'string', Password::default(), 'confirmed'];
}
/**
* Get the validation rules used to validate the current password.
*
* @return array<int, Password|ValidationRule|array<mixed>|string>
*/
protected function currentPasswordRules(): array
{
return ['required', 'string', 'current_password'];
}
}
+51
View File
@@ -0,0 +1,51 @@
<?php
namespace App\Concerns;
use App\Models\User;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Validation\Rule;
trait ProfileValidationRules
{
/**
* Get the validation rules used to validate user profiles.
*
* @return array<string, array<int, ValidationRule|array<mixed>|string>>
*/
protected function profileRules(?int $userId = null): array
{
return [
'name' => $this->nameRules(),
'email' => $this->emailRules($userId),
];
}
/**
* Get the validation rules used to validate user names.
*
* @return array<int, ValidationRule|array<mixed>|string>
*/
protected function nameRules(): array
{
return ['required', 'string', 'max:255'];
}
/**
* Get the validation rules used to validate user emails.
*
* @return array<int, ValidationRule|array<mixed>|string>
*/
protected function emailRules(?int $userId = null): array
{
return [
'required',
'string',
'email',
'max:255',
$userId === null
? Rule::unique(User::class)
: Rule::unique(User::class)->ignore($userId),
];
}
}
+3 -3
View File
@@ -3,8 +3,8 @@
namespace App\Events; namespace App\Events;
use App\Models\Recording; use App\Models\Recording;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets; use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow; use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
@@ -21,12 +21,12 @@ class RecordingTranscriptionUpdated implements ShouldBroadcastNow
/** /**
* Get the channels the event should broadcast on. * Get the channels the event should broadcast on.
* *
* @return array<int, Channel> * @return array<int, PrivateChannel>
*/ */
public function broadcastOn(): array public function broadcastOn(): array
{ {
return [ return [
new Channel('recordings'), new PrivateChannel('recording.'.$this->recording->id),
]; ];
} }
@@ -4,6 +4,7 @@ namespace App\Http\Controllers;
use App\Models\Recording; use App\Models\Recording;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Gate;
class CancelTranscriptionController extends Controller class CancelTranscriptionController extends Controller
{ {
@@ -12,6 +13,8 @@ class CancelTranscriptionController extends Controller
*/ */
public function __invoke(Recording $recording): RedirectResponse public function __invoke(Recording $recording): RedirectResponse
{ {
Gate::authorize('transcribe', $recording);
if (! $recording->isTranscribing()) { if (! $recording->isTranscribing()) {
return back()->with('error', 'No transcription is currently running.'); return back()->with('error', 'No transcription is currently running.');
} }
+18 -8
View File
@@ -8,6 +8,7 @@ use App\Services\Mp3MetadataService;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile; use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Illuminate\View\View; use Illuminate\View\View;
@@ -18,12 +19,14 @@ class RecordingController extends Controller
*/ */
public function index(Request $request): View public function index(Request $request): View
{ {
Recording::query() $user = $request->user();
$user->recordings()
->whereIn('transcription_status', ['pending', 'processing']) ->whereIn('transcription_status', ['pending', 'processing'])
->orderBy('id') ->orderBy('id')
->each(fn (Recording $recording) => $recording->recoverOrphanedTranscription()); ->each(fn (Recording $recording) => $recording->recoverOrphanedTranscription());
$query = Recording::query()->latest(); $query = $user->recordings()->latest();
if ($search = $request->string('q')->trim()->toString()) { if ($search = $request->string('q')->trim()->toString()) {
$query->search($search); $query->search($search);
@@ -31,7 +34,7 @@ class RecordingController extends Controller
$recordings = $query->paginate(20)->withQueryString(); $recordings = $query->paginate(20)->withQueryString();
$pendingCount = Recording::query() $pendingCount = $user->recordings()
->whereIn('transcription_status', ['pending', 'failed', 'cancelled']) ->whereIn('transcription_status', ['pending', 'failed', 'cancelled'])
->get() ->get()
->filter(fn (Recording $recording) => ! $recording->hasActiveTranscriptionJob()) ->filter(fn (Recording $recording) => ! $recording->hasActiveTranscriptionJob())
@@ -47,9 +50,9 @@ class RecordingController extends Controller
/** /**
* Show the upload form. * Show the upload form.
*/ */
public function create(): View public function create(Request $request): View
{ {
$existingFingerprints = Recording::query() $existingFingerprints = $request->user()->recordings()
->get(['original_filename', 'file_size_bytes']) ->get(['original_filename', 'file_size_bytes'])
->map(fn (Recording $recording) => $this->uploadFingerprint( ->map(fn (Recording $recording) => $this->uploadFingerprint(
$recording->original_filename, $recording->original_filename,
@@ -75,6 +78,7 @@ class RecordingController extends Controller
fn ($file) => $file instanceof UploadedFile, fn ($file) => $file instanceof UploadedFile,
)); ));
$user = $request->user();
$titleOverride = $request->string('title')->trim()->toString(); $titleOverride = $request->string('title')->trim()->toString();
$recordings = []; $recordings = [];
$skippedDuplicates = 0; $skippedDuplicates = 0;
@@ -89,8 +93,8 @@ class RecordingController extends Controller
if ( if (
isset($seenHashes[$hash]) isset($seenHashes[$hash])
|| Recording::query()->where('content_hash', $hash)->exists() || $user->recordings()->where('content_hash', $hash)->exists()
|| Recording::query() || $user->recordings()
->where('original_filename', $file->getClientOriginalName()) ->where('original_filename', $file->getClientOriginalName())
->where('file_size_bytes', $file->getSize() ?: 0) ->where('file_size_bytes', $file->getSize() ?: 0)
->exists() ->exists()
@@ -106,7 +110,7 @@ class RecordingController extends Controller
? $titleOverride ? $titleOverride
: null; : null;
$recordings[] = $this->storeUploadedRecording($file, $metadata, $hash, $title); $recordings[] = $this->storeUploadedRecording($request, $file, $metadata, $hash, $title);
} }
if ($recordings === [] && $skippedDuplicates > 0) { if ($recordings === [] && $skippedDuplicates > 0) {
@@ -149,6 +153,8 @@ class RecordingController extends Controller
*/ */
public function show(Recording $recording): View public function show(Recording $recording): View
{ {
Gate::authorize('view', $recording);
$recording->recoverOrphanedTranscription(); $recording->recoverOrphanedTranscription();
$recording->refresh(); $recording->refresh();
@@ -160,6 +166,8 @@ class RecordingController extends Controller
*/ */
public function destroy(Recording $recording): RedirectResponse public function destroy(Recording $recording): RedirectResponse
{ {
Gate::authorize('delete', $recording);
$recording->deleteFile(); $recording->deleteFile();
$recording->delete(); $recording->delete();
@@ -172,6 +180,7 @@ class RecordingController extends Controller
* Persist a single uploaded audio file as a recording. * Persist a single uploaded audio file as a recording.
*/ */
private function storeUploadedRecording( private function storeUploadedRecording(
Request $request,
UploadedFile $file, UploadedFile $file,
Mp3MetadataService $metadata, Mp3MetadataService $metadata,
string $contentHash, string $contentHash,
@@ -185,6 +194,7 @@ class RecordingController extends Controller
?: ($tags['title'] ?? pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME)); ?: ($tags['title'] ?? pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME));
$recording = Recording::create([ $recording = Recording::create([
'user_id' => $request->user()->id,
'title' => $title, 'title' => $title,
'original_filename' => $file->getClientOriginalName(), 'original_filename' => $file->getClientOriginalName(),
'file_path' => $path, 'file_path' => $path,
@@ -5,6 +5,7 @@ namespace App\Http\Controllers;
use App\Http\Requests\TranscribeRecordingRequest; use App\Http\Requests\TranscribeRecordingRequest;
use App\Models\Recording; use App\Models\Recording;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Gate;
class TranscribeController extends Controller class TranscribeController extends Controller
{ {
@@ -15,6 +16,8 @@ class TranscribeController extends Controller
*/ */
public function __invoke(TranscribeRecordingRequest $request, Recording $recording): RedirectResponse public function __invoke(TranscribeRecordingRequest $request, Recording $recording): RedirectResponse
{ {
Gate::authorize('transcribe', $recording);
$recording->queueLocalTranscription(); $recording->queueLocalTranscription();
return back()->with('success', 'Transcription started. Progress updates below.'); return back()->with('success', 'Transcription started. Progress updates below.');
@@ -4,17 +4,18 @@ namespace App\Http\Controllers;
use App\Models\Recording; use App\Models\Recording;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class TranscribePendingController extends Controller class TranscribePendingController extends Controller
{ {
/** /**
* Queue local transcription for recordings that still need a transcript. * Queue local transcription for recordings that still need a transcript.
*/ */
public function __invoke(): RedirectResponse public function __invoke(Request $request): RedirectResponse
{ {
$queued = 0; $queued = 0;
Recording::query() $request->user()->recordings()
->whereIn('transcription_status', ['pending', 'failed', 'cancelled']) ->whereIn('transcription_status', ['pending', 'failed', 'cancelled'])
->orderBy('id') ->orderBy('id')
->each(function (Recording $recording) use (&$queued): void { ->each(function (Recording $recording) use (&$queued): void {
@@ -4,6 +4,7 @@ namespace App\Http\Controllers;
use App\Models\Recording; use App\Models\Recording;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Gate;
class TranscriptionStatusController extends Controller class TranscriptionStatusController extends Controller
{ {
@@ -12,6 +13,8 @@ class TranscriptionStatusController extends Controller
*/ */
public function __invoke(Recording $recording): JsonResponse public function __invoke(Recording $recording): JsonResponse
{ {
Gate::authorize('view', $recording);
$recording = $recording->fresh(); $recording = $recording->fresh();
if ($recording->recoverOrphanedTranscription()) { if ($recording->recoverOrphanedTranscription()) {
+1 -1
View File
@@ -37,7 +37,7 @@ class StoreRecordingRequest extends FormRequest
public function authorize(): bool public function authorize(): bool
{ {
return true; return $this->user() !== null;
} }
/** /**
@@ -8,7 +8,9 @@ class TranscribeRecordingRequest extends FormRequest
{ {
public function authorize(): bool public function authorize(): bool
{ {
return true; $recording = $this->route('recording');
return $recording !== null && $this->user()?->can('transcribe', $recording) === true;
} }
/** /**
@@ -0,0 +1,24 @@
<?php
namespace App\Listeners;
use App\Models\Recording;
use App\Models\User;
use Illuminate\Auth\Events\Registered;
class AssignOrphanedRecordings
{
/**
* Assign unowned recordings to the first registered user.
*/
public function handle(Registered $event): void
{
if (User::query()->count() !== 1) {
return;
}
Recording::query()
->whereNull('user_id')
->update(['user_id' => $event->user->id]);
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
namespace App\Livewire\Actions;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Session;
use Livewire\Features\SupportRedirects\Redirector;
class Logout
{
/**
* Log the current user out of the application.
*/
public function __invoke(): Redirector|RedirectResponse
{
Auth::guard('web')->logout();
Session::invalidate();
Session::regenerateToken();
return redirect('/');
}
}
+11
View File
@@ -9,6 +9,7 @@ use Illuminate\Database\Eloquent\Attributes\Scope;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
@@ -21,6 +22,7 @@ class Recording extends Model
* @var list<string> * @var list<string>
*/ */
protected $fillable = [ protected $fillable = [
'user_id',
'title', 'title',
'original_filename', 'original_filename',
'file_path', 'file_path',
@@ -53,9 +55,18 @@ class Recording extends Model
'duration_seconds' => 'integer', 'duration_seconds' => 'integer',
'file_size_bytes' => 'integer', 'file_size_bytes' => 'integer',
'transcription_percent' => 'integer', 'transcription_percent' => 'integer',
'user_id' => 'integer',
]; ];
} }
/**
* @return BelongsTo<User, $this>
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/** /**
* Human-readable duration (m:ss). * Human-readable duration (m:ss).
*/ */
+22 -1
View File
@@ -2,13 +2,14 @@
namespace App\Models; namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Database\Factories\UserFactory; use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden; use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Str;
#[Fillable(['name', 'email', 'password'])] #[Fillable(['name', 'email', 'password'])]
#[Hidden(['password', 'remember_token'])] #[Hidden(['password', 'remember_token'])]
@@ -29,4 +30,24 @@ class User extends Authenticatable
'password' => 'hashed', 'password' => 'hashed',
]; ];
} }
/**
* @return HasMany<Recording, $this>
*/
public function recordings(): HasMany
{
return $this->hasMany(Recording::class);
}
/**
* Initials for Flux avatar components.
*/
public function initials(): string
{
return Str::of($this->name)
->explode(' ')
->take(2)
->map(fn (string $part) => Str::substr($part, 0, 1))
->implode('');
}
} }
+57
View File
@@ -0,0 +1,57 @@
<?php
namespace App\Policies;
use App\Models\Recording;
use App\Models\User;
class RecordingPolicy
{
/**
* Determine whether the user can view any models.
*/
public function viewAny(User $user): bool
{
return true;
}
/**
* Determine whether the user can view the model.
*/
public function view(User $user, Recording $recording): bool
{
return $recording->user_id === $user->id;
}
/**
* Determine whether the user can create models.
*/
public function create(User $user): bool
{
return true;
}
/**
* Determine whether the user can update the model.
*/
public function update(User $user, Recording $recording): bool
{
return $recording->user_id === $user->id;
}
/**
* Determine whether the user can delete the model.
*/
public function delete(User $user, Recording $recording): bool
{
return $recording->user_id === $user->id;
}
/**
* Determine whether the user can start or stop transcription.
*/
public function transcribe(User $user, Recording $recording): bool
{
return $recording->user_id === $user->id;
}
}
+7 -1
View File
@@ -2,7 +2,11 @@
namespace App\Providers; namespace App\Providers;
use App\Listeners\AssignOrphanedRecordings;
use Illuminate\Auth\Events\Registered;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Illuminate\Validation\Rules\Password;
class AppServiceProvider extends ServiceProvider class AppServiceProvider extends ServiceProvider
{ {
@@ -19,6 +23,8 @@ class AppServiceProvider extends ServiceProvider
*/ */
public function boot(): void public function boot(): void
{ {
// Password::defaults(fn () => Password::min(8));
Event::listen(Registered::class, AssignOrphanedRecordings::class);
} }
} }
+65
View File
@@ -0,0 +1,65 @@
<?php
namespace App\Providers;
use App\Actions\Fortify\CreateNewUser;
use App\Actions\Fortify\ResetUserPassword;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Str;
use Laravel\Fortify\Fortify;
class FortifyServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
//
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
$this->configureActions();
$this->configureViews();
$this->configureRateLimiting();
}
/**
* Configure Fortify actions.
*/
private function configureActions(): void
{
Fortify::createUsersUsing(CreateNewUser::class);
Fortify::resetUserPasswordsUsing(ResetUserPassword::class);
}
/**
* Configure Fortify views.
*/
private function configureViews(): void
{
Fortify::loginView(fn () => view('pages::auth.login'));
Fortify::registerView(fn () => view('pages::auth.register'));
Fortify::resetPasswordView(fn () => view('pages::auth.reset-password'));
Fortify::requestPasswordResetLinkView(fn () => view('pages::auth.forgot-password'));
}
/**
* Configure rate limiting.
*/
private function configureRateLimiting(): void
{
RateLimiter::for('login', function (Request $request) {
$throttleKey = Str::transliterate(Str::lower($request->input(Fortify::username())).'|'.$request->ip());
return Limit::perMinute(5)->by($throttleKey);
});
}
}
+2 -1
View File
@@ -13,7 +13,8 @@ return Application::configure(basePath: dirname(__DIR__))
health: '/up', health: '/up',
) )
->withMiddleware(function (Middleware $middleware): void { ->withMiddleware(function (Middleware $middleware): void {
// $middleware->redirectGuestsTo(fn () => route('login'));
$middleware->redirectUsersTo(fn () => route('recordings.index'));
}) })
->withExceptions(function (Exceptions $exceptions): void { ->withExceptions(function (Exceptions $exceptions): void {
$exceptions->shouldRenderJsonWhen( $exceptions->shouldRenderJsonWhen(
+2
View File
@@ -1,7 +1,9 @@
<?php <?php
use App\Providers\AppServiceProvider; use App\Providers\AppServiceProvider;
use App\Providers\FortifyServiceProvider;
return [ return [
AppServiceProvider::class, AppServiceProvider::class,
FortifyServiceProvider::class,
]; ];
+5 -1
View File
@@ -9,9 +9,13 @@
"php": "^8.3", "php": "^8.3",
"james-heinrich/getid3": "^1.9", "james-heinrich/getid3": "^1.9",
"laravel/ai": "^0.10.3", "laravel/ai": "^0.10.3",
"laravel/fortify": "^1.38",
"laravel/framework": "^13.17", "laravel/framework": "^13.17",
"laravel/reverb": "^1.11", "laravel/reverb": "^1.11",
"laravel/tinker": "^3.0" "laravel/tinker": "^3.0",
"livewire/flux": "^2.16",
"livewire/livewire": "^4.4",
"symfony/polyfill-iconv": "^1.37"
}, },
"require-dev": { "require-dev": {
"fakerphp/faker": "^1.23", "fakerphp/faker": "^1.23",
Generated
+1607 -1
View File
File diff suppressed because it is too large Load Diff
+167
View File
@@ -0,0 +1,167 @@
<?php
use Laravel\Fortify\Features;
return [
/*
|--------------------------------------------------------------------------
| Fortify Guard
|--------------------------------------------------------------------------
|
| Here you may specify which authentication guard Fortify will use while
| authenticating users. This value should correspond with one of your
| guards that is already present in your "auth" configuration file.
|
*/
'guard' => 'web',
/*
|--------------------------------------------------------------------------
| Fortify Password Broker
|--------------------------------------------------------------------------
|
| Here you may specify which password broker Fortify can use when a user
| is resetting their password. This configured value should match one
| of your password brokers setup in your "auth" configuration file.
|
*/
'passwords' => 'users',
/*
|--------------------------------------------------------------------------
| Username / Email
|--------------------------------------------------------------------------
|
| This value defines which model attribute should be considered as your
| application's "username" field. Typically, this might be the email
| address of the users but you are free to change this value here.
|
| Out of the box, Fortify expects forgot password and reset password
| requests to have a field named 'email'. If the application uses
| another name for the field you may define it below as needed.
|
*/
'username' => 'email',
'email' => 'email',
/*
|--------------------------------------------------------------------------
| Lowercase Usernames
|--------------------------------------------------------------------------
|
| This value defines whether usernames should be lowercased before saving
| them in the database, as some database system string fields are case
| sensitive. You may disable this for your application if necessary.
|
*/
'lowercase_usernames' => true,
/*
|--------------------------------------------------------------------------
| Home Path
|--------------------------------------------------------------------------
|
| Here you may configure the path where users will get redirected during
| authentication or password reset when the operations are successful
| and the user is authenticated. You are free to change this value.
|
*/
'home' => '/recordings',
/*
|--------------------------------------------------------------------------
| Fortify Routes Prefix / Subdomain
|--------------------------------------------------------------------------
|
| Here you may specify which prefix Fortify will assign to all the routes
| that it registers with the application. If necessary, you may change
| subdomain under which all of the Fortify routes will be available.
|
*/
'prefix' => '',
'domain' => null,
/*
|--------------------------------------------------------------------------
| Fortify Routes Middleware
|--------------------------------------------------------------------------
|
| Here you may specify which middleware Fortify will assign to the routes
| that it registers with the application. If necessary, you may change
| these middleware but typically this provided default is preferred.
|
*/
'middleware' => ['web'],
/*
|--------------------------------------------------------------------------
| Rate Limiting
|--------------------------------------------------------------------------
|
| By default, Fortify will throttle logins to five requests per minute for
| every email and IP address combination. However, if you would like to
| specify a custom rate limiter to call then you may specify it here.
|
*/
'limiters' => [
'login' => 'login',
],
/*
|--------------------------------------------------------------------------
| Register View Routes
|--------------------------------------------------------------------------
|
| Here you may specify if the routes returning views should be disabled as
| you may not need them when building your own application. This may be
| especially true if you're writing a custom single-page application.
|
*/
'views' => true,
/*
|--------------------------------------------------------------------------
| Passkeys
|--------------------------------------------------------------------------
|
| These settings configure Fortify's passkey (WebAuthn) support. Passkeys
| allow users to sign in without needing to remember credentials since
| they use public-key cryptography - making them immune to breaches.
|
*/
'passkeys' => [
'relying_party_id' => parse_url(config('app.url'), PHP_URL_HOST),
'allowed_origins' => [config('app.url')],
'timeout' => 60000,
],
/*
|--------------------------------------------------------------------------
| Features
|--------------------------------------------------------------------------
|
| Some of the Fortify features are optional. You may disable the features
| by removing them from this array. You're free to only remove some of
| these features or you can even remove all of these if you need to.
|
*/
'features' => [
Features::registration(),
Features::resetPasswords(),
],
];
+282
View File
@@ -0,0 +1,282 @@
<?php
return [
/*
|---------------------------------------------------------------------------
| Component Locations
|---------------------------------------------------------------------------
|
| This value sets the root directories that'll be used to resolve view-based
| components like single and multi-file components. The make command will
| use the first directory in this array to add new component files to.
|
*/
'component_locations' => [
resource_path('views/components'),
resource_path('views/livewire'),
],
/*
|---------------------------------------------------------------------------
| Component Namespaces
|---------------------------------------------------------------------------
|
| This value sets default namespaces that will be used to resolve view-based
| components like single-file and multi-file components. These folders'll
| also be referenced when creating new components via the make command.
|
*/
'component_namespaces' => [
'layouts' => resource_path('views/layouts'),
'pages' => resource_path('views/pages'),
],
/*
|---------------------------------------------------------------------------
| Page Layout
|---------------------------------------------------------------------------
| The view that will be used as the layout when rendering a single component as
| an entire page via `Route::livewire('/post/create', 'pages::create-post')`.
| In this case, the content of pages::create-post will render into $slot.
|
*/
'component_layout' => 'layouts::app.header',
/*
|---------------------------------------------------------------------------
| Lazy Loading Placeholder
|---------------------------------------------------------------------------
| Livewire allows you to lazy load components that would otherwise slow down
| the initial page load. Every component can have a custom placeholder or
| you can define the default placeholder view for all components below.
|
*/
'component_placeholder' => null, // Example: 'placeholders::skeleton'
/*
|---------------------------------------------------------------------------
| Make Command
|---------------------------------------------------------------------------
| This value determines the default configuration for the artisan make command
| You can configure the component type (sfc, mfc, class) and whether to use
| the high-voltage () emoji as a prefix in the sfc|mfc component names.
|
*/
'make_command' => [
'type' => 'sfc', // Options: 'sfc', 'mfc', 'class'
'emoji' => true, // Options: true, false
'with' => [
'js' => false,
'css' => false,
'test' => false,
],
],
/*
|---------------------------------------------------------------------------
| Class Namespace
|---------------------------------------------------------------------------
|
| This value sets the root class namespace for Livewire component classes in
| your application. This value will change where component auto-discovery
| finds components. It's also referenced by the file creation commands.
|
*/
'class_namespace' => 'App\\Livewire',
/*
|---------------------------------------------------------------------------
| Class Path
|---------------------------------------------------------------------------
|
| This value is used to specify the path where Livewire component class files
| are created when running creation commands like `artisan make:livewire`.
| This path is customizable to match your projects directory structure.
|
*/
'class_path' => app_path('Livewire'),
/*
|---------------------------------------------------------------------------
| View Path
|---------------------------------------------------------------------------
|
| This value is used to specify where Livewire component Blade templates are
| stored when running file creation commands like `artisan make:livewire`.
| It is also used if you choose to omit a component's render() method.
|
*/
'view_path' => resource_path('views/livewire'),
/*
|---------------------------------------------------------------------------
| Temporary File Uploads
|---------------------------------------------------------------------------
|
| Livewire handles file uploads by storing uploads in a temporary directory
| before the file is stored permanently. All file uploads are directed to
| a global endpoint for temporary storage. You may configure this below:
|
*/
'temporary_file_upload' => [
'disk' => env('LIVEWIRE_TEMPORARY_FILE_UPLOAD_DISK'), // Example: 'local', 's3' | Default: 'default'
'rules' => null, // Example: ['file', 'mimes:png,jpg'] | Default: ['required', 'file', 'max:12288'] (12MB)
'directory' => null, // Example: 'tmp' | Default: 'livewire-tmp'
'middleware' => null, // Example: 'throttle:5,1' | Default: 'throttle:60,1'
'preview_mimes' => [ // Supported file types for temporary pre-signed file URLs...
'png', 'gif', 'bmp', 'svg', 'wav', 'mp4',
'mov', 'avi', 'wmv', 'mp3', 'm4a',
'jpg', 'jpeg', 'mpga', 'webp', 'wma',
],
'max_upload_time' => 5, // Max duration (in minutes) before an upload is invalidated...
'cleanup' => true, // Should cleanup temporary uploads older than 24 hrs...
],
/*
|---------------------------------------------------------------------------
| Render On Redirect
|---------------------------------------------------------------------------
|
| This value determines if Livewire will run a component's `render()` method
| after a redirect has been triggered using something like `redirect(...)`
| Setting this to true will render the view once more before redirecting
|
*/
'render_on_redirect' => false,
/*
|---------------------------------------------------------------------------
| Eloquent Model Binding
|---------------------------------------------------------------------------
|
| Previous versions of Livewire supported binding directly to eloquent model
| properties using wire:model by default. However, this behavior has been
| deemed too "magical" and has therefore been put under a feature flag.
|
*/
'legacy_model_binding' => false,
/*
|---------------------------------------------------------------------------
| Auto-inject Frontend Assets
|---------------------------------------------------------------------------
|
| By default, Livewire automatically injects its JavaScript and CSS into the
| <head> and <body> of pages containing Livewire components. By disabling
| this behavior, you need to use @livewireStyles and @livewireScripts.
|
*/
'inject_assets' => true,
/*
|---------------------------------------------------------------------------
| Navigate (SPA mode)
|---------------------------------------------------------------------------
|
| By adding `wire:navigate` to links in your Livewire application, Livewire
| will prevent the default link handling and instead request those pages
| via AJAX, creating an SPA-like effect. Configure this behavior here.
|
*/
'navigate' => [
'show_progress_bar' => true,
'progress_bar_color' => '#2299dd',
],
/*
|---------------------------------------------------------------------------
| HTML Morph Markers
|---------------------------------------------------------------------------
|
| Livewire intelligently "morphs" existing HTML into the newly rendered HTML
| after each update. To make this process more reliable, Livewire injects
| "markers" into the rendered Blade surrounding @if, @class & @foreach.
|
*/
'inject_morph_markers' => true,
/*
|---------------------------------------------------------------------------
| Smart Wire Keys
|---------------------------------------------------------------------------
|
| Livewire uses loops and keys used within loops to generate smart keys that
| are applied to nested components that don't have them. This makes using
| nested components more reliable by ensuring that they all have keys.
|
*/
'smart_wire_keys' => true,
/*
|---------------------------------------------------------------------------
| Pagination Theme
|---------------------------------------------------------------------------
|
| When enabling Livewire's pagination feature by using the `WithPagination`
| trait, Livewire will use Tailwind templates to render pagination views
| on the page. If you want Bootstrap CSS, you can specify: "bootstrap"
|
*/
'pagination_theme' => 'tailwind',
/*
|---------------------------------------------------------------------------
| Release Token
|---------------------------------------------------------------------------
|
| This token is stored client-side and sent along with each request to check
| a users session to see if a new release has invalidated it. If there is
| a mismatch it will throw an error and prompt for a browser refresh.
|
*/
'release_token' => 'a',
/*
|---------------------------------------------------------------------------
| CSP Safe
|---------------------------------------------------------------------------
|
| This config is used to determine if Livewire will use the CSP-safe version
| of Alpine in its bundle. This is useful for applications that are using
| strict Content Security Policy (CSP) to protect against XSS attacks.
|
*/
'csp_safe' => false,
/*
|---------------------------------------------------------------------------
| Payload Guards
|---------------------------------------------------------------------------
|
| These settings protect against malicious or oversized payloads that could
| cause denial of service. The default values should feel reasonable for
| most web applications. Each can be set to null to disable the limit.
|
*/
'payload' => [
'max_size' => 1024 * 1024, // 1MB - maximum request payload size in bytes
'max_nesting_depth' => 10, // Maximum depth of dot-notation property paths
'max_calls' => 50, // Maximum method calls per request
'max_components' => 200, // Maximum components per batch request
],
];
@@ -0,0 +1,42 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->text('two_factor_secret')
->after('password')
->nullable();
$table->text('two_factor_recovery_codes')
->after('two_factor_secret')
->nullable();
$table->timestamp('two_factor_confirmed_at')
->after('two_factor_recovery_codes')
->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn([
'two_factor_secret',
'two_factor_recovery_codes',
'two_factor_confirmed_at',
]);
});
}
};
@@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('recordings', function (Blueprint $table) {
$table->foreignId('user_id')
->nullable()
->after('id')
->constrained()
->nullOnDelete();
$table->index('user_id');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('recordings', function (Blueprint $table) {
$table->dropConstrainedForeignId('user_id');
});
}
};
+16 -5
View File
@@ -2,6 +2,7 @@
namespace Database\Seeders; namespace Database\Seeders;
use App\Models\Recording;
use App\Models\User; use App\Models\User;
use Illuminate\Database\Console\Seeds\WithoutModelEvents; use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
@@ -15,11 +16,21 @@ class DatabaseSeeder extends Seeder
*/ */
public function run(): void public function run(): void
{ {
// User::factory(10)->create(); $email = (string) env('SEED_USER_EMAIL', 'demo@example.com');
$name = (string) env('SEED_USER_NAME', 'Demo User');
$password = (string) env('SEED_USER_PASSWORD', 'password');
User::factory()->create([ $user = User::query()->updateOrCreate(
'name' => 'Test User', ['email' => $email],
'email' => 'test@example.com', [
]); 'name' => $name,
'password' => $password,
'email_verified_at' => now(),
],
);
Recording::query()
->whereNull('user_id')
->update(['user_id' => $user->id]);
} }
} }
+58
View File
@@ -0,0 +1,58 @@
# Local development overlay: bind-mount source + Vite HMR.
# Usage:
# docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build -d
# Or set in .env:
# COMPOSE_FILE=docker-compose.yml:docker-compose.dev.yml
services:
app:
environment:
KEEP_VITE_HOT: "true"
volumes:
- .:/app
depends_on:
vite:
condition: service_started
queue:
# Reload PHP between jobs so code mounts take effect without restarting.
command:
- php
- artisan
- queue:listen
- database
- --sleep=1
- --tries=1
- --timeout=${TRANSCRIPTION_TIMEOUT:-600}
environment:
KEEP_VITE_HOT: "true"
volumes:
- .:/app
reverb:
environment:
KEEP_VITE_HOT: "true"
volumes:
- .:/app
vite:
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"
ports:
- "${VITE_HOST_PORT:-5173}:5173"
environment:
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}
VITE_USE_POLLING: "true"
volumes:
- .:/app
- vite-node-modules:/app/node_modules
restart: unless-stopped
volumes:
vite-node-modules:
+3
View File
@@ -33,6 +33,9 @@ x-app-env: &app-env
LOCAL_WHISPER_MODEL: ${LOCAL_WHISPER_MODEL:-Systran/faster-whisper-base} LOCAL_WHISPER_MODEL: ${LOCAL_WHISPER_MODEL:-Systran/faster-whisper-base}
TRANSCRIPTION_TIMEOUT: ${TRANSCRIPTION_TIMEOUT:-600} TRANSCRIPTION_TIMEOUT: ${TRANSCRIPTION_TIMEOUT:-600}
DB_QUEUE_RETRY_AFTER: ${DB_QUEUE_RETRY_AFTER:-660} DB_QUEUE_RETRY_AFTER: ${DB_QUEUE_RETRY_AFTER:-660}
SEED_USER_NAME: ${SEED_USER_NAME:-Demo User}
SEED_USER_EMAIL: ${SEED_USER_EMAIL:-demo@example.com}
SEED_USER_PASSWORD: ${SEED_USER_PASSWORD:-password}
services: services:
app: app:
+11 -2
View File
@@ -17,8 +17,11 @@ if [ ! -f database/database.sqlite ]; then
touch database/database.sqlite touch database/database.sqlite
fi fi
# Never use a host Vite HMR file inside the container image. # Production images should not honor a leftover Vite HMR file from the host.
rm -f public/hot # Development bind mounts keep public/hot so the Vite container can drive assets.
if [ "${KEEP_VITE_HOT:-false}" != "true" ]; then
rm -f public/hot
fi
# Bind mounts may arrive as root-owned; keep the app and host tooling writable. # Bind mounts may arrive as root-owned; keep the app and host tooling writable.
chmod -R a+rwX database storage bootstrap/cache 2>/dev/null || true chmod -R a+rwX database storage bootstrap/cache 2>/dev/null || true
@@ -28,6 +31,12 @@ if [ -z "${APP_KEY:-}" ]; then
exit 1 exit 1
fi fi
# Bind-mounted trees may lack vendor/ (image files are shadowed by the mount).
if [ ! -f vendor/autoload.php ]; then
composer install --prefer-dist --no-interaction
fi
php artisan migrate --force --no-interaction php artisan migrate --force --no-interaction
php artisan db:seed --force --no-interaction
exec "$@" exec "$@"
+4
View File
@@ -4,3 +4,7 @@ post_max_size = 10G
memory_limit = 512M memory_limit = 512M
max_execution_time = 3600 max_execution_time = 3600
max_input_time = 3600 max_input_time = 3600
; Pick up bind-mounted PHP changes without restarting FrankenPHP.
opcache.validate_timestamps = 1
opcache.revalidate_freq = 0
+7
View File
@@ -1,6 +1,8 @@
@import 'tailwindcss'; @import 'tailwindcss';
@import '../../vendor/livewire/flux/dist/flux.css';
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php'; @source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
@source '../../vendor/livewire/flux/stubs/**/*.blade.php';
@source '../../storage/framework/views/*.php'; @source '../../storage/framework/views/*.php';
@source '../views'; @source '../views';
@source '../js'; @source '../js';
@@ -8,4 +10,9 @@
@theme { @theme {
--font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', --font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
'Segoe UI Symbol', 'Noto Color Emoji'; 'Segoe UI Symbol', 'Noto Color Emoji';
--color-accent: var(--color-teal-700);
--color-accent-content: var(--color-teal-700);
--color-accent-foreground: var(--color-white);
} }
@custom-variant dark (&:where(.dark, .dark *));
-4
View File
@@ -1,11 +1,7 @@
import Alpine from 'alpinejs';
import './echo'; import './echo';
import { recordingsIndex, transcriptionMonitor } from './transcription'; import { recordingsIndex, transcriptionMonitor } from './transcription';
import { uploadDropzone } from './upload'; import { uploadDropzone } from './upload';
window.Alpine = Alpine;
window.transcriptionMonitor = transcriptionMonitor; window.transcriptionMonitor = transcriptionMonitor;
window.recordingsIndex = recordingsIndex; window.recordingsIndex = recordingsIndex;
window.uploadDropzone = uploadDropzone; window.uploadDropzone = uploadDropzone;
Alpine.start();
+6
View File
@@ -11,4 +11,10 @@ window.Echo = new Echo({
wssPort: import.meta.env.VITE_REVERB_PORT ?? 443, wssPort: import.meta.env.VITE_REVERB_PORT ?? 443,
forceTLS: (import.meta.env.VITE_REVERB_SCHEME ?? 'https') === 'https', forceTLS: (import.meta.env.VITE_REVERB_SCHEME ?? 'https') === 'https',
enabledTransports: ['ws', 'wss'], enabledTransports: ['ws', 'wss'],
authEndpoint: '/broadcasting/auth',
auth: {
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'),
},
},
}); });
+20 -11
View File
@@ -3,15 +3,15 @@
*/ */
const BADGE_CLASSES = { const BADGE_CLASSES = {
done: 'bg-teal-50 text-teal-800 ring-teal-600/20', done: 'bg-teal-50 text-teal-800 ring-teal-600/20 dark:bg-teal-950 dark:text-teal-200 dark:ring-teal-400/30',
processing: 'bg-amber-50 text-amber-800 ring-amber-600/20', processing: 'bg-amber-50 text-amber-800 ring-amber-600/20 dark:bg-amber-950 dark:text-amber-200 dark:ring-amber-400/30',
pending: 'bg-amber-50 text-amber-800 ring-amber-600/20', pending: 'bg-amber-50 text-amber-800 ring-amber-600/20 dark:bg-amber-950 dark:text-amber-200 dark:ring-amber-400/30',
failed: 'bg-red-50 text-red-800 ring-red-600/20', failed: 'bg-red-50 text-red-800 ring-red-600/20 dark:bg-red-950 dark:text-red-200 dark:ring-red-400/30',
cancelled: 'bg-stone-100 text-stone-700 ring-stone-500/20', cancelled: 'bg-stone-100 text-stone-700 ring-stone-500/20 dark:bg-zinc-800 dark:text-zinc-300 dark:ring-zinc-500/30',
}; };
export function badgeClassFor(status) { export function badgeClassFor(status) {
return BADGE_CLASSES[status] || 'bg-stone-100 text-stone-700 ring-stone-500/20'; return BADGE_CLASSES[status] || 'bg-stone-100 text-stone-700 ring-stone-500/20 dark:bg-zinc-800 dark:text-zinc-300 dark:ring-zinc-500/30';
} }
export function formatElapsed(seconds) { export function formatElapsed(seconds) {
@@ -56,17 +56,26 @@ function formatWordCount(count) {
return n > 0 ? n.toLocaleString() : '—'; return n > 0 ? n.toLocaleString() : '—';
} }
function subscribeToRecordings(handler) { function subscribeToRecording(recordingId, handler) {
if (!window.Echo) { if (!window.Echo) {
return () => {}; return () => {};
} }
const channel = window.Echo.channel('recordings'); const channelName = 'recording.' + recordingId;
const channel = window.Echo.private(channelName);
channel.listen('.RecordingTranscriptionUpdated', handler); channel.listen('.RecordingTranscriptionUpdated', handler);
return () => { return () => {
window.Echo.leave('recordings'); window.Echo.leave(channelName);
};
}
function subscribeToRecordings(recordingIds, handler) {
const leaveFns = recordingIds.map((id) => subscribeToRecording(id, handler));
return () => {
leaveFns.forEach((leave) => leave());
}; };
} }
@@ -94,7 +103,7 @@ export function transcriptionMonitor({ statusUrl, initial }) {
}, },
start() { start() {
this.leaveChannel = subscribeToRecordings((event) => { this.leaveChannel = subscribeToRecording(this.status.id, (event) => {
if (Number(event.id) !== Number(this.status.id)) { if (Number(event.id) !== Number(this.status.id)) {
return; return;
} }
@@ -202,7 +211,7 @@ export function recordingsIndex({ recordings, pendingCount }) {
leaveChannel: null, leaveChannel: null,
start() { start() {
this.leaveChannel = subscribeToRecordings((event) => { this.leaveChannel = subscribeToRecordings(Object.keys(this.rows), (event) => {
this.applyPayload(event); this.applyPayload(event);
}); });
}, },
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 42" {{ $attributes }}>
<path
fill="currentColor"
fill-rule="evenodd"
clip-rule="evenodd"
d="M17.2 5.633 8.6.855 0 5.633v26.51l16.2 9 16.2-9v-8.442l7.6-4.223V9.856l-8.6-4.777-8.6 4.777V18.3l-5.6 3.111V5.633ZM38 18.301l-5.6 3.11v-6.157l5.6-3.11V18.3Zm-1.06-7.856-5.54 3.078-5.54-3.079 5.54-3.078 5.54 3.079ZM24.8 18.3v-6.157l5.6 3.111v6.158L24.8 18.3Zm-1 1.732 5.54 3.078-13.14 7.302-5.54-3.078 13.14-7.3v-.002Zm-16.2 7.89 7.6 4.222V38.3L2 30.966V7.92l5.6 3.111v16.892ZM8.6 9.3 3.06 6.222 8.6 3.143l5.54 3.08L8.6 9.3Zm21.8 15.51-13.2 7.334V38.3l13.2-7.334v-6.156ZM9.6 11.034l5.6-3.11v14.6l-5.6 3.11v-14.6Z"
/>
</svg>

After

Width:  |  Height:  |  Size: 714 B

@@ -0,0 +1,17 @@
@props([
'sidebar' => false,
])
@if($sidebar)
<flux:sidebar.brand :name="config('app.name', 'Laravel')" {{ $attributes }}>
<x-slot name="logo" class="flex aspect-square size-8 items-center justify-center rounded-md bg-accent-content text-accent-foreground">
<x-app-logo-icon class="size-5 fill-current text-white dark:text-black" />
</x-slot>
</flux:sidebar.brand>
@else
<flux:brand :name="config('app.name', 'Laravel')" {{ $attributes }}>
<x-slot name="logo" class="flex aspect-square size-8 items-center justify-center rounded-md bg-accent-content text-accent-foreground">
<x-app-logo-icon class="size-5 fill-current text-white dark:text-black" />
</x-slot>
</flux:brand>
@endif
@@ -0,0 +1,13 @@
<flux:dropdown x-data align="end">
<flux:button variant="subtle" square class="group" aria-label="{{ __('Preferred color scheme') }}">
<flux:icon.sun x-show="$flux.appearance === 'light'" variant="mini" class="text-zinc-500 dark:text-white" />
<flux:icon.moon x-show="$flux.appearance === 'dark'" variant="mini" class="text-zinc-500 dark:text-white" />
<flux:icon.moon x-show="$flux.appearance === 'system' && $flux.dark" variant="mini" class="text-zinc-500 dark:text-white" />
<flux:icon.sun x-show="$flux.appearance === 'system' && ! $flux.dark" variant="mini" class="text-zinc-500 dark:text-white" />
</flux:button>
<flux:menu>
<flux:menu.item icon="sun" x-on:click="$flux.appearance = 'light'">{{ __('Light') }}</flux:menu.item>
<flux:menu.item icon="moon" x-on:click="$flux.appearance = 'dark'">{{ __('Dark') }}</flux:menu.item>
<flux:menu.item icon="computer-desktop" x-on:click="$flux.appearance = 'system'">{{ __('System') }}</flux:menu.item>
</flux:menu>
</flux:dropdown>
@@ -0,0 +1,9 @@
@props([
'title',
'description',
])
<div class="flex w-full flex-col text-center">
<flux:heading size="xl">{{ $title }}</flux:heading>
<flux:subheading>{{ $description }}</flux:subheading>
</div>
@@ -0,0 +1,9 @@
@props([
'status',
])
@if ($status)
<div {{ $attributes->merge(['class' => 'font-medium text-sm text-green-600']) }}>
{{ $status }}
</div>
@endif
@@ -0,0 +1,31 @@
@auth
<flux:dropdown position="bottom" align="end">
<flux:button variant="ghost" class="max-lg:hidden" data-test="user-menu-button">
{{ auth()->user()->name }}
</flux:button>
<flux:button variant="ghost" class="lg:hidden" icon="user" data-test="user-menu-button-mobile" />
<flux:menu>
<div class="flex items-center gap-2 px-1 py-1.5 text-start text-sm">
<flux:avatar :name="auth()->user()->name" :initials="auth()->user()->initials()" />
<div class="grid flex-1 text-start text-sm leading-tight">
<flux:heading class="truncate">{{ auth()->user()->name }}</flux:heading>
<flux:text class="truncate">{{ auth()->user()->email }}</flux:text>
</div>
</div>
<flux:menu.separator />
<form method="POST" action="{{ route('logout') }}" class="w-full">
@csrf
<flux:menu.item
as="button"
type="submit"
icon="arrow-right-start-on-rectangle"
class="w-full cursor-pointer"
data-test="logout-button"
>
{{ __('Log out') }}
</flux:menu.item>
</form>
</flux:menu>
</flux:dropdown>
@endauth
@@ -1,18 +1,18 @@
@php @php
/** @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 class="border-b border-stone-200 bg-white"> <div class="border-b border-stone-200 bg-white dark:border-zinc-700 dark:bg-zinc-800">
<div class="mx-auto max-w-5xl px-4 py-2 sm:px-6"> <div class="mx-auto max-w-5xl px-4 py-2 sm:px-6">
<div class="flex items-center justify-between gap-3 text-xs text-stone-600"> <div class="flex items-center justify-between gap-3 text-xs text-stone-600 dark:text-zinc-400">
<span class="font-medium text-stone-700">Disk space</span> <span class="font-medium text-stone-700 dark:text-zinc-200">Disk space</span>
<span class="tabular-nums"> <span class="tabular-nums">
{{ $disk['free_human'] }} free {{ $disk['free_human'] }} free
<span class="text-stone-400">·</span> <span class="text-stone-400 dark:text-zinc-500">·</span>
{{ $disk['used_percent'] }}% used of {{ $disk['total_human'] }} {{ $disk['used_percent'] }}% used of {{ $disk['total_human'] }}
</span> </span>
</div> </div>
<div <div
class="mt-1.5 h-1.5 overflow-hidden rounded-full bg-stone-200" class="mt-1.5 h-1.5 overflow-hidden rounded-full bg-stone-200 dark:bg-zinc-700"
role="progressbar" role="progressbar"
aria-valuemin="0" aria-valuemin="0"
aria-valuemax="100" aria-valuemax="100"
+55 -19
View File
@@ -1,44 +1,78 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}"> <html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="dark">
<head> <head>
<meta charset="utf-8"> @include('partials.head', ['title' => trim($__env->yieldContent('title')) ?: null])
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>@yield('title', 'Recordings') {{ config('app.name', 'AndyTranscribe') }}</title>
@vite(['resources/css/app.css', 'resources/js/app.js'])
<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"> <body class="min-h-screen bg-stone-100 text-stone-900 antialiased dark:bg-zinc-900 dark:text-zinc-100">
<x-disk-space-bar /> <x-disk-space-bar />
<header class="border-b border-stone-200 bg-white"> <flux:header class="border-b border-stone-200 bg-white dark:border-zinc-700 dark:bg-zinc-800">
<div class="mx-auto flex max-w-5xl items-center justify-between gap-4 px-4 py-4 sm:px-6"> <div class="mx-auto flex w-full max-w-5xl items-center gap-4 px-4 sm:px-6">
<a href="{{ route('recordings.index') }}" class="text-lg font-semibold tracking-tight text-teal-800"> <flux:sidebar.toggle class="lg:hidden" icon="bars-2" inset="left" />
<a href="{{ route('recordings.index') }}" class="text-lg font-semibold tracking-tight text-teal-800 dark:text-teal-300">
AndyTranscribe AndyTranscribe
</a> </a>
<nav class="flex items-center gap-3 text-sm">
<a href="{{ route('recordings.index') }}" class="text-stone-600 hover:text-stone-900">Recordings</a> <flux:navbar class="-mb-px max-lg:hidden">
<a href="{{ route('recordings.create') }}" class="rounded bg-teal-700 px-3 py-1.5 font-medium text-white hover:bg-teal-800"> <flux:navbar.item
Upload :href="route('recordings.index')"
</a> :current="request()->routeIs('recordings.index', 'recordings.show')"
</nav> >
{{ __('Recordings') }}
</flux:navbar.item>
<flux:navbar.item
:href="route('recordings.create')"
:current="request()->routeIs('recordings.create')"
>
{{ __('Upload') }}
</flux:navbar.item>
</flux:navbar>
<flux:spacer />
<x-appearance-toggle />
@auth
<x-desktop-user-menu />
@endauth
</div> </div>
</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.header>
<a href="{{ route('recordings.index') }}" class="text-base font-semibold text-teal-800 dark:text-teal-300">
AndyTranscribe
</a>
<flux:sidebar.collapse />
</flux:sidebar.header>
<flux:sidebar.nav>
<flux:sidebar.item :href="route('recordings.index')" :current="request()->routeIs('recordings.*')">
{{ __('Recordings') }}
</flux:sidebar.item>
<flux:sidebar.item :href="route('recordings.create')" :current="request()->routeIs('recordings.create')">
{{ __('Upload') }}
</flux:sidebar.item>
</flux:sidebar.nav>
</flux:sidebar>
<main class="mx-auto max-w-5xl px-4 py-8 sm:px-6"> <main class="mx-auto max-w-5xl px-4 py-8 sm:px-6">
@if (session('success')) @if (session('success'))
<div class="mb-6 rounded border border-teal-200 bg-teal-50 px-4 py-3 text-sm text-teal-900"> <div class="mb-6 rounded border border-teal-200 bg-teal-50 px-4 py-3 text-sm text-teal-900 dark:border-teal-800 dark:bg-teal-950 dark:text-teal-100">
{{ session('success') }} {{ session('success') }}
</div> </div>
@endif @endif
@if (session('error')) @if (session('error'))
<div class="mb-6 rounded border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-900"> <div class="mb-6 rounded border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-900 dark:border-red-900 dark:bg-red-950 dark:text-red-100">
{{ session('error') }} {{ session('error') }}
</div> </div>
@endif @endif
@if ($errors->any()) @if ($errors->any())
<div class="mb-6 rounded border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-900"> <div class="mb-6 rounded border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-900 dark:border-red-900 dark:bg-red-950 dark:text-red-100">
<ul class="list-disc space-y-1 pl-5"> <ul class="list-disc space-y-1 pl-5">
@foreach ($errors->all() as $error) @foreach ($errors->all() as $error)
<li>{{ $error }}</li> <li>{{ $error }}</li>
@@ -49,5 +83,7 @@
@yield('content') @yield('content')
</main> </main>
@fluxScripts
</body> </body>
</html> </html>
@@ -0,0 +1,29 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="dark">
<head>
@include('partials.head')
<style>[x-cloak]{display:none!important}</style>
</head>
<body class="min-h-screen bg-stone-100 text-stone-900 antialiased dark:bg-zinc-900 dark:text-zinc-100">
<x-disk-space-bar />
<flux:header class="border-b border-stone-200 bg-white dark:border-zinc-700 dark:bg-zinc-800">
<div class="mx-auto flex w-full max-w-5xl items-center gap-4 px-4 sm:px-6">
<a href="{{ route('recordings.index') }}" class="text-lg font-semibold tracking-tight text-teal-800 dark:text-teal-300">
AndyTranscribe
</a>
<flux:spacer />
<x-appearance-toggle />
@auth
<x-desktop-user-menu />
@endauth
</div>
</flux:header>
<main class="mx-auto max-w-5xl px-4 py-8 sm:px-6">
{{ $slot }}
</main>
@fluxScripts
</body>
</html>
+3
View File
@@ -0,0 +1,3 @@
<x-layouts::auth.simple :title="$title ?? null">
{{ $slot }}
</x-layouts::auth.simple>
@@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="dark">
<head>
@include('partials.head')
</head>
<body class="min-h-screen bg-neutral-100 antialiased dark:bg-linear-to-b dark:from-neutral-950 dark:to-neutral-900">
<div class="bg-muted flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
<div class="flex w-full max-w-md flex-col gap-6">
<a href="{{ route('home') }}" class="flex flex-col items-center gap-2 font-medium" wire:navigate>
<span class="flex h-9 w-9 items-center justify-center rounded-md">
<x-app-logo-icon class="size-9 fill-current text-black dark:text-white" />
</span>
<span class="sr-only">{{ config('app.name', 'Laravel') }}</span>
</a>
<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="px-10 py-8">{{ $slot }}</div>
</div>
</div>
</div>
</div>
@persist('toast')
<flux:toast.group>
<flux:toast />
</flux:toast.group>
@endpersist
@fluxScripts
</body>
</html>
@@ -0,0 +1,27 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="dark">
<head>
@include('partials.head')
<style>[x-cloak]{display:none!important}</style>
</head>
<body class="min-h-screen bg-stone-100 text-stone-900 antialiased dark:bg-zinc-900 dark:text-zinc-100">
<div class="relative flex min-h-svh flex-col items-center justify-center gap-6 bg-stone-100 p-6 md:p-10 dark:bg-zinc-900">
<div class="absolute end-4 top-4">
<x-appearance-toggle />
</div>
<div class="flex w-full max-w-sm flex-col gap-2">
<a href="{{ url('/') }}" class="mb-1 flex flex-col items-center gap-2 font-medium">
<span class="flex h-9 w-9 items-center justify-center rounded-md bg-teal-700 text-sm font-semibold text-white dark:bg-teal-600">
AT
</span>
<span class="text-lg font-semibold tracking-tight text-teal-800 dark:text-teal-300">{{ config('app.name', 'AndyTranscribe') }}</span>
</a>
<div class="flex flex-col gap-6">
{{ $slot }}
</div>
</div>
</div>
@fluxScripts
</body>
</html>
@@ -0,0 +1,50 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="dark">
<head>
@include('partials.head')
</head>
<body class="min-h-screen bg-white antialiased dark:bg-linear-to-b dark:from-neutral-950 dark:to-neutral-900">
<div class="relative grid h-dvh flex-col items-center justify-center px-8 sm:px-0 lg:max-w-none lg:grid-cols-2 lg:px-0">
<div class="bg-muted relative hidden h-full flex-col p-10 text-white lg:flex dark:border-e dark:border-neutral-800">
<div class="absolute inset-0 bg-neutral-900"></div>
<a href="{{ route('home') }}" class="relative z-20 flex items-center text-lg font-medium" wire:navigate>
<span class="flex h-10 w-10 items-center justify-center rounded-md">
<x-app-logo-icon class="me-2 h-7 fill-current text-white" />
</span>
{{ config('app.name', 'Laravel') }}
</a>
@php
[$message, $author] = str(Illuminate\Foundation\Inspiring::quotes()->random())->explode('-');
@endphp
<div class="relative z-20 mt-auto">
<blockquote class="space-y-2">
<flux:heading size="lg">&ldquo;{{ trim($message) }}&rdquo;</flux:heading>
<footer><flux:heading>{{ trim($author) }}</flux:heading></footer>
</blockquote>
</div>
</div>
<div class="w-full lg:p-8">
<div class="mx-auto flex w-full flex-col justify-center space-y-6 sm:w-[350px]">
<a href="{{ route('home') }}" class="z-20 flex flex-col items-center gap-2 font-medium lg:hidden" wire:navigate>
<span class="flex h-9 w-9 items-center justify-center rounded-md">
<x-app-logo-icon class="size-9 fill-current text-black dark:text-white" />
</span>
<span class="sr-only">{{ config('app.name', 'Laravel') }}</span>
</a>
{{ $slot }}
</div>
</div>
</div>
@persist('toast')
<flux:toast.group>
<flux:toast />
</flux:toast.group>
@endpersist
@fluxScripts
</body>
</html>
@@ -0,0 +1,38 @@
<x-layouts::auth :title="__('Confirm password')">
<div class="flex flex-col gap-6">
<x-auth-header
:title="__('Confirm password')"
:description="__('This is a secure area of the application. Please confirm your password before continuing.')"
/>
<x-auth-session-status class="text-center" :status="session('status')" />
{{-- @chisel-passkeys --}}
<x-passkey-verify
options-route="passkey.confirm-options"
submit-route="passkey.confirm"
:label="__('Confirm with passkey')"
:loading-label="__('Confirming...')"
:separator="__('Or confirm with password')"
/>
{{-- @end-chisel-passkeys --}}
<form method="POST" action="{{ route('password.confirm.store') }}" class="flex flex-col gap-6">
@csrf
<flux:input
name="password"
:label="__('Password')"
type="password"
required
autocomplete="current-password"
:placeholder="__('Password')"
viewable
/>
<flux:button variant="primary" type="submit" class="w-full" data-test="confirm-password-button">
{{ __('Confirm') }}
</flux:button>
</form>
</div>
</x-layouts::auth>
@@ -0,0 +1,31 @@
<x-layouts::auth :title="__('Forgot password')">
<div class="flex flex-col gap-6">
<x-auth-header :title="__('Forgot password')" :description="__('Enter your email to receive a password reset link')" />
<!-- Session Status -->
<x-auth-session-status class="text-center" :status="session('status')" />
<form method="POST" action="{{ route('password.email') }}" class="flex flex-col gap-6">
@csrf
<!-- Email Address -->
<flux:input
name="email"
:label="__('Email address')"
type="email"
required
autofocus
placeholder="email@example.com"
/>
<flux:button variant="primary" type="submit" class="w-full" data-test="email-password-reset-link-button">
{{ __('Email password reset link') }}
</flux:button>
</form>
<div class="space-x-1 rtl:space-x-reverse text-center text-sm text-zinc-400">
<span>{{ __('Or, return to') }}</span>
<flux:link :href="route('login')" wire:navigate>{{ __('log in') }}</flux:link>
</div>
</div>
</x-layouts::auth>
@@ -0,0 +1,53 @@
<x-layouts::auth :title="__('Log in')">
<div class="flex flex-col gap-6">
<x-auth-header :title="__('Log in to your account')" :description="__('Enter your email and password below to log in')" />
<x-auth-session-status class="text-center" :status="session('status')" />
<form method="POST" action="{{ route('login.store') }}" class="flex flex-col gap-6">
@csrf
<flux:input
name="email"
:label="__('Email address')"
:value="old('email')"
type="email"
required
autofocus
autocomplete="email"
placeholder="email@example.com"
/>
<div class="relative">
<flux:input
name="password"
:label="__('Password')"
type="password"
required
autocomplete="current-password"
:placeholder="__('Password')"
viewable
/>
@if (Route::has('password.request'))
<flux:link class="absolute top-0 text-sm end-0" :href="route('password.request')" wire:navigate>
{{ __('Forgot your password?') }}
</flux:link>
@endif
</div>
<flux:checkbox name="remember" :label="__('Remember me')" :checked="old('remember')" />
<div class="flex items-center justify-end">
<flux:button variant="primary" type="submit" class="w-full" data-test="login-button">
{{ __('Log in') }}
</flux:button>
</div>
</form>
<div class="space-x-1 text-center text-sm text-zinc-600 rtl:space-x-reverse dark:text-zinc-400">
<span>{{ __('Don\'t have an account?') }}</span>
<flux:link :href="route('register')" wire:navigate>{{ __('Sign up') }}</flux:link>
</div>
</div>
</x-layouts::auth>
@@ -0,0 +1,69 @@
<x-layouts::auth :title="__('Register')">
<div class="flex flex-col gap-6">
<x-auth-header :title="__('Create an account')" :description="__('Enter your details below to create your account')" />
<!-- Session Status -->
<x-auth-session-status class="text-center" :status="session('status')" />
<form method="POST" action="{{ route('register.store') }}" class="flex flex-col gap-6">
@csrf
<!-- Name -->
<flux:input
name="name"
:label="__('Name')"
:value="old('name')"
type="text"
required
autofocus
autocomplete="name"
:placeholder="__('Full name')"
/>
<!-- Email Address -->
<flux:input
name="email"
:label="__('Email address')"
:value="old('email')"
type="email"
required
autocomplete="email"
placeholder="email@example.com"
/>
<!-- Password -->
<flux:input
name="password"
:label="__('Password')"
type="password"
required
autocomplete="new-password"
:placeholder="__('Password')"
passwordrules="{{ \Illuminate\Validation\Rules\Password::defaults()->toPasswordRulesString() }}"
viewable
/>
<!-- Confirm Password -->
<flux:input
name="password_confirmation"
:label="__('Confirm password')"
type="password"
required
autocomplete="new-password"
:placeholder="__('Confirm password')"
passwordrules="{{ \Illuminate\Validation\Rules\Password::defaults()->toPasswordRulesString() }}"
viewable
/>
<div class="flex items-center justify-end">
<flux:button type="submit" variant="primary" class="w-full" data-test="register-user-button">
{{ __('Create account') }}
</flux:button>
</div>
</form>
<div class="space-x-1 rtl:space-x-reverse text-center text-sm text-zinc-600 dark:text-zinc-400">
<span>{{ __('Already have an account?') }}</span>
<flux:link :href="route('login')" wire:navigate>{{ __('Log in') }}</flux:link>
</div>
</div>
</x-layouts::auth>
@@ -0,0 +1,54 @@
<x-layouts::auth :title="__('Reset password')">
<div class="flex flex-col gap-6">
<x-auth-header :title="__('Reset password')" :description="__('Please enter your new password below')" />
<!-- Session Status -->
<x-auth-session-status class="text-center" :status="session('status')" />
<form method="POST" action="{{ route('password.update') }}" class="flex flex-col gap-6">
@csrf
<!-- Token -->
<input type="hidden" name="token" value="{{ request()->route('token') }}">
<!-- Email Address -->
<flux:input
name="email"
value="{{ request('email') }}"
:label="__('Email')"
type="email"
required
autocomplete="email"
/>
<!-- Password -->
<flux:input
name="password"
:label="__('Password')"
type="password"
required
autocomplete="new-password"
:placeholder="__('Password')"
passwordrules="{{ \Illuminate\Validation\Rules\Password::defaults()->toPasswordRulesString() }}"
viewable
/>
<!-- Confirm Password -->
<flux:input
name="password_confirmation"
:label="__('Confirm password')"
type="password"
required
autocomplete="new-password"
:placeholder="__('Confirm password')"
passwordrules="{{ \Illuminate\Validation\Rules\Password::defaults()->toPasswordRulesString() }}"
viewable
/>
<div class="flex items-center justify-end">
<flux:button type="submit" variant="primary" class="w-full" data-test="reset-password-button">
{{ __('Reset password') }}
</flux:button>
</div>
</form>
</div>
</x-layouts::auth>
@@ -0,0 +1,101 @@
<x-layouts::auth :title="__('Two-factor authentication')">
<div class="flex flex-col gap-6">
<div
class="relative w-full h-auto"
x-cloak
x-data="{
showRecoveryInput: @js($errors->has('recovery_code')),
code: '',
recovery_code: '',
focusOtp() {
this.$nextTick(() => this.$refs.otp?.querySelector('input')?.focus());
},
init() {
if (! this.showRecoveryInput) {
this.focusOtp();
}
},
toggleInput() {
this.showRecoveryInput = !this.showRecoveryInput;
this.code = '';
this.recovery_code = '';
$nextTick(() => {
this.showRecoveryInput
? this.$refs.recovery_code?.focus()
: this.focusOtp();
});
},
}"
>
<div x-show="!showRecoveryInput">
<x-auth-header
:title="__('Authentication code')"
:description="__('Enter the authentication code provided by your authenticator application.')"
/>
</div>
<div x-show="showRecoveryInput">
<x-auth-header
:title="__('Recovery code')"
:description="__('Please confirm access to your account by entering one of your emergency recovery codes.')"
/>
</div>
<form method="POST" action="{{ route('two-factor.login.store') }}">
@csrf
<div class="space-y-5 text-center">
<div x-show="!showRecoveryInput">
<div class="flex items-center justify-center my-5" x-ref="otp">
<flux:otp
x-model="code"
length="6"
name="code"
label="OTP Code"
label:sr-only
class="mx-auto"
/>
</div>
</div>
<div x-show="showRecoveryInput">
<div class="my-5">
<flux:input
type="text"
name="recovery_code"
x-ref="recovery_code"
x-bind:required="showRecoveryInput"
autocomplete="one-time-code"
x-model="recovery_code"
/>
</div>
@error('recovery_code')
<flux:text color="red">
{{ $message }}
</flux:text>
@enderror
</div>
<flux:button
variant="primary"
type="submit"
class="w-full"
>
{{ __('Continue') }}
</flux:button>
</div>
<div class="mt-5 space-x-0.5 text-sm leading-5 text-center">
<span class="opacity-50">{{ __('or you can') }}</span>
<div class="inline font-medium underline cursor-pointer opacity-80">
<span x-show="!showRecoveryInput" @click="toggleInput()">{{ __('login using a recovery code') }}</span>
<span x-show="showRecoveryInput" @click="toggleInput()">{{ __('login using an authentication code') }}</span>
</div>
</div>
</form>
</div>
</div>
</x-layouts::auth>
@@ -0,0 +1,29 @@
<x-layouts::auth :title="__('Email verification')">
<div class="mt-4 flex flex-col gap-6">
<flux:text class="text-center">
{{ __('Please verify your email address by clicking on the link we just emailed to you.') }}
</flux:text>
@if (session('status') == 'verification-link-sent')
<flux:text class="text-center font-medium !dark:text-green-400 !text-green-600">
{{ __('A new verification link has been sent to the email address you provided during registration.') }}
</flux:text>
@endif
<div class="flex flex-col items-center justify-between space-y-3">
<form method="POST" action="{{ route('verification.send') }}">
@csrf
<flux:button type="submit" variant="primary" class="w-full">
{{ __('Resend verification email') }}
</flux:button>
</form>
<form method="POST" action="{{ route('logout') }}">
@csrf
<flux:button variant="ghost" type="submit" class="text-sm cursor-pointer" data-test="logout-button">
{{ __('Log out') }}
</flux:button>
</form>
</div>
</div>
</x-layouts::auth>
+12
View File
@@ -0,0 +1,12 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>
{{ filled($title ?? null) ? $title.' — '.config('app.name', 'AndyTranscribe') : config('app.name', 'AndyTranscribe') }}
</title>
<link rel="icon" href="/favicon.ico" sizes="any">
@vite(['resources/css/app.css', 'resources/js/app.js'])
@fluxAppearance
+18 -18
View File
@@ -5,7 +5,7 @@
@section('content') @section('content')
<div class="mb-8"> <div class="mb-8">
<h1 class="text-2xl font-semibold tracking-tight">Upload recordings</h1> <h1 class="text-2xl font-semibold tracking-tight">Upload recordings</h1>
<p class="mt-1 text-sm text-stone-600"> <p class="mt-1 text-sm text-stone-600 dark:text-zinc-400">
Drop one or many pocket-recorder files. Embedded metadata is extracted when available. Drop one or many pocket-recorder files. Embedded metadata is extracted when available.
Duplicate files (same name and size, or identical content) are skipped. Duplicate files (same name and size, or identical content) are skipped.
</p> </p>
@@ -17,12 +17,12 @@
enctype="multipart/form-data" enctype="multipart/form-data"
x-data="uploadDropzone(@js(['existingFingerprints' => $existingFingerprints ?? []]))" x-data="uploadDropzone(@js(['existingFingerprints' => $existingFingerprints ?? []]))"
@submit="ensureFilesSelected($event)" @submit="ensureFilesSelected($event)"
class="max-w-2xl space-y-6 rounded border border-stone-200 bg-white p-6 shadow-sm" class="max-w-2xl space-y-6 rounded border border-stone-200 bg-white p-6 shadow-sm dark:border-zinc-700 dark:bg-zinc-800"
> >
@csrf @csrf
<div> <div>
<label class="block text-sm font-medium text-stone-700">Audio files</label> <label class="block text-sm font-medium text-stone-700 dark:text-zinc-200">Audio files</label>
<div <div
@dragenter.prevent="dragging = true" @dragenter.prevent="dragging = true"
@@ -30,12 +30,12 @@
@dragleave.prevent="dragging = false" @dragleave.prevent="dragging = false"
@drop.prevent="onDrop($event)" @drop.prevent="onDrop($event)"
@click="$refs.fileInput.click()" @click="$refs.fileInput.click()"
:class="dragging ? 'border-teal-600 bg-teal-50' : 'border-stone-300 bg-stone-50 hover:border-teal-500 hover:bg-teal-50/40'" :class="dragging ? 'border-teal-600 bg-teal-50 dark:bg-teal-950/40' : 'border-stone-300 bg-stone-50 hover:border-teal-500 hover:bg-teal-50/40 dark:border-zinc-600 dark:bg-zinc-900/50 dark:hover:border-teal-500 dark:hover:bg-teal-950/30'"
class="mt-2 flex cursor-pointer flex-col items-center justify-center rounded border-2 border-dashed px-6 py-12 text-center transition-colors" class="mt-2 flex cursor-pointer flex-col items-center justify-center rounded border-2 border-dashed px-6 py-12 text-center transition-colors"
> >
<p class="text-sm font-medium text-stone-800">Drop audio files here</p> <p class="text-sm font-medium text-stone-800 dark:text-zinc-100">Drop audio files here</p>
<p class="mt-1 text-sm text-stone-600">or click to browse</p> <p class="mt-1 text-sm text-stone-600 dark:text-zinc-400">or click to browse</p>
<p class="mt-3 text-xs text-stone-500">MP3, WAV, OGG, FLAC, M4A, AAC, WebM, WMA, or AIFF · max 2 GB each · up to 50 files</p> <p class="mt-3 text-xs text-stone-500 dark:text-zinc-500">MP3, WAV, OGG, FLAC, M4A, AAC, WebM, WMA, or AIFF · max 2 GB each · up to 50 files</p>
</div> </div>
<input <input
@@ -52,26 +52,26 @@
<div x-show="files.length > 0" x-cloak class="space-y-2"> <div x-show="files.length > 0" x-cloak class="space-y-2">
<div class="flex items-center justify-between gap-3"> <div class="flex items-center justify-between gap-3">
<p class="text-sm font-medium text-stone-700"> <p class="text-sm font-medium text-stone-700 dark:text-zinc-200">
<span x-text="files.length"></span> <span x-text="files.length"></span>
<span x-text="files.length === 1 ? 'file selected' : 'files selected'"></span> <span x-text="files.length === 1 ? 'file selected' : 'files selected'"></span>
</p> </p>
<button type="button" @click="clearFiles()" class="text-sm text-stone-600 hover:underline"> <button type="button" @click="clearFiles()" class="text-sm text-stone-600 dark:text-zinc-400 hover:underline dark:text-zinc-400">
Clear all Clear all
</button> </button>
</div> </div>
<ul class="divide-y divide-stone-100 rounded border border-stone-200"> <ul class="divide-y divide-stone-100 rounded border border-stone-200 dark:divide-zinc-700 dark:border-zinc-700">
<template x-for="(file, index) in files" :key="fileListKey(file, index)"> <template x-for="(file, index) in files" :key="fileListKey(file, index)">
<li class="flex items-center justify-between gap-3 px-3 py-2 text-sm"> <li class="flex items-center justify-between gap-3 px-3 py-2 text-sm">
<div class="min-w-0"> <div class="min-w-0">
<p class="truncate font-medium text-stone-800" x-text="file.name"></p> <p class="truncate font-medium text-stone-800 dark:text-zinc-100" x-text="file.name"></p>
<p class="text-xs text-stone-500" x-text="formatSize(file.size)"></p> <p class="text-xs text-stone-500 dark:text-zinc-400" x-text="formatSize(file.size)"></p>
</div> </div>
<button <button
type="button" type="button"
@click="removeFile(index)" @click="removeFile(index)"
class="shrink-0 text-stone-500 hover:text-red-700" class="shrink-0 text-stone-500 hover:text-red-700 dark:text-zinc-400 dark:hover:text-red-400"
> >
Remove Remove
</button> </button>
@@ -81,19 +81,19 @@
</div> </div>
<div x-show="files.length === 1" x-cloak> <div x-show="files.length === 1" x-cloak>
<label for="title" class="block text-sm font-medium text-stone-700">Title (optional)</label> <label for="title" class="block text-sm font-medium text-stone-700 dark:text-zinc-200">Title (optional)</label>
<input <input
id="title" id="title"
type="text" type="text"
name="title" name="title"
value="{{ old('title') }}" value="{{ old('title') }}"
placeholder="Leave blank to use embedded title or filename" placeholder="Leave blank to use embedded title or filename"
class="mt-2 w-full rounded border border-stone-300 px-3 py-2 text-sm shadow-sm focus:border-teal-600 focus:outline-none focus:ring-1 focus:ring-teal-600" class="mt-2 w-full rounded border border-stone-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-teal-600 focus:outline-none focus:ring-1 focus:ring-teal-600 dark:border-zinc-600 dark:bg-zinc-900 dark:text-zinc-100"
> >
</div> </div>
<p x-show="notice" x-cloak class="text-sm text-amber-800" x-text="notice"></p> <p x-show="notice" x-cloak class="text-sm text-amber-800 dark:text-amber-300" x-text="notice"></p>
<p x-show="error" x-cloak class="text-sm text-red-700" x-text="error"></p> <p x-show="error" x-cloak class="text-sm text-red-700 dark:text-red-300" x-text="error"></p>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<button <button
@@ -103,7 +103,7 @@
> >
<span x-text="uploadLabel"></span> <span x-text="uploadLabel"></span>
</button> </button>
<a href="{{ route('recordings.index') }}" class="text-sm text-stone-600 hover:underline">Cancel</a> <a href="{{ route('recordings.index') }}" class="text-sm text-stone-600 dark:text-zinc-400 hover:underline dark:text-zinc-400">Cancel</a>
</div> </div>
</form> </form>
@endsection @endsection
+25 -25
View File
@@ -17,12 +17,12 @@
? number_format($recording->word_count) ? number_format($recording->word_count)
: '—', : '—',
'badge_class' => match ($recording->transcription_status) { 'badge_class' => match ($recording->transcription_status) {
'done' => 'bg-teal-50 text-teal-800 ring-teal-600/20', 'done' => 'bg-teal-50 text-teal-800 ring-teal-600/20 dark:bg-teal-950 dark:text-teal-200 dark:ring-teal-400/30',
'processing' => 'bg-amber-50 text-amber-800 ring-amber-600/20', 'processing' => 'bg-amber-50 text-amber-800 ring-amber-600/20 dark:bg-amber-950 dark:text-amber-200 dark:ring-amber-400/30',
'pending' => 'bg-amber-50 text-amber-800 ring-amber-600/20', 'pending' => 'bg-amber-50 text-amber-800 ring-amber-600/20 dark:bg-amber-950 dark:text-amber-200 dark:ring-amber-400/30',
'failed' => 'bg-red-50 text-red-800 ring-red-600/20', 'failed' => 'bg-red-50 text-red-800 ring-red-600/20 dark:bg-red-950 dark:text-red-200 dark:ring-red-400/30',
'cancelled' => 'bg-stone-100 text-stone-700 ring-stone-500/20', 'cancelled' => 'bg-stone-100 text-stone-700 ring-stone-500/20 dark:bg-zinc-800 dark:text-zinc-300 dark:ring-zinc-500/30',
default => 'bg-stone-100 text-stone-700 ring-stone-500/20', default => 'bg-stone-100 text-stone-700 ring-stone-500/20 dark:bg-zinc-800 dark:text-zinc-300 dark:ring-zinc-500/30',
}, },
]; ];
})->values(); })->values();
@@ -41,7 +41,7 @@
<div class="mb-8 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between"> <div class="mb-8 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div> <div>
<h1 class="text-2xl font-semibold tracking-tight">Recordings</h1> <h1 class="text-2xl font-semibold tracking-tight">Recordings</h1>
<p class="mt-1 text-sm text-stone-600">Manage pocket-recorder audio and transcripts.</p> <p class="mt-1 text-sm text-stone-600 dark:text-zinc-400">Manage pocket-recorder audio and transcripts.</p>
</div> </div>
<div class="flex flex-col gap-2 sm:items-end"> <div class="flex flex-col gap-2 sm:items-end">
<form method="GET" action="{{ route('recordings.index') }}" class="flex gap-2"> <form method="GET" action="{{ route('recordings.index') }}" class="flex gap-2">
@@ -50,9 +50,9 @@
name="q" name="q"
value="{{ $search ?? '' }}" value="{{ $search ?? '' }}"
placeholder="Search title, artist, transcript…" placeholder="Search title, artist, transcript…"
class="w-full min-w-[16rem] rounded border border-stone-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-teal-600 focus:outline-none focus:ring-1 focus:ring-teal-600" class="w-full min-w-[16rem] rounded border border-stone-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-teal-600 focus:outline-none focus:ring-1 focus:ring-teal-600 dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-100 dark:placeholder:text-zinc-500"
> >
<button type="submit" class="rounded border border-stone-300 bg-white px-3 py-2 text-sm hover:bg-stone-50"> <button type="submit" class="rounded border border-stone-300 bg-white px-3 py-2 text-sm hover:bg-stone-50 dark:border-zinc-600 dark:bg-zinc-800 dark:hover:bg-zinc-700">
Search Search
</button> </button>
</form> </form>
@@ -63,7 +63,7 @@
x-cloak x-cloak
> >
@csrf @csrf
<button type="submit" class="text-sm font-medium text-teal-700 hover:underline"> <button type="submit" class="text-sm font-medium text-teal-700 hover:underline dark:text-teal-300">
Queue <span x-text="pendingCount"></span> pending Queue <span x-text="pendingCount"></span> pending
<span x-text="pendingCount === 1 ? 'transcription' : 'transcriptions'"></span> <span x-text="pendingCount === 1 ? 'transcription' : 'transcriptions'"></span>
</button> </button>
@@ -72,16 +72,16 @@
</div> </div>
@if ($recordings->isEmpty()) @if ($recordings->isEmpty())
<div class="rounded border border-dashed border-stone-300 bg-white px-6 py-16 text-center"> <div class="rounded border border-dashed border-stone-300 bg-white px-6 py-16 text-center dark:border-zinc-600 dark:bg-zinc-800">
<p class="text-stone-600">No recordings yet.</p> <p class="text-stone-600 dark:text-zinc-400">No recordings yet.</p>
<a href="{{ route('recordings.create') }}" class="mt-4 inline-block text-sm font-medium text-teal-700 hover:underline"> <a href="{{ route('recordings.create') }}" class="mt-4 inline-block text-sm font-medium text-teal-700 hover:underline dark:text-teal-300">
Upload your first MP3 Upload your first MP3
</a> </a>
</div> </div>
@else @else
<div class="overflow-hidden rounded border border-stone-200 bg-white shadow-sm"> <div class="overflow-hidden rounded border border-stone-200 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-800">
<table class="min-w-full divide-y divide-stone-200 text-sm"> <table class="min-w-full divide-y divide-stone-200 text-sm dark:divide-zinc-700">
<thead class="bg-stone-50 text-left text-xs font-medium uppercase tracking-wide text-stone-500"> <thead class="bg-stone-50 text-left text-xs font-medium uppercase tracking-wide text-stone-500 dark:bg-zinc-900/50 dark:text-zinc-400">
<tr> <tr>
<th class="px-4 py-3">Title</th> <th class="px-4 py-3">Title</th>
<th class="px-4 py-3">Duration</th> <th class="px-4 py-3">Duration</th>
@@ -90,25 +90,25 @@
<th class="px-4 py-3">Uploaded</th> <th class="px-4 py-3">Uploaded</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-stone-100"> <tbody class="divide-y divide-stone-100 dark:divide-zinc-700">
@foreach ($recordings as $recording) @foreach ($recordings as $recording)
<tr class="hover:bg-stone-50"> <tr class="hover:bg-stone-50 dark:hover:bg-zinc-700/50">
<td class="px-4 py-3"> <td class="px-4 py-3">
<a href="{{ route('recordings.show', $recording) }}" class="font-medium text-teal-800 hover:underline"> <a href="{{ route('recordings.show', $recording) }}" class="font-medium text-teal-800 hover:underline dark:text-teal-300">
{{ $recording->title }} {{ $recording->title }}
</a> </a>
@if ($recording->artist) @if ($recording->artist)
<div class="text-xs text-stone-500">{{ $recording->artist }}</div> <div class="text-xs text-stone-500 dark:text-zinc-400">{{ $recording->artist }}</div>
@endif @endif
@if ($snippet = $recording->transcriptSnippet($search ?: null)) @if ($snippet = $recording->transcriptSnippet($search ?: null))
<p class="mt-1 max-w-xl text-xs leading-relaxed text-stone-500"> <p class="mt-1 max-w-xl text-xs leading-relaxed text-stone-500 dark:text-zinc-400">
{{ $snippet }} {{ $snippet }}
</p> </p>
@endif @endif
</td> </td>
<td class="px-4 py-3 text-stone-600">{{ $recording->duration_formatted }}</td> <td class="px-4 py-3 text-stone-600 dark:text-zinc-400">{{ $recording->duration_formatted }}</td>
<td <td
class="px-4 py-3 tabular-nums text-stone-600" class="px-4 py-3 tabular-nums text-stone-600 dark:text-zinc-400"
x-text="row({{ $recording->id }}).word_count_display" 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) : '—' }}
@@ -122,7 +122,7 @@
{{ $recording->transcriptionStatusLabel() }} {{ $recording->transcriptionStatusLabel() }}
</span> </span>
<div <div
class="mt-1 max-w-[14rem] truncate text-xs text-amber-700" class="mt-1 max-w-[14rem] truncate text-xs text-amber-700 dark:text-amber-300"
x-show="row({{ $recording->id }}).is_active && row({{ $recording->id }}).progress" x-show="row({{ $recording->id }}).is_active && row({{ $recording->id }}).progress"
x-cloak x-cloak
:title="row({{ $recording->id }}).progress" :title="row({{ $recording->id }}).progress"
@@ -131,7 +131,7 @@
<span x-text="row({{ $recording->id }}).progress"></span> <span x-text="row({{ $recording->id }}).progress"></span>
</div> </div>
</td> </td>
<td class="px-4 py-3 text-stone-600">{{ $recording->created_at?->format('Y-m-d H:i') }}</td> <td class="px-4 py-3 text-stone-600 dark:text-zinc-400">{{ $recording->created_at?->format('Y-m-d H:i') }}</td>
</tr> </tr>
@endforeach @endforeach
</tbody> </tbody>
@@ -8,12 +8,12 @@
default => (string) $status, default => (string) $status,
}; };
$classes = match ($status) { $classes = match ($status) {
'done' => 'bg-teal-50 text-teal-800 ring-teal-600/20', 'done' => 'bg-teal-50 text-teal-800 ring-teal-600/20 dark:bg-teal-950 dark:text-teal-200 dark:ring-teal-400/30',
'processing' => 'bg-amber-50 text-amber-800 ring-amber-600/20', 'processing' => 'bg-amber-50 text-amber-800 ring-amber-600/20 dark:bg-amber-950 dark:text-amber-200 dark:ring-amber-400/30',
'pending' => 'bg-amber-50 text-amber-800 ring-amber-600/20', 'pending' => 'bg-amber-50 text-amber-800 ring-amber-600/20 dark:bg-amber-950 dark:text-amber-200 dark:ring-amber-400/30',
'failed' => 'bg-red-50 text-red-800 ring-red-600/20', 'failed' => 'bg-red-50 text-red-800 ring-red-600/20 dark:bg-red-950 dark:text-red-200 dark:ring-red-400/30',
'cancelled' => 'bg-stone-100 text-stone-700 ring-stone-500/20', 'cancelled' => 'bg-stone-100 text-stone-700 ring-stone-500/20 dark:bg-zinc-800 dark:text-zinc-300 dark:ring-zinc-500/30',
default => 'bg-stone-100 text-stone-700 ring-stone-500/20', default => 'bg-stone-100 text-stone-700 ring-stone-500/20 dark:bg-zinc-800 dark:text-zinc-300 dark:ring-zinc-500/30',
}; };
@endphp @endphp
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ring-1 ring-inset {{ $classes }}"> <span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ring-1 ring-inset {{ $classes }}">
+33 -33
View File
@@ -15,70 +15,70 @@
> >
<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>
<a href="{{ route('recordings.index') }}" class="text-sm text-stone-500 hover:text-stone-800"> Recordings</a> <a href="{{ route('recordings.index') }}" class="text-sm text-stone-500 hover:text-stone-800 dark:text-zinc-400 dark:hover:text-zinc-200"> Recordings</a>
<h1 class="mt-2 text-2xl font-semibold tracking-tight">{{ $recording->title }}</h1> <h1 class="mt-2 text-2xl font-semibold tracking-tight">{{ $recording->title }}</h1>
<div class="mt-2 flex flex-wrap items-center gap-2 text-sm text-stone-600"> <div class="mt-2 flex flex-wrap items-center gap-2 text-sm text-stone-600 dark:text-zinc-400">
<span <span
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ring-1 ring-inset" class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ring-1 ring-inset"
:class="badgeClass" :class="badgeClass"
x-text="status.status_label || status.status" x-text="status.status_label || status.status"
></span> ></span>
<template x-if="status.driver_label"> <template x-if="status.driver_label">
<span class="text-xs text-stone-500" x-text="status.driver_label"></span> <span class="text-xs text-stone-500 dark:text-zinc-400" x-text="status.driver_label"></span>
</template> </template>
</div> </div>
</div> </div>
<form method="POST" action="{{ route('recordings.destroy', $recording) }}" onsubmit="return confirm('Delete this recording and its file?')"> <form method="POST" action="{{ route('recordings.destroy', $recording) }}" onsubmit="return confirm('Delete this recording and its file?')">
@csrf @csrf
@method('DELETE') @method('DELETE')
<button type="submit" class="rounded border border-red-200 bg-white px-3 py-1.5 text-sm text-red-700 hover:bg-red-50"> <button type="submit" class="rounded border border-red-200 bg-white px-3 py-1.5 text-sm text-red-700 hover:bg-red-50 dark:border-red-900 dark:bg-zinc-800 dark:text-red-300 dark:hover:bg-red-950">
Delete Delete
</button> </button>
</form> </form>
</div> </div>
<div class="grid gap-6 lg:grid-cols-2"> <div class="grid gap-6 lg:grid-cols-2">
<section class="rounded border border-stone-200 bg-white p-6 shadow-sm"> <section class="rounded border border-stone-200 bg-white p-6 shadow-sm dark:border-zinc-700 dark:bg-zinc-800">
<h2 class="text-sm font-semibold uppercase tracking-wide text-stone-500">Metadata</h2> <h2 class="text-sm font-semibold uppercase tracking-wide text-stone-500 dark:text-zinc-400">Metadata</h2>
<dl class="mt-4 space-y-3 text-sm"> <dl class="mt-4 space-y-3 text-sm">
<div class="flex justify-between gap-4"> <div class="flex justify-between gap-4">
<dt class="text-stone-500">Original file</dt> <dt class="text-stone-500 dark:text-zinc-400">Original file</dt>
<dd class="text-right font-medium">{{ $recording->original_filename }}</dd> <dd class="text-right font-medium">{{ $recording->original_filename }}</dd>
</div> </div>
<div class="flex justify-between gap-4"> <div class="flex justify-between gap-4">
<dt class="text-stone-500">Duration</dt> <dt class="text-stone-500 dark:text-zinc-400">Duration</dt>
<dd class="font-medium">{{ $recording->duration_formatted }}</dd> <dd class="font-medium">{{ $recording->duration_formatted }}</dd>
</div> </div>
<div class="flex justify-between gap-4"> <div class="flex justify-between gap-4">
<dt class="text-stone-500">Artist</dt> <dt class="text-stone-500 dark:text-zinc-400">Artist</dt>
<dd class="font-medium">{{ $recording->artist ?: '—' }}</dd> <dd class="font-medium">{{ $recording->artist ?: '—' }}</dd>
</div> </div>
<div class="flex justify-between gap-4"> <div class="flex justify-between gap-4">
<dt class="text-stone-500">Album</dt> <dt class="text-stone-500 dark:text-zinc-400">Album</dt>
<dd class="font-medium">{{ $recording->album ?: '—' }}</dd> <dd class="font-medium">{{ $recording->album ?: '—' }}</dd>
</div> </div>
<div class="flex justify-between gap-4"> <div class="flex justify-between gap-4">
<dt class="text-stone-500">Recorded</dt> <dt class="text-stone-500 dark:text-zinc-400">Recorded</dt>
<dd class="font-medium">{{ $recording->recorded_at?->format('Y-m-d') ?: '—' }}</dd> <dd class="font-medium">{{ $recording->recorded_at?->format('Y-m-d') ?: '—' }}</dd>
</div> </div>
<div class="flex justify-between gap-4"> <div class="flex justify-between gap-4">
<dt class="text-stone-500">Size</dt> <dt class="text-stone-500 dark:text-zinc-400">Size</dt>
<dd class="font-medium">{{ number_format($recording->file_size_bytes / 1024, 1) }} KB</dd> <dd class="font-medium">{{ number_format($recording->file_size_bytes / 1024, 1) }} KB</dd>
</div> </div>
<div class="flex justify-between gap-4"> <div class="flex justify-between gap-4">
<dt class="text-stone-500">Uploaded</dt> <dt class="text-stone-500 dark:text-zinc-400">Uploaded</dt>
<dd class="font-medium">{{ $recording->created_at?->format('Y-m-d H:i') }}</dd> <dd class="font-medium">{{ $recording->created_at?->format('Y-m-d H:i') }}</dd>
</div> </div>
</dl> </dl>
</section> </section>
<section class="rounded border border-stone-200 bg-white p-6 shadow-sm"> <section class="rounded border border-stone-200 bg-white p-6 shadow-sm dark:border-zinc-700 dark:bg-zinc-800">
<h2 class="text-sm font-semibold uppercase tracking-wide text-stone-500">Transcribe</h2> <h2 class="text-sm font-semibold uppercase tracking-wide text-stone-500 dark:text-zinc-400">Transcribe</h2>
<form method="POST" action="{{ route('recordings.transcribe', $recording) }}" class="mt-4"> <form method="POST" action="{{ route('recordings.transcribe', $recording) }}" class="mt-4">
@csrf @csrf
<button <button
type="submit" type="submit"
class="rounded bg-teal-700 px-4 py-2 text-sm font-medium text-white hover:bg-teal-800" class="rounded bg-teal-700 px-4 py-2 text-sm font-medium text-white hover:bg-teal-800 dark:bg-teal-600 dark:hover:bg-teal-500"
> >
<span x-text="startButtonLabel"></span> <span x-text="startButtonLabel"></span>
</button> </button>
@@ -94,7 +94,7 @@
@csrf @csrf
<button <button
type="submit" type="submit"
class="rounded border border-stone-300 bg-white px-4 py-2 text-sm font-medium text-stone-800 hover:bg-stone-50" class="rounded border border-stone-300 bg-white px-4 py-2 text-sm font-medium text-stone-800 hover:bg-stone-50 dark:border-zinc-600 dark:bg-zinc-900 dark:text-zinc-100 dark:hover:bg-zinc-700"
> >
Stop transcription Stop transcription
</button> </button>
@@ -102,38 +102,38 @@
</section> </section>
</div> </div>
<section class="mt-6 rounded border border-stone-200 bg-white p-6 shadow-sm"> <section class="mt-6 rounded border border-stone-200 bg-white p-6 shadow-sm dark:border-zinc-700 dark:bg-zinc-800">
<div class="flex items-center justify-between gap-4"> <div class="flex items-center justify-between gap-4">
<h2 class="text-sm font-semibold uppercase tracking-wide text-stone-500">Transcript</h2> <h2 class="text-sm font-semibold uppercase tracking-wide text-stone-500 dark:text-zinc-400">Transcript</h2>
<button <button
type="button" type="button"
x-show="!status.is_active && status.has_transcript" x-show="!status.is_active && status.has_transcript"
x-cloak x-cloak
@click="navigator.clipboard.writeText(status.transcript || '')" @click="navigator.clipboard.writeText(status.transcript || '')"
class="text-sm text-teal-700 hover:underline" class="text-sm text-teal-700 hover:underline dark:text-teal-300"
> >
Copy Copy
</button> </button>
</div> </div>
<div x-show="status.is_active" x-cloak class="mt-4 space-y-3 rounded border border-amber-200 bg-amber-50 p-4"> <div x-show="status.is_active" x-cloak class="mt-4 space-y-3 rounded border border-amber-200 bg-amber-50 p-4 dark:border-amber-900 dark:bg-amber-950/40">
<div class="flex flex-wrap items-center justify-between gap-2 text-sm"> <div class="flex flex-wrap items-center justify-between gap-2 text-sm">
<p class="font-medium text-amber-900" x-text="status.progress || 'Working…'"></p> <p class="font-medium text-amber-900 dark:text-amber-100" x-text="status.progress || 'Working…'"></p>
<p class="text-amber-800 tabular-nums"> <p class="tabular-nums text-amber-800 dark:text-amber-200">
<span x-text="(status.percent ?? 0) + '%'"></span> <span x-text="(status.percent ?? 0) + '%'"></span>
<span class="mx-1 text-amber-600">·</span> <span class="mx-1 text-amber-600 dark:text-amber-400">·</span>
<span x-text="'Elapsed ' + (status.elapsed_human || '0s')"></span> <span x-text="'Elapsed ' + (status.elapsed_human || '0s')"></span>
</p> </p>
</div> </div>
<div class="h-2 overflow-hidden rounded-full bg-amber-100"> <div class="h-2 overflow-hidden rounded-full bg-amber-100 dark:bg-amber-900/50">
<div <div
class="h-full rounded-full bg-amber-500 transition-all duration-500" class="h-full rounded-full bg-amber-500 transition-all duration-500"
:style="`width: ${Math.max(status.percent || 5, 5)}%`" :style="`width: ${Math.max(status.percent || 5, 5)}%`"
></div> ></div>
</div> </div>
<ul class="space-y-1 text-xs text-amber-900/80"> <ul class="space-y-1 text-xs text-amber-900/80 dark:text-amber-200/80">
<li> <li>
Engine: Engine:
<span class="font-medium" x-text="status.driver_label || '—'"></span> <span class="font-medium" x-text="status.driver_label || '—'"></span>
@@ -142,26 +142,26 @@
<li> <li>
Audio length: Audio length:
<span class="font-medium" x-text="formatDuration(status.duration_seconds)"></span> <span class="font-medium" x-text="formatDuration(status.duration_seconds)"></span>
<span class="text-amber-700">(longer files take longer)</span> <span class="text-amber-700 dark:text-amber-300">(longer files take longer)</span>
</li> </li>
</template> </template>
<li x-show="pollError" class="text-red-700" x-text="pollError"></li> <li x-show="pollError" class="text-red-700 dark:text-red-300" x-text="pollError"></li>
</ul> </ul>
</div> </div>
<div x-show="!status.is_active && status.status === 'cancelled'" x-cloak class="mt-4 rounded border border-stone-200 bg-stone-50 p-4 text-sm text-stone-700"> <div x-show="!status.is_active && status.status === 'cancelled'" x-cloak class="mt-4 rounded border border-stone-200 bg-stone-50 p-4 text-sm text-stone-700 dark:border-zinc-600 dark:bg-zinc-900 dark:text-zinc-300">
Transcription stopped. Choose an engine above to start again. Transcription stopped. Choose an engine above to start again.
</div> </div>
<div x-show="!status.is_active && status.status === 'failed'" x-cloak class="mt-4 space-y-2 rounded border border-red-200 bg-red-50 p-4 text-sm text-red-800"> <div x-show="!status.is_active && status.status === 'failed'" x-cloak class="mt-4 space-y-2 rounded border border-red-200 bg-red-50 p-4 text-sm text-red-800 dark:border-red-900 dark:bg-red-950 dark:text-red-200">
<p class="font-medium">Transcription failed</p> <p class="font-medium">Transcription failed</p>
<p x-text="status.error || 'Check the logs and try again with another engine.'"></p> <p x-text="status.error || 'Check the logs and try again with another engine.'"></p>
</div> </div>
<div x-show="!status.is_active && status.has_transcript" x-cloak> <div x-show="!status.is_active && status.has_transcript" x-cloak>
<p class="mt-4 whitespace-pre-wrap text-sm leading-relaxed text-stone-800" x-text="status.transcript"></p> <p class="mt-4 whitespace-pre-wrap text-sm leading-relaxed text-stone-800 dark:text-zinc-100" x-text="status.transcript"></p>
<p <p
class="mt-4 text-xs text-stone-500" class="mt-4 text-xs text-stone-500 dark:text-zinc-400"
x-show="status.transcribed_at" x-show="status.transcribed_at"
x-text="status.transcribed_at ? ('Transcribed ' + formatTimestamp(status.transcribed_at)) : ''" x-text="status.transcribed_at ? ('Transcribed ' + formatTimestamp(status.transcribed_at)) : ''"
></p> ></p>
@@ -170,7 +170,7 @@
<p <p
x-show="!status.is_active && status.status !== 'failed' && status.status !== 'cancelled' && !status.has_transcript" x-show="!status.is_active && status.status !== 'failed' && status.status !== 'cancelled' && !status.has_transcript"
x-cloak x-cloak
class="mt-4 text-sm text-stone-500" class="mt-4 text-sm text-stone-500 dark:text-zinc-400"
> >
No transcript yet. Transcription starts automatically after upload, or use the button above. No transcript yet. Transcription starts automatically after upload, or use the button above.
</p> </p>
+7 -12
View File
@@ -1,17 +1,12 @@
<?php <?php
use App\Models\Recording;
use App\Models\User;
use Illuminate\Support\Facades\Broadcast; use Illuminate\Support\Facades\Broadcast;
/* Broadcast::channel('recording.{recordingId}', function (User $user, int $recordingId): bool {
|-------------------------------------------------------------------------- return Recording::query()
| Broadcast Channels ->whereKey($recordingId)
|-------------------------------------------------------------------------- ->where('user_id', $user->id)
| ->exists();
| Transcription updates use the public "recordings" channel (no auth).
| Private channel stubs can be added here when the app gains users.
|
*/
Broadcast::channel('App.Models.User.{id}', function ($user, $id) {
return (int) $user->id === (int) $id;
}); });
+12 -9
View File
@@ -7,13 +7,16 @@ use App\Http\Controllers\TranscribePendingController;
use App\Http\Controllers\TranscriptionStatusController; use App\Http\Controllers\TranscriptionStatusController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::redirect('/', '/recordings'); Route::redirect('/', '/recordings')->name('home');
Route::resource('recordings', RecordingController::class)->except(['edit', 'update']); Route::middleware('auth')->group(function (): void {
Route::post('recordings/transcribe-pending', TranscribePendingController::class) Route::resource('recordings', RecordingController::class)->except(['edit', 'update']);
->name('recordings.transcribe-pending'); Route::post('recordings/transcribe-pending', TranscribePendingController::class)
Route::post('recordings/{recording}/transcribe', TranscribeController::class)->name('recordings.transcribe'); ->name('recordings.transcribe-pending');
Route::post('recordings/{recording}/transcribe/cancel', CancelTranscriptionController::class) Route::post('recordings/{recording}/transcribe', TranscribeController::class)
->name('recordings.transcribe.cancel'); ->name('recordings.transcribe');
Route::get('recordings/{recording}/transcription-status', TranscriptionStatusController::class) Route::post('recordings/{recording}/transcribe/cancel', CancelTranscriptionController::class)
->name('recordings.transcription-status'); ->name('recordings.transcribe.cancel');
Route::get('recordings/{recording}/transcription-status', TranscriptionStatusController::class)
->name('recordings.transcription-status');
});
+109
View File
@@ -0,0 +1,109 @@
<?php
namespace Tests\Feature;
use App\Models\Recording;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class AuthenticationTest extends TestCase
{
use RefreshDatabase;
public function test_guests_are_redirected_from_recordings_to_login(): void
{
$this->get(route('recordings.index'))
->assertRedirect(route('login'));
}
public function test_login_page_is_shown(): void
{
$this->get(route('login'))
->assertOk()
->assertSee('Log in');
}
public function test_register_page_is_shown(): void
{
$this->get(route('register'))
->assertOk()
->assertSee('Create an account');
}
public function test_users_can_register_and_are_authenticated(): void
{
$response = $this->post(route('register.store'), [
'name' => 'Ada Lovelace',
'email' => 'ada@example.com',
'password' => 'password',
'password_confirmation' => 'password',
]);
$response->assertRedirect(route('recordings.index'));
$this->assertAuthenticated();
$this->assertDatabaseHas('users', ['email' => 'ada@example.com']);
}
public function test_users_can_log_in(): void
{
$user = User::factory()->create([
'email' => 'ben@example.com',
'password' => 'password',
]);
$response = $this->post(route('login.store'), [
'email' => 'ben@example.com',
'password' => 'password',
]);
$response->assertRedirect(route('recordings.index'));
$this->assertAuthenticatedAs($user);
}
public function test_first_registered_user_claims_orphaned_recordings(): void
{
Recording::query()->create([
'title' => 'Orphan',
'original_filename' => 'orphan.mp3',
'file_path' => 'recordings/orphan.mp3',
'file_size_bytes' => 10,
'transcription_status' => 'pending',
'user_id' => null,
]);
$this->post(route('register.store'), [
'name' => 'First User',
'email' => 'first@example.com',
'password' => 'password',
'password_confirmation' => 'password',
])->assertRedirect(route('recordings.index'));
$user = User::query()->where('email', 'first@example.com')->first();
$this->assertNotNull($user);
$this->assertSame($user->id, Recording::query()->first()->user_id);
}
public function test_second_registered_user_does_not_claim_orphans(): void
{
User::factory()->create();
Recording::query()->create([
'title' => 'Still orphan',
'original_filename' => 'still.mp3',
'file_path' => 'recordings/still.mp3',
'file_size_bytes' => 10,
'transcription_status' => 'pending',
'user_id' => null,
]);
$this->post(route('register.store'), [
'name' => 'Second User',
'email' => 'second@example.com',
'password' => 'password',
'password_confirmation' => 'password',
])->assertRedirect(route('recordings.index'));
$this->assertNull(Recording::query()->where('title', 'Still orphan')->value('user_id'));
}
}
+53
View File
@@ -0,0 +1,53 @@
<?php
namespace Tests\Feature;
use App\Models\Recording;
use App\Models\User;
use Database\Seeders\DatabaseSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Tests\TestCase;
class DemoUserSeederTest extends TestCase
{
use RefreshDatabase;
public function test_seeder_creates_demo_user(): void
{
$this->seed(DatabaseSeeder::class);
$user = User::query()->where('email', 'demo@example.com')->first();
$this->assertNotNull($user);
$this->assertSame('Demo User', $user->name);
$this->assertTrue(Hash::check('password', $user->password));
}
public function test_seeder_is_idempotent(): void
{
$this->seed(DatabaseSeeder::class);
$this->seed(DatabaseSeeder::class);
$this->assertSame(1, User::query()->where('email', 'demo@example.com')->count());
}
public function test_seeder_assigns_orphaned_recordings_to_demo_user(): void
{
Recording::query()->create([
'title' => 'Orphan',
'original_filename' => 'orphan.mp3',
'file_path' => 'recordings/orphan.mp3',
'file_size_bytes' => 10,
'transcription_status' => 'pending',
'user_id' => null,
]);
$this->seed(DatabaseSeeder::class);
$user = User::query()->where('email', 'demo@example.com')->first();
$this->assertNotNull($user);
$this->assertSame($user->id, Recording::query()->where('title', 'Orphan')->value('user_id'));
}
}
+11
View File
@@ -2,6 +2,7 @@
namespace Tests\Feature; namespace Tests\Feature;
use App\Models\User;
use App\Services\DiskSpaceService; use App\Services\DiskSpaceService;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
@@ -11,6 +12,16 @@ class DiskSpaceTest extends TestCase
{ {
use RefreshDatabase; use RefreshDatabase;
protected User $user;
protected function setUp(): void
{
parent::setUp();
$this->user = User::factory()->create();
$this->actingAs($this->user);
}
public function test_disk_space_service_returns_snapshot_for_storage_path(): void public function test_disk_space_service_returns_snapshot_for_storage_path(): void
{ {
Cache::flush(); Cache::flush();
+5 -3
View File
@@ -2,17 +2,19 @@
namespace Tests\Feature; namespace Tests\Feature;
// use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase; use Tests\TestCase;
class ExampleTest extends TestCase class ExampleTest extends TestCase
{ {
/** /**
* A basic test example. * Guests hitting / are sent toward recordings, then login.
*/ */
public function test_the_application_returns_a_successful_response(): void public function test_the_application_redirects_guests_to_login(): void
{ {
$this->get('/') $this->get('/')
->assertRedirect('/recordings'); ->assertRedirect('/recordings');
$this->get('/recordings')
->assertRedirect(route('login'));
} }
} }
@@ -4,6 +4,7 @@ namespace Tests\Feature;
use App\Jobs\TranscribeRecording; use App\Jobs\TranscribeRecording;
use App\Models\Recording; use App\Models\Recording;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile; use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Bus; use Illuminate\Support\Facades\Bus;
@@ -14,6 +15,16 @@ class RecordingDuplicateUploadTest extends TestCase
{ {
use RefreshDatabase; use RefreshDatabase;
protected User $user;
protected function setUp(): void
{
parent::setUp();
$this->user = User::factory()->create();
$this->actingAs($this->user);
}
public function test_duplicate_content_hash_is_skipped_on_upload(): void public function test_duplicate_content_hash_is_skipped_on_upload(): void
{ {
Storage::fake('local'); Storage::fake('local');
@@ -62,6 +73,7 @@ class RecordingDuplicateUploadTest extends TestCase
public function test_upload_page_includes_existing_fingerprints_for_client_dedupe(): void public function test_upload_page_includes_existing_fingerprints_for_client_dedupe(): void
{ {
Recording::query()->create([ Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Existing', 'title' => 'Existing',
'original_filename' => 'note.mp3', 'original_filename' => 'note.mp3',
'file_path' => 'recordings/note.mp3', 'file_path' => 'recordings/note.mp3',
@@ -82,6 +94,7 @@ class RecordingDuplicateUploadTest extends TestCase
Bus::fake(); Bus::fake();
Recording::query()->create([ Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Legacy', 'title' => 'Legacy',
'original_filename' => 'legacy.mp3', 'original_filename' => 'legacy.mp3',
'file_path' => 'recordings/legacy.mp3', 'file_path' => 'recordings/legacy.mp3',
+83
View File
@@ -0,0 +1,83 @@
<?php
namespace Tests\Feature;
use App\Models\Recording;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class RecordingOwnershipTest extends TestCase
{
use RefreshDatabase;
public function test_user_only_sees_their_own_recordings(): void
{
$owner = User::factory()->create();
$other = User::factory()->create();
Recording::query()->create([
'user_id' => $owner->id,
'title' => 'Mine',
'original_filename' => 'mine.mp3',
'file_path' => 'recordings/mine.mp3',
'file_size_bytes' => 10,
'transcription_status' => 'done',
]);
Recording::query()->create([
'user_id' => $other->id,
'title' => 'Theirs',
'original_filename' => 'theirs.mp3',
'file_path' => 'recordings/theirs.mp3',
'file_size_bytes' => 10,
'transcription_status' => 'done',
]);
$this->actingAs($owner)
->get(route('recordings.index'))
->assertOk()
->assertSee('Mine')
->assertDontSee('Theirs');
}
public function test_user_cannot_view_another_users_recording(): void
{
$owner = User::factory()->create();
$intruder = User::factory()->create();
$recording = Recording::query()->create([
'user_id' => $owner->id,
'title' => 'Private',
'original_filename' => 'private.mp3',
'file_path' => 'recordings/private.mp3',
'file_size_bytes' => 10,
'transcription_status' => 'done',
]);
$this->actingAs($intruder)
->get(route('recordings.show', $recording))
->assertForbidden();
}
public function test_user_cannot_delete_another_users_recording(): void
{
$owner = User::factory()->create();
$intruder = User::factory()->create();
$recording = Recording::query()->create([
'user_id' => $owner->id,
'title' => 'Keep me',
'original_filename' => 'keep.mp3',
'file_path' => 'recordings/keep.mp3',
'file_size_bytes' => 10,
'transcription_status' => 'done',
]);
$this->actingAs($intruder)
->delete(route('recordings.destroy', $recording))
->assertForbidden();
$this->assertDatabaseHas('recordings', ['id' => $recording->id]);
}
}
+28
View File
@@ -5,6 +5,7 @@ namespace Tests\Feature;
use App\Http\Requests\StoreRecordingRequest; use App\Http\Requests\StoreRecordingRequest;
use App\Jobs\TranscribeRecording; use App\Jobs\TranscribeRecording;
use App\Models\Recording; use App\Models\Recording;
use App\Models\User;
use App\Services\TranscriptionService; use App\Services\TranscriptionService;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile; use Illuminate\Http\UploadedFile;
@@ -17,9 +18,20 @@ class RecordingUploadTest extends TestCase
{ {
use RefreshDatabase; use RefreshDatabase;
protected User $user;
protected function setUp(): void
{
parent::setUp();
$this->user = User::factory()->create();
$this->actingAs($this->user);
}
public function test_index_shows_recordings(): void public function test_index_shows_recordings(): void
{ {
Recording::query()->create([ Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Pocket note', 'title' => 'Pocket note',
'original_filename' => 'note.mp3', 'original_filename' => 'note.mp3',
'file_path' => 'recordings/note.mp3', 'file_path' => 'recordings/note.mp3',
@@ -150,6 +162,7 @@ class RecordingUploadTest extends TestCase
public function test_user_can_queue_local_transcription(): void public function test_user_can_queue_local_transcription(): void
{ {
$recording = Recording::query()->create([ $recording = Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Dictation', 'title' => 'Dictation',
'original_filename' => 'dictation.mp3', 'original_filename' => 'dictation.mp3',
'file_path' => 'recordings/dictation.mp3', 'file_path' => 'recordings/dictation.mp3',
@@ -173,6 +186,7 @@ class RecordingUploadTest extends TestCase
public function test_transcription_status_endpoint_returns_progress(): void public function test_transcription_status_endpoint_returns_progress(): void
{ {
$recording = Recording::query()->create([ $recording = Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Live status', 'title' => 'Live status',
'original_filename' => 'live.mp3', 'original_filename' => 'live.mp3',
'file_path' => 'recordings/live.mp3', 'file_path' => 'recordings/live.mp3',
@@ -201,6 +215,7 @@ class RecordingUploadTest extends TestCase
Transcription::fake(['Hello from the recorder.']); Transcription::fake(['Hello from the recorder.']);
$recording = Recording::query()->create([ $recording = Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Sample', 'title' => 'Sample',
'original_filename' => 'sample.mp3', 'original_filename' => 'sample.mp3',
'file_path' => 'recordings/sample.mp3', 'file_path' => 'recordings/sample.mp3',
@@ -229,6 +244,7 @@ class RecordingUploadTest extends TestCase
}); });
$recording = Recording::query()->create([ $recording = Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Bad', 'title' => 'Bad',
'original_filename' => 'bad.mp3', 'original_filename' => 'bad.mp3',
'file_path' => 'recordings/bad.mp3', 'file_path' => 'recordings/bad.mp3',
@@ -252,6 +268,7 @@ class RecordingUploadTest extends TestCase
public function test_orphaned_processing_is_recovered_on_status_poll(): void public function test_orphaned_processing_is_recovered_on_status_poll(): void
{ {
$recording = Recording::query()->create([ $recording = Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Stuck', 'title' => 'Stuck',
'original_filename' => 'stuck.mp3', 'original_filename' => 'stuck.mp3',
'file_path' => 'recordings/stuck.mp3', 'file_path' => 'recordings/stuck.mp3',
@@ -277,6 +294,7 @@ class RecordingUploadTest extends TestCase
public function test_recent_processing_is_not_marked_orphaned(): void public function test_recent_processing_is_not_marked_orphaned(): void
{ {
$recording = Recording::query()->create([ $recording = Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Still working', 'title' => 'Still working',
'original_filename' => 'working.mp3', 'original_filename' => 'working.mp3',
'file_path' => 'recordings/working.mp3', 'file_path' => 'recordings/working.mp3',
@@ -303,6 +321,7 @@ class RecordingUploadTest extends TestCase
Transcription::fake(['Recovered transcript.']); Transcription::fake(['Recovered transcript.']);
$recording = Recording::query()->create([ $recording = Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Restart me', 'title' => 'Restart me',
'original_filename' => 'restart.mp3', 'original_filename' => 'restart.mp3',
'file_path' => 'recordings/restart.mp3', 'file_path' => 'recordings/restart.mp3',
@@ -324,6 +343,7 @@ class RecordingUploadTest extends TestCase
public function test_user_can_stop_an_active_transcription(): void public function test_user_can_stop_an_active_transcription(): void
{ {
$recording = Recording::query()->create([ $recording = Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Stop me', 'title' => 'Stop me',
'original_filename' => 'stop.mp3', 'original_filename' => 'stop.mp3',
'file_path' => 'recordings/stop.mp3', 'file_path' => 'recordings/stop.mp3',
@@ -350,6 +370,7 @@ class RecordingUploadTest extends TestCase
Transcription::fake(['Restarted transcript.']); Transcription::fake(['Restarted transcript.']);
$recording = Recording::query()->create([ $recording = Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Restart me while busy', 'title' => 'Restart me while busy',
'original_filename' => 'switch.mp3', 'original_filename' => 'switch.mp3',
'file_path' => 'recordings/switch.mp3', 'file_path' => 'recordings/switch.mp3',
@@ -377,6 +398,7 @@ class RecordingUploadTest extends TestCase
Transcription::fake(['Should be ignored.']); Transcription::fake(['Should be ignored.']);
$recording = Recording::query()->create([ $recording = Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Ignore late job', 'title' => 'Ignore late job',
'original_filename' => 'ignore.mp3', 'original_filename' => 'ignore.mp3',
'file_path' => 'recordings/ignore.mp3', 'file_path' => 'recordings/ignore.mp3',
@@ -400,6 +422,7 @@ class RecordingUploadTest extends TestCase
public function test_recordings_can_be_searched_by_transcript(): void public function test_recordings_can_be_searched_by_transcript(): void
{ {
Recording::query()->create([ Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Office chat', 'title' => 'Office chat',
'original_filename' => 'office.mp3', 'original_filename' => 'office.mp3',
'file_path' => 'recordings/office.mp3', 'file_path' => 'recordings/office.mp3',
@@ -409,6 +432,7 @@ class RecordingUploadTest extends TestCase
]); ]);
Recording::query()->create([ Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Kitchen note', 'title' => 'Kitchen note',
'original_filename' => 'kitchen.mp3', 'original_filename' => 'kitchen.mp3',
'file_path' => 'recordings/kitchen.mp3', 'file_path' => 'recordings/kitchen.mp3',
@@ -429,6 +453,7 @@ class RecordingUploadTest extends TestCase
Bus::fake(); Bus::fake();
$recording = Recording::query()->create([ $recording = Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Keep me', 'title' => 'Keep me',
'original_filename' => 'keep.mp3', 'original_filename' => 'keep.mp3',
'file_path' => 'recordings/keep.mp3', 'file_path' => 'recordings/keep.mp3',
@@ -454,6 +479,7 @@ class RecordingUploadTest extends TestCase
Bus::fake(); Bus::fake();
Recording::query()->create([ Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Needs work', 'title' => 'Needs work',
'original_filename' => 'needs.mp3', 'original_filename' => 'needs.mp3',
'file_path' => 'recordings/needs.mp3', 'file_path' => 'recordings/needs.mp3',
@@ -462,6 +488,7 @@ class RecordingUploadTest extends TestCase
]); ]);
Recording::query()->create([ Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Already done', 'title' => 'Already done',
'original_filename' => 'done.mp3', 'original_filename' => 'done.mp3',
'file_path' => 'recordings/done.mp3', 'file_path' => 'recordings/done.mp3',
@@ -481,6 +508,7 @@ class RecordingUploadTest extends TestCase
public function test_index_shows_human_status_labels(): void public function test_index_shows_human_status_labels(): void
{ {
Recording::query()->create([ Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Label check', 'title' => 'Label check',
'original_filename' => 'label.mp3', 'original_filename' => 'label.mp3',
'file_path' => 'recordings/label.mp3', 'file_path' => 'recordings/label.mp3',
+18 -2
View File
@@ -5,6 +5,7 @@ namespace Tests\Feature;
use App\Events\RecordingTranscriptionUpdated; use App\Events\RecordingTranscriptionUpdated;
use App\Jobs\TranscribeRecording; use App\Jobs\TranscribeRecording;
use App\Models\Recording; use App\Models\Recording;
use App\Models\User;
use App\Services\TranscriptionService; use App\Services\TranscriptionService;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Event;
@@ -16,11 +17,22 @@ class TranscriptionBroadcastTest extends TestCase
{ {
use RefreshDatabase; use RefreshDatabase;
protected User $user;
protected function setUp(): void
{
parent::setUp();
$this->user = User::factory()->create();
$this->actingAs($this->user);
}
public function test_report_progress_broadcasts_transcription_updated(): void public function test_report_progress_broadcasts_transcription_updated(): void
{ {
Event::fake([RecordingTranscriptionUpdated::class]); Event::fake([RecordingTranscriptionUpdated::class]);
$recording = Recording::query()->create([ $recording = Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Broadcast progress', 'title' => 'Broadcast progress',
'original_filename' => 'progress.mp3', 'original_filename' => 'progress.mp3',
'file_path' => 'recordings/progress.mp3', 'file_path' => 'recordings/progress.mp3',
@@ -48,6 +60,7 @@ class TranscriptionBroadcastTest extends TestCase
Transcription::fake(['Hello from the recorder.']); Transcription::fake(['Hello from the recorder.']);
$recording = Recording::query()->create([ $recording = Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Sample', 'title' => 'Sample',
'original_filename' => 'sample.mp3', 'original_filename' => 'sample.mp3',
'file_path' => 'recordings/sample.mp3', 'file_path' => 'recordings/sample.mp3',
@@ -81,6 +94,7 @@ class TranscriptionBroadcastTest extends TestCase
}); });
$recording = Recording::query()->create([ $recording = Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Bad', 'title' => 'Bad',
'original_filename' => 'bad.mp3', 'original_filename' => 'bad.mp3',
'file_path' => 'recordings/bad.mp3', 'file_path' => 'recordings/bad.mp3',
@@ -111,6 +125,7 @@ class TranscriptionBroadcastTest extends TestCase
Event::fake([RecordingTranscriptionUpdated::class]); Event::fake([RecordingTranscriptionUpdated::class]);
$recording = Recording::query()->create([ $recording = Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Cancel me', 'title' => 'Cancel me',
'original_filename' => 'cancel.mp3', 'original_filename' => 'cancel.mp3',
'file_path' => 'recordings/cancel.mp3', 'file_path' => 'recordings/cancel.mp3',
@@ -128,9 +143,10 @@ class TranscriptionBroadcastTest extends TestCase
}); });
} }
public function test_broadcast_event_uses_public_recordings_channel(): void public function test_broadcast_event_uses_private_recording_channel(): void
{ {
$recording = Recording::query()->create([ $recording = Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Channel', 'title' => 'Channel',
'original_filename' => 'channel.mp3', 'original_filename' => 'channel.mp3',
'file_path' => 'recordings/channel.mp3', 'file_path' => 'recordings/channel.mp3',
@@ -141,6 +157,6 @@ class TranscriptionBroadcastTest extends TestCase
$event = new RecordingTranscriptionUpdated($recording); $event = new RecordingTranscriptionUpdated($recording);
$this->assertSame('RecordingTranscriptionUpdated', $event->broadcastAs()); $this->assertSame('RecordingTranscriptionUpdated', $event->broadcastAs());
$this->assertSame('recordings', $event->broadcastOn()[0]->name); $this->assertSame('private-recording.'.$recording->id, $event->broadcastOn()[0]->name);
} }
} }
+7
View File
@@ -17,8 +17,15 @@ export default defineConfig({
tailwindcss(), tailwindcss(),
], ],
server: { server: {
host: process.env.VITE_HOST || true,
port: Number(process.env.VITE_PORT || 5173),
strictPort: true,
hmr: {
host: process.env.VITE_HMR_HOST || 'localhost',
},
watch: { watch: {
ignored: ['**/storage/framework/views/**'], ignored: ['**/storage/framework/views/**'],
usePolling: process.env.VITE_USE_POLLING === 'true',
}, },
}, },
}); });