Add first edition of AndyTranscribe.

This commit is contained in:
ben
2026-08-12 13:59:33 +02:00
parent 9c8461405f
commit 771658040b
100 changed files with 18039 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
@import 'tailwindcss';
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
@source '../../storage/framework/views/*.php';
@source '../views';
@source '../js';
@theme {
--font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
'Segoe UI Symbol', 'Noto Color Emoji';
}
+1
View File
@@ -0,0 +1 @@
//
+50
View File
@@ -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>
+157
View File
@@ -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