Batch uploads were only storing files as pending without dispatching Whisper jobs; queue them immediately, add a bulk pending action, and show human-readable status labels.
221 lines
8.7 KiB
PHP
221 lines
8.7 KiB
PHP
@extends('layouts.app')
|
|
|
|
@section('title', 'Upload recording')
|
|
|
|
@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">
|
|
Drop one or many pocket-recorder files. Embedded metadata is extracted when available.
|
|
</p>
|
|
</div>
|
|
|
|
<form
|
|
method="POST"
|
|
action="{{ route('recordings.store') }}"
|
|
enctype="multipart/form-data"
|
|
x-data="uploadDropzone()"
|
|
@submit="ensureFilesSelected($event)"
|
|
class="max-w-2xl space-y-6 rounded border border-stone-200 bg-white p-6 shadow-sm"
|
|
>
|
|
@csrf
|
|
|
|
<div>
|
|
<label class="block text-sm font-medium text-stone-700">Audio files</label>
|
|
|
|
<div
|
|
@dragenter.prevent="dragging = true"
|
|
@dragover.prevent="dragging = true"
|
|
@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="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 100 MB each · up to 50 files</p>
|
|
</div>
|
|
|
|
<input
|
|
x-ref="fileInput"
|
|
id="audio"
|
|
type="file"
|
|
name="audio[]"
|
|
accept=".mp3,.wav,.ogg,.oga,.flac,.m4a,.mp4,.aac,.webm,.wma,.aiff,.aif,audio/*"
|
|
multiple
|
|
class="sr-only"
|
|
@change="onBrowse($event)"
|
|
>
|
|
</div>
|
|
|
|
<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">
|
|
<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">
|
|
Clear all
|
|
</button>
|
|
</div>
|
|
|
|
<ul class="divide-y divide-stone-100 rounded border border-stone-200">
|
|
<template x-for="(file, index) in files" :key="fileKey(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>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
@click="removeFile(index)"
|
|
class="shrink-0 text-stone-500 hover:text-red-700"
|
|
>
|
|
Remove
|
|
</button>
|
|
</li>
|
|
</template>
|
|
</ul>
|
|
</div>
|
|
|
|
<div x-show="files.length === 1" x-cloak>
|
|
<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 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"
|
|
>
|
|
</div>
|
|
|
|
<p x-show="error" x-cloak class="text-sm text-red-700" x-text="error"></p>
|
|
|
|
<div class="flex items-center gap-3">
|
|
<button
|
|
type="submit"
|
|
:disabled="files.length === 0 || uploading"
|
|
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"
|
|
>
|
|
<span x-text="uploadLabel"></span>
|
|
</button>
|
|
<a href="{{ route('recordings.index') }}" class="text-sm text-stone-600 hover:underline">Cancel</a>
|
|
</div>
|
|
</form>
|
|
|
|
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
|
<style>[x-cloak]{display:none!important}</style>
|
|
<script>
|
|
function uploadDropzone() {
|
|
const acceptExt = ['.mp3', '.wav', '.ogg', '.oga', '.flac', '.m4a', '.mp4', '.aac', '.webm', '.wma', '.aiff', '.aif'];
|
|
|
|
return {
|
|
files: [],
|
|
dragging: false,
|
|
uploading: false,
|
|
error: null,
|
|
|
|
get uploadLabel() {
|
|
if (this.uploading) return 'Uploading…';
|
|
if (this.files.length <= 1) return 'Upload';
|
|
return 'Upload ' + this.files.length + ' files';
|
|
},
|
|
|
|
onBrowse(event) {
|
|
this.addFiles(Array.from(event.target.files || []));
|
|
},
|
|
|
|
onDrop(event) {
|
|
this.dragging = false;
|
|
this.addFiles(Array.from(event.dataTransfer?.files || []));
|
|
},
|
|
|
|
addFiles(incoming) {
|
|
this.error = null;
|
|
const accepted = [];
|
|
|
|
for (const file of incoming) {
|
|
if (! this.isAccepted(file)) {
|
|
this.error = 'Skipped unsupported file type. Use common audio formats only.';
|
|
continue;
|
|
}
|
|
if (file.size > 100 * 1024 * 1024) {
|
|
this.error = 'Skipped a file larger than 100 MB.';
|
|
continue;
|
|
}
|
|
accepted.push(file);
|
|
}
|
|
|
|
const merged = [...this.files, ...accepted];
|
|
const unique = [];
|
|
const seen = new Set();
|
|
|
|
for (const file of merged) {
|
|
const key = this.fileKey(file);
|
|
if (seen.has(key)) continue;
|
|
seen.add(key);
|
|
unique.push(file);
|
|
}
|
|
|
|
if (unique.length > 50) {
|
|
this.error = 'You can upload at most 50 files at once.';
|
|
this.files = unique.slice(0, 50);
|
|
} else {
|
|
this.files = unique;
|
|
}
|
|
|
|
this.syncInput();
|
|
},
|
|
|
|
isAccepted(file) {
|
|
const name = (file.name || '').toLowerCase();
|
|
if (acceptExt.some((ext) => name.endsWith(ext))) return true;
|
|
return (file.type || '').startsWith('audio/');
|
|
},
|
|
|
|
fileKey(file, index = 0) {
|
|
return [file.name, file.size, file.lastModified, index].join(':');
|
|
},
|
|
|
|
removeFile(index) {
|
|
this.files.splice(index, 1);
|
|
this.syncInput();
|
|
},
|
|
|
|
clearFiles() {
|
|
this.files = [];
|
|
this.syncInput();
|
|
},
|
|
|
|
syncInput() {
|
|
const input = this.$refs.fileInput;
|
|
if (! input) return;
|
|
|
|
const transfer = new DataTransfer();
|
|
this.files.forEach((file) => transfer.items.add(file));
|
|
input.files = transfer.files;
|
|
},
|
|
|
|
ensureFilesSelected(event) {
|
|
if (this.files.length === 0) {
|
|
event.preventDefault();
|
|
this.error = 'Drop or choose at least one audio file.';
|
|
return;
|
|
}
|
|
|
|
this.uploading = true;
|
|
this.error = null;
|
|
},
|
|
|
|
formatSize(bytes) {
|
|
if (bytes < 1024) return bytes + ' B';
|
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
|
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
|
},
|
|
};
|
|
}
|
|
</script>
|
|
@endsection
|