Move recordings UI to Livewire pages with Flux components.

Replace controller-driven Blade views with full-page Livewire index/show/create flows so Flux tables, toasts, and uploads work natively.
This commit is contained in:
ben
2026-08-12 19:17:52 +02:00
parent bfcfc12f58
commit 34ccf0c32b
37 changed files with 1482 additions and 1103 deletions
-2
View File
@@ -1,7 +1,5 @@
import './echo';
import { recordingsIndex, transcriptionMonitor } from './transcription';
import { uploadDropzone } from './upload';
window.transcriptionMonitor = transcriptionMonitor;
window.recordingsIndex = recordingsIndex;
window.uploadDropzone = uploadDropzone;
+72 -12
View File
@@ -2,16 +2,21 @@
* Shared helpers and Alpine components for live transcription updates via Reverb.
*/
const BADGE_CLASSES = {
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',
const BADGE_COLORS = {
done: 'teal',
processing: 'amber',
pending: 'amber',
failed: 'red',
cancelled: 'zinc',
};
export function badgeColorFor(status) {
return BADGE_COLORS[status] || 'zinc';
}
/** @deprecated Use badgeColorFor — kept for any leftover callers */
export function badgeClassFor(status) {
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';
return badgeColorFor(status);
}
export function formatElapsed(seconds) {
@@ -85,13 +90,16 @@ function subscribeToRecordings(recordingIds, handler) {
export function transcriptionMonitor({ statusUrl, initial }) {
return {
statusUrl,
status: initial,
status: {
...initial,
badge_color: badgeColorFor(initial.status),
},
pollError: null,
tickTimer: null,
leaveChannel: null,
get badgeClass() {
return badgeClassFor(this.status.status);
get badgeColor() {
return badgeColorFor(this.status.status);
},
get startButtonLabel() {
@@ -128,7 +136,11 @@ export function transcriptionMonitor({ statusUrl, initial }) {
applyPayload(payload) {
const wasActive = this.status.is_active;
this.status = { ...this.status, ...payload };
this.status = {
...this.status,
...payload,
badge_color: badgeColorFor(payload.status ?? this.status.status),
};
this.pollError = null;
if (this.status.is_active) {
@@ -209,6 +221,8 @@ export function recordingsIndex({ recordings, pendingCount }) {
rows: byId,
pendingCount: Number(pendingCount) || 0,
leaveChannel: null,
playingId: null,
isPlaying: false,
start() {
this.leaveChannel = subscribeToRecordings(Object.keys(this.rows), (event) => {
@@ -221,6 +235,52 @@ export function recordingsIndex({ recordings, pendingCount }) {
this.leaveChannel();
this.leaveChannel = null;
}
const player = this.$refs.player;
if (player) {
player.pause();
player.removeAttribute('src');
player.load();
}
},
syncPlayer() {
const player = this.$refs.player;
this.isPlaying = Boolean(player && !player.paused && !player.ended);
if (player?.ended) {
this.playingId = null;
}
},
isPlayingRow(id) {
return this.playingId === id && this.isPlaying;
},
togglePlay(id, url) {
const player = this.$refs.player;
if (!player) {
return;
}
if (this.playingId === id && this.isPlaying) {
player.pause();
return;
}
if (this.playingId !== id) {
player.src = url;
this.playingId = id;
}
player.play().catch(() => {
this.playingId = null;
this.isPlaying = false;
});
},
applyPayload(payload) {
@@ -240,7 +300,7 @@ export function recordingsIndex({ recordings, pendingCount }) {
is_active: payload.is_active,
word_count: payload.word_count ?? this.rows[id].word_count,
word_count_display: formatWordCount(payload.word_count ?? this.rows[id].word_count),
badge_class: badgeClassFor(payload.status),
badge_color: badgeColorFor(payload.status),
};
this.rows[id] = next;
-165
View File
@@ -1,165 +0,0 @@
/**
* Upload dropzone: discard duplicate files in the selection (and known server fingerprints)
* before submitting the form.
*/
const MAX_FILE_BYTES = 2 * 1024 * 1024 * 1024;
export function uploadDropzone({ existingFingerprints = [] } = {}) {
const acceptExt = ['.mp3', '.wav', '.ogg', '.oga', '.flac', '.m4a', '.mp4', '.aac', '.webm', '.wma', '.aiff', '.aif'];
const known = new Set(existingFingerprints);
return {
files: [],
dragging: false,
uploading: false,
error: null,
notice: 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;
this.notice = null;
const accepted = [];
let skippedUnsupported = 0;
let skippedTooLarge = 0;
let skippedDuplicates = 0;
for (const file of incoming) {
if (! this.isAccepted(file)) {
skippedUnsupported++;
continue;
}
if (file.size > MAX_FILE_BYTES) {
skippedTooLarge++;
continue;
}
const fingerprint = this.fingerprint(file);
if (known.has(fingerprint) || this.files.some((existing) => this.fingerprint(existing) === fingerprint)) {
skippedDuplicates++;
continue;
}
if (accepted.some((existing) => this.fingerprint(existing) === fingerprint)) {
skippedDuplicates++;
continue;
}
accepted.push(file);
}
this.files = [...this.files, ...accepted];
if (this.files.length > 50) {
this.error = 'You can upload at most 50 files at once.';
this.files = this.files.slice(0, 50);
}
if (skippedUnsupported > 0) {
this.error = 'Skipped unsupported file type. Use common audio formats only.';
} else if (skippedTooLarge > 0) {
this.error = 'Skipped a file larger than 2 GB.';
}
if (skippedDuplicates > 0) {
this.notice = skippedDuplicates === 1
? 'Skipped 1 duplicate file.'
: `Skipped ${skippedDuplicates} duplicate files.`;
}
this.syncInput();
},
isAccepted(file) {
const name = (file.name || '').toLowerCase();
if (acceptExt.some((ext) => name.endsWith(ext))) {
return true;
}
return (file.type || '').startsWith('audio/');
},
fingerprint(file) {
return `${String(file.name || '').toLowerCase()}:${Number(file.size) || 0}`;
},
fileListKey(file, index = 0) {
return `${this.fingerprint(file)}:${index}`;
},
removeFile(index) {
this.files.splice(index, 1);
this.syncInput();
},
clearFiles() {
this.files = [];
this.notice = null;
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';
}
if (bytes < 1024 * 1024 * 1024) {
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}
return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
},
};
}