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:
@@ -1,6 +1,8 @@
|
||||
@import 'tailwindcss';
|
||||
@import '../../vendor/livewire/flux/dist/flux.css';
|
||||
|
||||
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
|
||||
@source '../../vendor/livewire/flux/stubs/**/*.blade.php';
|
||||
@source '../../storage/framework/views/*.php';
|
||||
@source '../views';
|
||||
@source '../js';
|
||||
@@ -8,4 +10,9 @@
|
||||
@theme {
|
||||
--font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI 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 { recordingsIndex, transcriptionMonitor } from './transcription';
|
||||
import { uploadDropzone } from './upload';
|
||||
|
||||
window.Alpine = Alpine;
|
||||
window.transcriptionMonitor = transcriptionMonitor;
|
||||
window.recordingsIndex = recordingsIndex;
|
||||
window.uploadDropzone = uploadDropzone;
|
||||
|
||||
Alpine.start();
|
||||
|
||||
@@ -11,4 +11,10 @@ window.Echo = new Echo({
|
||||
wssPort: import.meta.env.VITE_REVERB_PORT ?? 443,
|
||||
forceTLS: (import.meta.env.VITE_REVERB_SCHEME ?? 'https') === 'https',
|
||||
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 = {
|
||||
done: 'bg-teal-50 text-teal-800 ring-teal-600/20',
|
||||
processing: 'bg-amber-50 text-amber-800 ring-amber-600/20',
|
||||
pending: 'bg-amber-50 text-amber-800 ring-amber-600/20',
|
||||
failed: 'bg-red-50 text-red-800 ring-red-600/20',
|
||||
cancelled: 'bg-stone-100 text-stone-700 ring-stone-500/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 dark:bg-amber-950 dark:text-amber-200 dark:ring-amber-400/30',
|
||||
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 dark:bg-red-950 dark:text-red-200 dark:ring-red-400/30',
|
||||
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) {
|
||||
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) {
|
||||
@@ -56,17 +56,26 @@ function formatWordCount(count) {
|
||||
return n > 0 ? n.toLocaleString() : '—';
|
||||
}
|
||||
|
||||
function subscribeToRecordings(handler) {
|
||||
function subscribeToRecording(recordingId, handler) {
|
||||
if (!window.Echo) {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const channel = window.Echo.channel('recordings');
|
||||
const channelName = 'recording.' + recordingId;
|
||||
const channel = window.Echo.private(channelName);
|
||||
|
||||
channel.listen('.RecordingTranscriptionUpdated', handler);
|
||||
|
||||
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() {
|
||||
this.leaveChannel = subscribeToRecordings((event) => {
|
||||
this.leaveChannel = subscribeToRecording(this.status.id, (event) => {
|
||||
if (Number(event.id) !== Number(this.status.id)) {
|
||||
return;
|
||||
}
|
||||
@@ -202,7 +211,7 @@ export function recordingsIndex({ recordings, pendingCount }) {
|
||||
leaveChannel: null,
|
||||
|
||||
start() {
|
||||
this.leaveChannel = subscribeToRecordings((event) => {
|
||||
this.leaveChannel = subscribeToRecordings(Object.keys(this.rows), (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
|
||||
/** @var array{used_percent: float, free_percent: float, free_human: string, total_human: string, used_human: string} $disk */
|
||||
@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="flex items-center justify-between gap-3 text-xs text-stone-600">
|
||||
<span class="font-medium text-stone-700">Disk space</span>
|
||||
<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 dark:text-zinc-200">Disk space</span>
|
||||
<span class="tabular-nums">
|
||||
{{ $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'] }}
|
||||
</span>
|
||||
</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"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
|
||||
@@ -1,44 +1,78 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="dark">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<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'])
|
||||
@include('partials.head', ['title' => trim($__env->yieldContent('title')) ?: null])
|
||||
<style>[x-cloak]{display:none!important}</style>
|
||||
</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 />
|
||||
|
||||
<header class="border-b border-stone-200 bg-white">
|
||||
<div class="mx-auto flex max-w-5xl items-center justify-between gap-4 px-4 py-4 sm:px-6">
|
||||
<a href="{{ route('recordings.index') }}" class="text-lg font-semibold tracking-tight text-teal-800">
|
||||
<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">
|
||||
<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
|
||||
</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>
|
||||
<a href="{{ route('recordings.create') }}" class="rounded bg-teal-700 px-3 py-1.5 font-medium text-white hover:bg-teal-800">
|
||||
Upload
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<flux:navbar class="-mb-px max-lg:hidden">
|
||||
<flux:navbar.item
|
||||
:href="route('recordings.index')"
|
||||
:current="request()->routeIs('recordings.index', 'recordings.show')"
|
||||
>
|
||||
{{ __('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>
|
||||
</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">
|
||||
@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') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@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') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@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">
|
||||
@foreach ($errors->all() as $error)
|
||||
<li>{{ $error }}</li>
|
||||
@@ -49,5 +83,7 @@
|
||||
|
||||
@yield('content')
|
||||
</main>
|
||||
|
||||
@fluxScripts
|
||||
</body>
|
||||
</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')
|
||||
<div class="mb-8">
|
||||
<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.
|
||||
Duplicate files (same name and size, or identical content) are skipped.
|
||||
</p>
|
||||
@@ -17,12 +17,12 @@
|
||||
enctype="multipart/form-data"
|
||||
x-data="uploadDropzone(@js(['existingFingerprints' => $existingFingerprints ?? []]))"
|
||||
@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
|
||||
|
||||
<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
|
||||
@dragenter.prevent="dragging = true"
|
||||
@@ -30,12 +30,12 @@
|
||||
@dragleave.prevent="dragging = false"
|
||||
@drop.prevent="onDrop($event)"
|
||||
@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"
|
||||
>
|
||||
<p class="text-sm font-medium text-stone-800">Drop audio files here</p>
|
||||
<p class="mt-1 text-sm text-stone-600">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="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 dark:text-zinc-400">or click to browse</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>
|
||||
|
||||
<input
|
||||
@@ -52,26 +52,26 @@
|
||||
|
||||
<div x-show="files.length > 0" x-cloak class="space-y-2">
|
||||
<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 === 1 ? 'file selected' : 'files selected'"></span>
|
||||
</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
|
||||
</button>
|
||||
</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)">
|
||||
<li class="flex items-center justify-between gap-3 px-3 py-2 text-sm">
|
||||
<div class="min-w-0">
|
||||
<p class="truncate font-medium text-stone-800" x-text="file.name"></p>
|
||||
<p class="text-xs text-stone-500" x-text="formatSize(file.size)"></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 dark:text-zinc-400" x-text="formatSize(file.size)"></p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@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
|
||||
</button>
|
||||
@@ -81,19 +81,19 @@
|
||||
</div>
|
||||
|
||||
<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
|
||||
id="title"
|
||||
type="text"
|
||||
name="title"
|
||||
value="{{ old('title') }}"
|
||||
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>
|
||||
|
||||
<p x-show="notice" x-cloak class="text-sm text-amber-800" x-text="notice"></p>
|
||||
<p x-show="error" x-cloak class="text-sm text-red-700" x-text="error"></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 dark:text-red-300" x-text="error"></p>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
@@ -103,7 +103,7 @@
|
||||
>
|
||||
<span x-text="uploadLabel"></span>
|
||||
</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>
|
||||
</form>
|
||||
@endsection
|
||||
|
||||
@@ -17,12 +17,12 @@
|
||||
? number_format($recording->word_count)
|
||||
: '—',
|
||||
'badge_class' => match ($recording->transcription_status) {
|
||||
'done' => 'bg-teal-50 text-teal-800 ring-teal-600/20',
|
||||
'processing' => 'bg-amber-50 text-amber-800 ring-amber-600/20',
|
||||
'pending' => 'bg-amber-50 text-amber-800 ring-amber-600/20',
|
||||
'failed' => 'bg-red-50 text-red-800 ring-red-600/20',
|
||||
'cancelled' => 'bg-stone-100 text-stone-700 ring-stone-500/20',
|
||||
default => 'bg-stone-100 text-stone-700 ring-stone-500/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 dark:bg-amber-950 dark:text-amber-200 dark:ring-amber-400/30',
|
||||
'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 dark:bg-red-950 dark:text-red-200 dark:ring-red-400/30',
|
||||
'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 dark:bg-zinc-800 dark:text-zinc-300 dark:ring-zinc-500/30',
|
||||
},
|
||||
];
|
||||
})->values();
|
||||
@@ -41,7 +41,7 @@
|
||||
<div class="mb-8 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<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 class="flex flex-col gap-2 sm:items-end">
|
||||
<form method="GET" action="{{ route('recordings.index') }}" class="flex gap-2">
|
||||
@@ -50,9 +50,9 @@
|
||||
name="q"
|
||||
value="{{ $search ?? '' }}"
|
||||
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
|
||||
</button>
|
||||
</form>
|
||||
@@ -63,7 +63,7 @@
|
||||
x-cloak
|
||||
>
|
||||
@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
|
||||
<span x-text="pendingCount === 1 ? 'transcription' : 'transcriptions'"></span>
|
||||
</button>
|
||||
@@ -72,16 +72,16 @@
|
||||
</div>
|
||||
|
||||
@if ($recordings->isEmpty())
|
||||
<div class="rounded border border-dashed border-stone-300 bg-white px-6 py-16 text-center">
|
||||
<p class="text-stone-600">No recordings yet.</p>
|
||||
<a href="{{ route('recordings.create') }}" class="mt-4 inline-block text-sm font-medium text-teal-700 hover:underline">
|
||||
<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 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 dark:text-teal-300">
|
||||
Upload your first MP3
|
||||
</a>
|
||||
</div>
|
||||
@else
|
||||
<div class="overflow-hidden rounded border border-stone-200 bg-white shadow-sm">
|
||||
<table class="min-w-full divide-y divide-stone-200 text-sm">
|
||||
<thead class="bg-stone-50 text-left text-xs font-medium uppercase tracking-wide text-stone-500">
|
||||
<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 dark:divide-zinc-700">
|
||||
<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>
|
||||
<th class="px-4 py-3">Title</th>
|
||||
<th class="px-4 py-3">Duration</th>
|
||||
@@ -90,25 +90,25 @@
|
||||
<th class="px-4 py-3">Uploaded</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-stone-100">
|
||||
<tbody class="divide-y divide-stone-100 dark:divide-zinc-700">
|
||||
@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">
|
||||
<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 }}
|
||||
</a>
|
||||
@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
|
||||
@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 }}
|
||||
</p>
|
||||
@endif
|
||||
</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
|
||||
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"
|
||||
>
|
||||
{{ $recording->word_count > 0 ? number_format($recording->word_count) : '—' }}
|
||||
@@ -122,7 +122,7 @@
|
||||
{{ $recording->transcriptionStatusLabel() }}
|
||||
</span>
|
||||
<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-cloak
|
||||
:title="row({{ $recording->id }}).progress"
|
||||
@@ -131,7 +131,7 @@
|
||||
<span x-text="row({{ $recording->id }}).progress"></span>
|
||||
</div>
|
||||
</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>
|
||||
@endforeach
|
||||
</tbody>
|
||||
|
||||
@@ -8,12 +8,12 @@
|
||||
default => (string) $status,
|
||||
};
|
||||
$classes = match ($status) {
|
||||
'done' => 'bg-teal-50 text-teal-800 ring-teal-600/20',
|
||||
'processing' => 'bg-amber-50 text-amber-800 ring-amber-600/20',
|
||||
'pending' => 'bg-amber-50 text-amber-800 ring-amber-600/20',
|
||||
'failed' => 'bg-red-50 text-red-800 ring-red-600/20',
|
||||
'cancelled' => 'bg-stone-100 text-stone-700 ring-stone-500/20',
|
||||
default => 'bg-stone-100 text-stone-700 ring-stone-500/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 dark:bg-amber-950 dark:text-amber-200 dark:ring-amber-400/30',
|
||||
'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 dark:bg-red-950 dark:text-red-200 dark:ring-red-400/30',
|
||||
'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 dark:bg-zinc-800 dark:text-zinc-300 dark:ring-zinc-500/30',
|
||||
};
|
||||
@endphp
|
||||
<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>
|
||||
<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>
|
||||
<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
|
||||
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ring-1 ring-inset"
|
||||
:class="badgeClass"
|
||||
x-text="status.status_label || status.status"
|
||||
></span>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<form method="POST" action="{{ route('recordings.destroy', $recording) }}" onsubmit="return confirm('Delete this recording and its file?')">
|
||||
@csrf
|
||||
@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
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-2">
|
||||
<section class="rounded border border-stone-200 bg-white p-6 shadow-sm">
|
||||
<h2 class="text-sm font-semibold uppercase tracking-wide text-stone-500">Metadata</h2>
|
||||
<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 dark:text-zinc-400">Metadata</h2>
|
||||
<dl class="mt-4 space-y-3 text-sm">
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="rounded border border-stone-200 bg-white p-6 shadow-sm">
|
||||
<h2 class="text-sm font-semibold uppercase tracking-wide text-stone-500">Transcribe</h2>
|
||||
<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 dark:text-zinc-400">Transcribe</h2>
|
||||
<form method="POST" action="{{ route('recordings.transcribe', $recording) }}" class="mt-4">
|
||||
@csrf
|
||||
<button
|
||||
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>
|
||||
</button>
|
||||
@@ -94,7 +94,7 @@
|
||||
@csrf
|
||||
<button
|
||||
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
|
||||
</button>
|
||||
@@ -102,38 +102,38 @@
|
||||
</section>
|
||||
</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">
|
||||
<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
|
||||
type="button"
|
||||
x-show="!status.is_active && status.has_transcript"
|
||||
x-cloak
|
||||
@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
|
||||
</button>
|
||||
</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">
|
||||
<p class="font-medium text-amber-900" x-text="status.progress || 'Working…'"></p>
|
||||
<p class="text-amber-800 tabular-nums">
|
||||
<p class="font-medium text-amber-900 dark:text-amber-100" x-text="status.progress || 'Working…'"></p>
|
||||
<p class="tabular-nums text-amber-800 dark:text-amber-200">
|
||||
<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>
|
||||
</p>
|
||||
</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
|
||||
class="h-full rounded-full bg-amber-500 transition-all duration-500"
|
||||
:style="`width: ${Math.max(status.percent || 5, 5)}%`"
|
||||
></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>
|
||||
Engine:
|
||||
<span class="font-medium" x-text="status.driver_label || '—'"></span>
|
||||
@@ -142,26 +142,26 @@
|
||||
<li>
|
||||
Audio length:
|
||||
<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>
|
||||
</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>
|
||||
</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.
|
||||
</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 x-text="status.error || 'Check the logs and try again with another engine.'"></p>
|
||||
</div>
|
||||
|
||||
<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
|
||||
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-text="status.transcribed_at ? ('Transcribed ' + formatTimestamp(status.transcribed_at)) : ''"
|
||||
></p>
|
||||
@@ -170,7 +170,7 @@
|
||||
<p
|
||||
x-show="!status.is_active && status.status !== 'failed' && status.status !== 'cancelled' && !status.has_transcript"
|
||||
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.
|
||||
</p>
|
||||
|
||||
Reference in New Issue
Block a user