Add first edition of AndyTranscribe.
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
||||
<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'])
|
||||
</head>
|
||||
<body class="min-h-screen bg-stone-100 text-stone-900 antialiased">
|
||||
<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">
|
||||
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>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<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">
|
||||
{{ 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">
|
||||
{{ 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">
|
||||
<ul class="list-disc space-y-1 pl-5">
|
||||
@foreach ($errors->all() as $error)
|
||||
<li>{{ $error }}</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@yield('content')
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,50 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', 'Upload recording')
|
||||
|
||||
@section('content')
|
||||
<div class="mb-8">
|
||||
<h1 class="text-2xl font-semibold tracking-tight">Upload recording</h1>
|
||||
<p class="mt-1 text-sm text-stone-600">Upload an MP3 from your pocket recorder. ID3 metadata is extracted automatically.</p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action="{{ route('recordings.store') }}"
|
||||
enctype="multipart/form-data"
|
||||
class="max-w-xl space-y-6 rounded border border-stone-200 bg-white p-6 shadow-sm"
|
||||
>
|
||||
@csrf
|
||||
|
||||
<div>
|
||||
<label for="audio" class="block text-sm font-medium text-stone-700">MP3 file</label>
|
||||
<input
|
||||
id="audio"
|
||||
type="file"
|
||||
name="audio"
|
||||
accept=".mp3,audio/mpeg"
|
||||
required
|
||||
class="mt-2 block w-full text-sm text-stone-600 file:mr-4 file:rounded file:border-0 file:bg-teal-50 file:px-3 file:py-2 file:text-sm file:font-medium file:text-teal-800 hover:file:bg-teal-100"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="title" class="block text-sm font-medium text-stone-700">Title (optional)</label>
|
||||
<input
|
||||
id="title"
|
||||
type="text"
|
||||
name="title"
|
||||
value="{{ old('title') }}"
|
||||
placeholder="Leave blank to use ID3 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"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<button type="submit" class="rounded bg-teal-700 px-4 py-2 text-sm font-medium text-white hover:bg-teal-800">
|
||||
Upload
|
||||
</button>
|
||||
<a href="{{ route('recordings.index') }}" class="text-sm text-stone-600 hover:underline">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
@endsection
|
||||
@@ -0,0 +1,77 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', 'Recordings')
|
||||
|
||||
@section('content')
|
||||
<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 MP3s and transcripts.</p>
|
||||
</div>
|
||||
<form method="GET" action="{{ route('recordings.index') }}" class="flex gap-2">
|
||||
<input
|
||||
type="search"
|
||||
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"
|
||||
>
|
||||
<button type="submit" class="rounded border border-stone-300 bg-white px-3 py-2 text-sm hover:bg-stone-50">
|
||||
Search
|
||||
</button>
|
||||
</form>
|
||||
</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">
|
||||
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">
|
||||
<tr>
|
||||
<th class="px-4 py-3">Title</th>
|
||||
<th class="px-4 py-3">Duration</th>
|
||||
<th class="px-4 py-3">Status</th>
|
||||
<th class="px-4 py-3">Engine</th>
|
||||
<th class="px-4 py-3">Uploaded</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-stone-100">
|
||||
@foreach ($recordings as $recording)
|
||||
<tr class="hover:bg-stone-50">
|
||||
<td class="px-4 py-3">
|
||||
<a href="{{ route('recordings.show', $recording) }}" class="font-medium text-teal-800 hover:underline">
|
||||
{{ $recording->title }}
|
||||
</a>
|
||||
@if ($recording->artist)
|
||||
<div class="text-xs text-stone-500">{{ $recording->artist }}</div>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-4 py-3 text-stone-600">{{ $recording->duration_formatted }}</td>
|
||||
<td class="px-4 py-3">
|
||||
@include('recordings.partials.status-badge', ['status' => $recording->transcription_status])
|
||||
</td>
|
||||
<td class="px-4 py-3 text-stone-600">
|
||||
@if ($recording->transcription_driver)
|
||||
<span class="uppercase tracking-wide text-xs">{{ $recording->transcription_driver }}</span>
|
||||
@else
|
||||
—
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-4 py-3 text-stone-600">{{ $recording->created_at?->format('Y-m-d H:i') }}</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
{{ $recordings->links() }}
|
||||
</div>
|
||||
@endif
|
||||
@endsection
|
||||
@@ -0,0 +1,11 @@
|
||||
@php
|
||||
$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',
|
||||
'failed' => 'bg-red-50 text-red-800 ring-red-600/20',
|
||||
default => 'bg-stone-100 text-stone-700 ring-stone-500/20',
|
||||
};
|
||||
@endphp
|
||||
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ring-1 ring-inset {{ $classes }}">
|
||||
{{ $status }}
|
||||
</span>
|
||||
@@ -0,0 +1,157 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', $recording->title)
|
||||
|
||||
@section('content')
|
||||
<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>
|
||||
<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">
|
||||
@include('recordings.partials.status-badge', ['status' => $recording->transcription_status])
|
||||
@if ($recording->transcription_driver)
|
||||
<span class="text-xs uppercase tracking-wide text-stone-500">{{ $recording->transcription_driver }}</span>
|
||||
@endif
|
||||
</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">
|
||||
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>
|
||||
<dl class="mt-4 space-y-3 text-sm">
|
||||
<div class="flex justify-between gap-4">
|
||||
<dt class="text-stone-500">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>
|
||||
<dd class="font-medium">{{ $recording->duration_formatted }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-4">
|
||||
<dt class="text-stone-500">Artist</dt>
|
||||
<dd class="font-medium">{{ $recording->artist ?: '—' }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-4">
|
||||
<dt class="text-stone-500">Album</dt>
|
||||
<dd class="font-medium">{{ $recording->album ?: '—' }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-4">
|
||||
<dt class="text-stone-500">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>
|
||||
<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>
|
||||
<dd class="font-medium">{{ $recording->created_at?->format('Y-m-d H:i') }}</dd>
|
||||
</div>
|
||||
@if ($recording->ollama_url)
|
||||
<div class="flex justify-between gap-4">
|
||||
<dt class="text-stone-500">Ollama host</dt>
|
||||
<dd class="max-w-[60%] break-all text-right font-medium">{{ $recording->ollama_url }}</dd>
|
||||
</div>
|
||||
@endif
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section
|
||||
x-data="{ driver: '{{ old('driver', $recording->transcription_driver ?: 'cloud') }}' }"
|
||||
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>
|
||||
<p class="mt-2 text-sm text-stone-600">Choose how to convert this MP3 to text.</p>
|
||||
|
||||
<form method="POST" action="{{ route('recordings.transcribe', $recording) }}" class="mt-4 space-y-4">
|
||||
@csrf
|
||||
|
||||
<label class="flex cursor-pointer gap-3 rounded border border-stone-200 p-3 hover:bg-stone-50">
|
||||
<input type="radio" name="driver" value="cloud" x-model="driver" class="mt-1 text-teal-700 focus:ring-teal-600">
|
||||
<span>
|
||||
<span class="block text-sm font-medium">Cloud (OpenAI Whisper)</span>
|
||||
<span class="block text-xs text-stone-500">Fast; audio is sent to OpenAI.</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="flex cursor-pointer gap-3 rounded border border-stone-200 p-3 hover:bg-stone-50">
|
||||
<input type="radio" name="driver" value="local" x-model="driver" class="mt-1 text-teal-700 focus:ring-teal-600">
|
||||
<span>
|
||||
<span class="block text-sm font-medium">Local — confidential (faster-whisper)</span>
|
||||
<span class="block text-xs text-stone-500">Requires faster-whisper-server on this machine (Docker).</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="flex cursor-pointer gap-3 rounded border border-stone-200 p-3 hover:bg-stone-50">
|
||||
<input type="radio" name="driver" value="ollama" x-model="driver" class="mt-1 text-teal-700 focus:ring-teal-600">
|
||||
<span>
|
||||
<span class="block text-sm font-medium">Ollama host</span>
|
||||
<span class="block text-xs text-stone-500">
|
||||
Remote host URL. Must expose OpenAI-compatible <code class="text-[11px]">/v1/audio/transcriptions</code>
|
||||
(e.g. Whisper beside Ollama).
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div x-show="driver === 'ollama'" x-cloak class="space-y-2">
|
||||
<label for="ollama_url" class="block text-sm font-medium text-stone-700">Host URL</label>
|
||||
<input
|
||||
id="ollama_url"
|
||||
type="url"
|
||||
name="ollama_url"
|
||||
value="{{ old('ollama_url', $recording->ollama_url) }}"
|
||||
placeholder="http://192.168.1.50:8000"
|
||||
class="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"
|
||||
>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
@disabled($recording->transcription_status === 'processing')
|
||||
class="rounded bg-teal-700 px-4 py-2 text-sm font-medium text-white hover:bg-teal-800 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{{ $recording->transcript ? 'Re-transcribe' : 'Start transcription' }}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="mt-6 rounded border border-stone-200 bg-white p-6 shadow-sm">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<h2 class="text-sm font-semibold uppercase tracking-wide text-stone-500">Transcript</h2>
|
||||
@if ($recording->transcript)
|
||||
<button
|
||||
type="button"
|
||||
onclick="navigator.clipboard.writeText(@js($recording->transcript))"
|
||||
class="text-sm text-teal-700 hover:underline"
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if ($recording->transcription_status === 'processing')
|
||||
<p class="mt-4 text-sm text-amber-700">Transcription in progress… Refresh shortly.</p>
|
||||
@elseif ($recording->transcription_status === 'failed')
|
||||
<p class="mt-4 text-sm text-red-700">Transcription failed. Check the logs and try again with another engine.</p>
|
||||
@elseif ($recording->transcript)
|
||||
<p class="mt-4 whitespace-pre-wrap text-sm leading-relaxed text-stone-800">{{ $recording->transcript }}</p>
|
||||
@if ($recording->transcribed_at)
|
||||
<p class="mt-4 text-xs text-stone-500">Transcribed {{ $recording->transcribed_at->format('Y-m-d H:i') }}</p>
|
||||
@endif
|
||||
@else
|
||||
<p class="mt-4 text-sm text-stone-500">No transcript yet. Choose an engine above to start.</p>
|
||||
@endif
|
||||
</section>
|
||||
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||
<style>[x-cloak]{display:none!important}</style>
|
||||
@endsection
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user