sounds: upload, convert, play, rename, delete with references
/api/sounds (admin) takes the raw file, converts it with ffmpeg in one pass to <id>.wav (8 kHz) and <id>.g722 (HD for G.722 calls) in SOUNDS_DIR, measures it with ffprobe, and stores it as custom/<id>. Delete answers 409 with what still plays it (IVR greeting/invalid sound, announcements, the recording notice). Sounds & music page: drop or pick a file (name suggested from the file name), play, rename, delete. MOH classes stay in M8. The web client and greetings share one raw-upload helper; the api test audio helpers moved to test/audio.ts. e2e m6-t1: drag-and-drop upload in the browser, Asterisk plays custom/<id>, rename, delete refused while in use, then deleted. Task: M6-T1
This commit is contained in:
1 parent
609965bba5
commit
17db68a8c5
19 files changed
+752
-60
No files matched your search
+35
-15
@@ -17,6 +17,7 @@ import type {
|
||||
RouteTestResult,
|
||||
SettingsDto,
|
||||
SipCredentials,
|
||||
SoundDto,
|
||||
TrunkCreate,
|
||||
TrunkDto,
|
||||
TrunkTestResult,
|
||||
@@ -59,6 +60,25 @@ async function api<T>(method: string, url: string, body?: unknown): Promise<T> {
|
||||
return data as T;
|
||||
}
|
||||
|
||||
/** Audio uploads: the file itself is the body (no multipart), with its own Content-Type. */
|
||||
async function upload<T>(method: string, url: string, body: Blob): Promise<T> {
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'X-Requested-With': 'selfphoneng',
|
||||
'Content-Type': body.type || 'application/octet-stream',
|
||||
},
|
||||
body,
|
||||
});
|
||||
const data = await res.json().catch(() => null);
|
||||
if (!res.ok) {
|
||||
const e = data?.error ?? {};
|
||||
throw new ApiError(res.status, e.code ?? 'internal', e.message ?? res.statusText, e.details);
|
||||
}
|
||||
return data as T;
|
||||
}
|
||||
|
||||
export const authApi = {
|
||||
me: () => api<{ user: AuthUser }>('GET', '/api/auth/me').then((r) => r.user),
|
||||
login: (email: string, password: string) =>
|
||||
@@ -157,20 +177,20 @@ export const meApi = {
|
||||
greetings: () => api<Record<GreetingKind, boolean>>('GET', '/api/me/greetings'),
|
||||
greetingUrl: (kind: GreetingKind) => `/api/me/greetings/${kind}/audio`,
|
||||
/** The raw recording or file is the body; the server converts it with ffmpeg. */
|
||||
putGreeting: async (kind: GreetingKind, audio: Blob) => {
|
||||
const res = await fetch(`/api/me/greetings/${kind}`, {
|
||||
method: 'PUT',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'X-Requested-With': 'selfphoneng',
|
||||
'Content-Type': audio.type || 'application/octet-stream',
|
||||
},
|
||||
body: audio,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const e = (await res.json().catch(() => null))?.error ?? {};
|
||||
throw new ApiError(res.status, e.code ?? 'internal', e.message ?? res.statusText, e.details);
|
||||
}
|
||||
},
|
||||
putGreeting: (kind: GreetingKind, audio: Blob) =>
|
||||
upload<{ ok: true }>('PUT', `/api/me/greetings/${kind}`, audio),
|
||||
deleteGreeting: (kind: GreetingKind) => api<{ ok: true }>('DELETE', `/api/me/greetings/${kind}`),
|
||||
};
|
||||
|
||||
export const soundsApi = {
|
||||
list: () => api<ListResponse<SoundDto>>('GET', '/api/sounds').then((r) => r.items),
|
||||
audioUrl: (id: string) => `/api/sounds/${id}/audio`,
|
||||
upload: (name: string, file: File) =>
|
||||
upload<SoundDto>(
|
||||
'POST',
|
||||
`/api/sounds?${new URLSearchParams({ name, filename: file.name })}`,
|
||||
file,
|
||||
),
|
||||
rename: (id: string, name: string) => api<SoundDto>('PATCH', `/api/sounds/${id}`, { name }),
|
||||
remove: (id: string) => api<{ ok: true }>('DELETE', `/api/sounds/${id}`),
|
||||
};
|
||||
@@ -14,6 +14,7 @@ import { TrunksPage } from './pages/admin/TrunksPage.tsx';
|
||||
import { NumbersPage } from './pages/admin/NumbersPage.tsx';
|
||||
import { OutboundPage } from './pages/admin/OutboundPage.tsx';
|
||||
import { SettingsPage } from './pages/admin/SettingsPage.tsx';
|
||||
import { SoundsPage } from './pages/admin/SoundsPage.tsx';
|
||||
import { MePage } from './pages/user/MePage.tsx';
|
||||
import { SetupPage } from './pages/SetupPage.tsx';
|
||||
import './index.css';
|
||||
@@ -41,6 +42,8 @@ const router = createBrowserRouter([
|
||||
<NumbersPage />
|
||||
) : p.path === 'trunks' ? (
|
||||
<TrunksPage />
|
||||
) : p.path === 'sounds' ? (
|
||||
<SoundsPage />
|
||||
) : (
|
||||
<ComingSoon label={p.label} milestone={p.milestone ?? ''} />
|
||||
),
|
||||
|
||||
@@ -21,7 +21,7 @@ export const ADMIN_PAGES = [
|
||||
{ path: 'hours', label: 'Office hours', milestone: 'M6' },
|
||||
{ path: 'conferences', label: 'Conferences', milestone: 'M8' },
|
||||
{ path: 'paging', label: 'Paging', milestone: 'M8' },
|
||||
{ path: 'sounds', label: 'Sounds & music', milestone: 'M6' },
|
||||
{ path: 'sounds', label: 'Sounds & music', milestone: null },
|
||||
{ path: 'devices', label: 'Phones', milestone: 'M10' },
|
||||
{ path: 'contacts', label: 'Contacts', milestone: 'M10' },
|
||||
{ path: 'calls', label: 'Call history', milestone: 'M7' },
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Sounds & music (05 §2): recorded prompts for digital receptionists and announcements.
|
||||
// Upload by picking or dropping a file; play, rename, delete. Music on hold arrives with M8.
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import { SOUND_MAX_BYTES, type SoundDto } from '@selfphoneng/shared';
|
||||
import { soundsApi } from '../../api/client.ts';
|
||||
import { ConfirmDialog, Dialog } from '../../components/Dialog.tsx';
|
||||
import { Alert, Button, Field, inputClass } from '../../components/ui.tsx';
|
||||
|
||||
const secs = (ms: number) =>
|
||||
`${Math.floor(ms / 60000)}:${String(Math.round(ms / 1000) % 60).padStart(2, '0')}`;
|
||||
const stem = (filename: string) =>
|
||||
filename
|
||||
.replace(/\.[^.]+$/, '')
|
||||
.replace(/[_-]+/g, ' ')
|
||||
.trim();
|
||||
|
||||
export function SoundsPage() {
|
||||
const qc = useQueryClient();
|
||||
const sounds = useQuery({ queryKey: ['sounds'], queryFn: soundsApi.list });
|
||||
const refresh = () => qc.invalidateQueries({ queryKey: ['sounds'] });
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [name, setName] = useState('');
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [renaming, setRenaming] = useState<SoundDto | null>(null);
|
||||
const [deleting, setDeleting] = useState<SoundDto | null>(null);
|
||||
|
||||
const add = useMutation({
|
||||
mutationFn: () => soundsApi.upload(name.trim(), file!),
|
||||
onSuccess: async () => {
|
||||
setFile(null);
|
||||
setName('');
|
||||
await refresh();
|
||||
},
|
||||
});
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: string) => soundsApi.remove(id),
|
||||
onSuccess: async () => {
|
||||
setDeleting(null);
|
||||
await refresh();
|
||||
},
|
||||
});
|
||||
|
||||
function pick(f: File | undefined) {
|
||||
if (!f) return;
|
||||
add.reset();
|
||||
setFile(f);
|
||||
if (!name.trim()) setName(stem(f.name));
|
||||
}
|
||||
const tooBig = file !== null && file.size > SOUND_MAX_BYTES;
|
||||
|
||||
return (
|
||||
<section className="flex flex-col gap-4">
|
||||
<h1 className="text-xl font-semibold">Sounds & music</h1>
|
||||
<form
|
||||
aria-label="Upload a sound"
|
||||
className={`flex flex-col gap-3 rounded-lg border-2 border-dashed p-4 ${dragging ? 'border-sky-500 bg-sky-50 dark:bg-sky-950' : 'border-zinc-300 dark:border-zinc-700'}`}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setDragging(true);
|
||||
}}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setDragging(false);
|
||||
pick(e.dataTransfer.files[0]);
|
||||
}}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (file && name.trim() && !tooBig) add.mutate();
|
||||
}}
|
||||
>
|
||||
<p className="text-sm">
|
||||
Drop an audio file here, or pick one. Any common format works (mp3, wav, m4a, ogg, flac);
|
||||
it's converted for phones and evened out in volume. Up to 20 MB.
|
||||
</p>
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<Field label="File">
|
||||
{(p) => (
|
||||
<input
|
||||
{...p}
|
||||
type="file"
|
||||
accept="audio/*"
|
||||
className="text-sm"
|
||||
onChange={(e) => {
|
||||
pick(e.target.files?.[0]);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Name">
|
||||
{(p) => (
|
||||
<input
|
||||
{...p}
|
||||
className={inputClass}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Main greeting"
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={!file || !name.trim() || tooBig || add.isPending}
|
||||
>
|
||||
{add.isPending ? 'Converting…' : 'Upload'}
|
||||
</Button>
|
||||
</div>
|
||||
{file && <p className="text-sm text-zinc-500">Selected: {file.name}</p>}
|
||||
{tooBig && <Alert>That file is larger than 20 MB.</Alert>}
|
||||
{add.error && <Alert>{add.error.message}</Alert>}
|
||||
</form>
|
||||
|
||||
{sounds.isError && <Alert>{sounds.error.message}</Alert>}
|
||||
{sounds.data?.length === 0 && (
|
||||
<p className="text-sm text-zinc-500">
|
||||
No sounds yet. Upload greetings for your digital receptionist and announcements.
|
||||
</p>
|
||||
)}
|
||||
{sounds.data && sounds.data.length > 0 && (
|
||||
<ul className="divide-y divide-zinc-100 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800">
|
||||
{sounds.data.map((s) => (
|
||||
<li
|
||||
key={s.id}
|
||||
className="flex flex-wrap items-center gap-x-3 gap-y-2 px-3 py-2 text-sm"
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="font-medium">{s.name}</span>{' '}
|
||||
<span className="text-zinc-500">{secs(s.durationMs)}</span>
|
||||
</span>
|
||||
<audio
|
||||
controls
|
||||
preload="none"
|
||||
src={soundsApi.audioUrl(s.id)}
|
||||
aria-label={`Play ${s.name}`}
|
||||
className="h-9 w-full sm:w-64"
|
||||
/>
|
||||
<Button onClick={() => setRenaming(s)}>Rename</Button>
|
||||
<Button variant="ghost" onClick={() => setDeleting(s)}>
|
||||
Delete
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{renaming && (
|
||||
<RenameDialog sound={renaming} onClose={() => setRenaming(null)} onDone={refresh} />
|
||||
)}
|
||||
<ConfirmDialog
|
||||
open={deleting !== null}
|
||||
title="Delete sound"
|
||||
message={<p>Delete "{deleting?.name}"? This can't be undone.</p>}
|
||||
confirmLabel="Delete"
|
||||
busy={remove.isPending}
|
||||
error={remove.error?.message}
|
||||
onCancel={() => {
|
||||
setDeleting(null);
|
||||
remove.reset();
|
||||
}}
|
||||
onConfirm={() => deleting && remove.mutate(deleting.id)}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function RenameDialog({
|
||||
sound,
|
||||
onClose,
|
||||
onDone,
|
||||
}: {
|
||||
sound: SoundDto;
|
||||
onClose: () => void;
|
||||
onDone: () => Promise<unknown>;
|
||||
}) {
|
||||
const [name, setName] = useState(sound.name);
|
||||
const save = useMutation({
|
||||
mutationFn: () => soundsApi.rename(sound.id, name.trim()),
|
||||
onSuccess: async () => {
|
||||
await onDone();
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
onClose={onClose}
|
||||
title="Rename sound"
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!name.trim() || save.isPending}
|
||||
onClick={() => save.mutate()}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Field label="Name">
|
||||
{(p) => (
|
||||
<input
|
||||
{...p}
|
||||
className={inputClass}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
{save.error && <Alert>{save.error.message}</Alert>}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user