fix(uploads): serve private previews through API and show batch cover status
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m30s
ci / ts / ts (packages + web) (push) Canceled after 4m13s
ci / module boundaries / boundaries (push) Successful in 8s
ci / api / api (push) Successful in 56s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m1s
ci / db / db (push) Successful in 1m7s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m30s
ci / ts / ts (packages + web) (push) Canceled after 4m13s
ci / module boundaries / boundaries (push) Successful in 8s
ci / api / api (push) Successful in 56s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m1s
ci / db / db (push) Successful in 1m7s
This commit is contained in:
1 parent
37e33c656b
commit
7d62531c02
15 files changed
+533
-410
No files matched your search
@@ -0,0 +1,49 @@
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { refreshIcon } from '../../shared/ui/icons';
|
||||
import { Button } from '../../shared/ui/ui';
|
||||
import u from '../../pages/uploads/uploads.module.css';
|
||||
|
||||
export function AudioPreview({ url, name, active }: { url: string; name: string; active: boolean }) {
|
||||
const { t } = useTranslation('admin');
|
||||
const queryClient = useQueryClient();
|
||||
const audio = useRef<HTMLAudioElement>(null);
|
||||
const [error, setError] = useState<number | null>(null);
|
||||
const [httpStatus, setHttpStatus] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => { setError(null); setHttpStatus(null); }, [url]);
|
||||
useEffect(() => {
|
||||
if (error === null) return;
|
||||
const controller = new AbortController();
|
||||
// A media error alone cannot distinguish an HTTP failure from an unsupported codec.
|
||||
// Probe one byte using the same signed URL, then release the response immediately.
|
||||
void fetch(url, { headers: { Range: 'bytes=0-0' }, cache: 'no-store', signal: controller.signal })
|
||||
.then(async (response) => {
|
||||
await response.body?.cancel();
|
||||
if (!controller.signal.aborted) setHttpStatus(response.ok ? null : response.status);
|
||||
})
|
||||
.catch(() => { /* The native media error remains visible for network failures. */ });
|
||||
return () => controller.abort();
|
||||
}, [url, error]);
|
||||
|
||||
const retry = () => {
|
||||
setError(null);
|
||||
setHttpStatus(null);
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-uploads'] });
|
||||
audio.current?.load();
|
||||
};
|
||||
|
||||
return <div className={u.preview}>
|
||||
<audio ref={audio} controls preload={active ? 'metadata' : 'none'} src={url}
|
||||
aria-label={t('uploads.listen', { name })}
|
||||
onLoadedMetadata={() => { setError(null); setHttpStatus(null); }}
|
||||
onError={(event) => setError(event.currentTarget.error?.code ?? 0)} />
|
||||
{error !== null && <div className={u.previewError}>
|
||||
<span role="alert" className={u.bad}>{httpStatus !== null
|
||||
? t('uploads.preview_http_error', { status: httpStatus })
|
||||
: t(error === 3 || error === 4 ? 'uploads.preview_decode_error' : 'uploads.preview_error')}</span>
|
||||
<Button variant="ghost" icon={refreshIcon} onClick={retry}>{t('uploads.retry')}</Button>
|
||||
</div>}
|
||||
</div>;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { formatBytes, formatDateTime, formatDuration } from '../../shared/lib/fo
|
||||
import { chevronRightIcon, editIcon, refreshIcon } from '../../shared/ui/icons';
|
||||
import { useId, useState } from 'react';
|
||||
import { needsFormatAttention } from './files';
|
||||
import { AudioPreview } from './AudioPreview';
|
||||
import { UploadStatusBadge, useReasonText, type UploadFile } from './common';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, cx, Icon } from '../../shared/ui/ui';
|
||||
@@ -43,6 +44,9 @@ export function BatchCard({
|
||||
<b>{first.author.username}</b>
|
||||
<span>{formatDateTime(first.created_at, i18n.language)}</span>
|
||||
<span>{t('uploads.batch_size', { count: files.length, size: formatBytes(size, i18n.language) })}</span>
|
||||
<span className={cx(s.badge, first.batch_has_cover ? u.tone_info : u.tone_muted)}>
|
||||
{t(first.batch_has_cover ? 'uploads.batch_cover_present' : 'uploads.batch_cover_absent')}
|
||||
</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
{selectable && (
|
||||
<Button variant="ghost" onClick={() => onEditBatch(first.batch_id)}>
|
||||
@@ -56,6 +60,7 @@ export function BatchCard({
|
||||
<QueueRow
|
||||
key={f.id}
|
||||
file={f}
|
||||
active={!collapsed}
|
||||
selectable={selectable}
|
||||
checked={selected.has(f.id)}
|
||||
onCheck={(on) => onToggle([f.id], on)}
|
||||
@@ -70,6 +75,7 @@ export function BatchCard({
|
||||
|
||||
function QueueRow({
|
||||
file: f,
|
||||
active,
|
||||
selectable,
|
||||
checked,
|
||||
onCheck,
|
||||
@@ -77,6 +83,7 @@ function QueueRow({
|
||||
onRetry,
|
||||
}: {
|
||||
file: UploadFile;
|
||||
active: boolean;
|
||||
selectable: boolean;
|
||||
checked: boolean;
|
||||
onCheck: (on: boolean) => void;
|
||||
@@ -142,7 +149,7 @@ function QueueRow({
|
||||
</div>
|
||||
<div className={u.rowActions}>
|
||||
{f.stream_url && (
|
||||
<audio controls preload="metadata" src={f.stream_url} aria-label={t('uploads.listen', { name: f.original_name })} />
|
||||
<AudioPreview url={f.stream_url} name={f.original_name} active={active} />
|
||||
)}
|
||||
{editable && (
|
||||
<Button variant="ghost" aria-label={t('uploads.edit', { name: f.original_name })} onClick={onEdit}>
|
||||
|
||||
@@ -221,6 +221,10 @@
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.preview { width: 300px; max-width: 100%; min-width: 0; }
|
||||
.preview audio { display: block; width: 100%; }
|
||||
.previewError { display: flex; flex-direction: column; align-items: flex-start; gap: 4px; margin-top: 6px; font-size: 12px; }
|
||||
|
||||
.bulk {
|
||||
position: sticky;
|
||||
bottom: 12px;
|
||||
|
||||
Reference in new issue
Block a user