Add Docker stack, Reverb live progress, and duplicate upload detection.
Ship FrankenPHP Compose services with Reverb WebSockets for real-time transcription status, skip re-uploading identical audio via content hash, and format disk usage without requiring intl.
This commit is contained in:
@@ -7,6 +7,7 @@
|
||||
<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.
|
||||
Duplicate files (same name and size, or identical content) are skipped.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -14,7 +15,7 @@
|
||||
method="POST"
|
||||
action="{{ route('recordings.store') }}"
|
||||
enctype="multipart/form-data"
|
||||
x-data="uploadDropzone()"
|
||||
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"
|
||||
>
|
||||
@@ -61,7 +62,7 @@
|
||||
</div>
|
||||
|
||||
<ul class="divide-y divide-stone-100 rounded border border-stone-200">
|
||||
<template x-for="(file, index) in files" :key="fileKey(file, index)">
|
||||
<template x-for="(file, index) in files" :key="fileListKey(file, index)">
|
||||
<li class="flex items-center justify-between gap-3 px-3 py-2 text-sm">
|
||||
<div class="min-w-0">
|
||||
<p class="truncate font-medium text-stone-800" x-text="file.name"></p>
|
||||
@@ -91,6 +92,7 @@
|
||||
>
|
||||
</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>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
@@ -104,117 +106,4 @@
|
||||
<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
|
||||
|
||||
Reference in New Issue
Block a user