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:
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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'];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,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.');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()) {
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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('/');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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('');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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(
|
||||||
|
|||||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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(),
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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:
|
||||||
@@ -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
@@ -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,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
|
||||||
|
|||||||
@@ -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 *));
|
||||||
|
|||||||
@@ -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();
|
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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">“{{ trim($message) }}”</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>
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 }}">
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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');
|
||||||
|
});
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user