fix(uploads): serialize status filters and resize oversized covers
ci / module boundaries / boundaries (push) Successful in 9s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m37s
ci / api / api (push) Successful in 58s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m22s
ci / db / db (push) Successful in 6m19s
ci / ts / ts (packages + web) (push) Failing after 27m2s
ci / module boundaries / boundaries (push) Successful in 9s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m37s
ci / api / api (push) Successful in 58s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m22s
ci / db / db (push) Successful in 6m19s
ci / ts / ts (packages + web) (push) Failing after 27m2s
This commit is contained in:
1 parent
ca2a50b515
commit
e78cab6ce6
11 files changed
+152
-17
No files matched your search
@@ -2,6 +2,7 @@ import { useMutation } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import { useErrorText } from '../../shared/lib/errors';
|
||||
import { useUploadsApi } from './queries';
|
||||
import { coverBase64 } from './files';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Dialog, Field, FormError } from '../../shared/ui/ui';
|
||||
import s from '../../pages/page.module.css';
|
||||
@@ -13,6 +14,7 @@ export function BatchEditDialog({ batchId, onClose, onSaved }: { batchId: string
|
||||
const [album, setAlbum] = useState('');
|
||||
const [year, setYear] = useState('');
|
||||
const [cover, setCover] = useState<string | null | undefined>(undefined);
|
||||
const prepare = useMutation({ mutationFn: coverBase64, onSuccess: setCover });
|
||||
const save = useMutation({
|
||||
mutationFn: () => api.updateBatch(batchId, {
|
||||
...(artist.trim() ? { artist: artist.trim() } : {}),
|
||||
@@ -23,17 +25,13 @@ export function BatchEditDialog({ batchId, onClose, onSaved }: { batchId: string
|
||||
onSuccess: onSaved,
|
||||
});
|
||||
const pickCover = (file: File | undefined) => {
|
||||
save.reset();
|
||||
prepare.reset();
|
||||
setCover(undefined);
|
||||
if (!file) {
|
||||
setCover(undefined);
|
||||
return;
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const data = String(reader.result ?? '');
|
||||
const comma = data.indexOf(',');
|
||||
setCover(comma >= 0 ? data.slice(comma + 1) : data);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
prepare.mutate(file);
|
||||
};
|
||||
return (
|
||||
<Dialog
|
||||
@@ -44,7 +42,7 @@ export function BatchEditDialog({ batchId, onClose, onSaved }: { batchId: string
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
{t('common:form.cancel')}
|
||||
</Button>
|
||||
<Button variant="primary" busy={save.isPending} onClick={() => save.mutate()}>
|
||||
<Button variant="primary" busy={save.isPending || prepare.isPending} disabled={prepare.isError} onClick={() => save.mutate()}>
|
||||
{t('uploads.save')}
|
||||
</Button>
|
||||
</>
|
||||
@@ -57,12 +55,12 @@ export function BatchEditDialog({ batchId, onClose, onSaved }: { batchId: string
|
||||
<Field label={t('uploads.meta_year')} value={year} inputMode="numeric" onChange={setYear} />
|
||||
<label className={s.sessions}>
|
||||
<span>{t('uploads.batch_cover')}</span>
|
||||
<input type="file" accept="image/jpeg,image/png,image/webp" onChange={(e) => pickCover(e.target.files?.[0])} />
|
||||
<input type="file" accept="image/jpeg,image/png,image/webp" disabled={prepare.isPending || save.isPending} onChange={(e) => pickCover(e.target.files?.[0])} />
|
||||
</label>
|
||||
<Button variant="ghost" onClick={() => setCover(null)}>
|
||||
<Button variant="ghost" disabled={prepare.isPending || save.isPending} onClick={() => { prepare.reset(); setCover(null); }}>
|
||||
{t('uploads.batch_cover_clear')}
|
||||
</Button>
|
||||
<FormError>{save.error ? errorText(save.error) : null}</FormError>
|
||||
<FormError>{(save.error ?? prepare.error) ? errorText(save.error ?? prepare.error) : null}</FormError>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { createApiClient } from '@pipu/api-client';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('upload status query serialization', () => {
|
||||
it('sends comma-separated statuses for own and admin queues, including batch selection', async () => {
|
||||
const urls: URL[] = [];
|
||||
const client = createApiClient({ baseUrl: 'https://pipu.test', fetch: async (input) => {
|
||||
urls.push(new URL((input as Request).url));
|
||||
return new Response(JSON.stringify({ items: [], next_cursor: null }), { headers: { 'Content-Type': 'application/json' } });
|
||||
} });
|
||||
await client.GET('/api/v1/uploads/mine', { params: { query: { status: ['pending_review', 'imported', 'expired'], limit: 50 } } });
|
||||
await client.GET('/api/v1/admin/uploads', { params: { query: { status: ['uploading', 'processing'], limit: 100 } } });
|
||||
await client.GET('/api/v1/uploads/mine', { params: { query: { batch: '00000000-0000-4000-8000-000000000001', status: ['uploading', 'processing', 'pending_review'] } } });
|
||||
expect(urls.map((url) => url.searchParams.getAll('status'))).toEqual([
|
||||
['pending_review,imported,expired'], ['uploading,processing'], ['uploading,processing,pending_review'],
|
||||
]);
|
||||
expect(urls[0]!.searchParams.get('limit')).toBe('50');
|
||||
expect(urls[2]!.searchParams.get('batch')).toBe('00000000-0000-4000-8000-000000000001');
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,31 @@
|
||||
// Сбор файлов из drag-and-drop и выбора папки с относительными путями (они подсказывают альбом, docs/uploads.md#5).
|
||||
import { ApiError } from '@pipu/core';
|
||||
|
||||
const MAX_COVER_BYTES = 8 * 1024 * 1024;
|
||||
|
||||
/** Reduce oversized artwork before base64 expansion; small covers keep their original bytes. */
|
||||
async function prepareCover(file: File): Promise<Blob> {
|
||||
if (file.size <= MAX_COVER_BYTES) return file;
|
||||
let image: ImageBitmap;
|
||||
try { image = await createImageBitmap(file); }
|
||||
catch { throw new ApiError(422, 'validation.cover_type'); }
|
||||
try {
|
||||
const scale = Math.min(1, 2048 / Math.max(image.width, image.height));
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = Math.max(1, Math.round(image.width * scale));
|
||||
canvas.height = Math.max(1, Math.round(image.height * scale));
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) throw new ApiError(422, 'validation.cover_type');
|
||||
context.fillStyle = '#fff';
|
||||
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||
context.drawImage(image, 0, 0, canvas.width, canvas.height);
|
||||
for (const quality of [0.9, 0.75]) {
|
||||
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, 'image/jpeg', quality));
|
||||
if (blob && blob.size <= MAX_COVER_BYTES) return blob;
|
||||
}
|
||||
throw new ApiError(422, 'validation.cover_too_large');
|
||||
} finally { image.close(); }
|
||||
}
|
||||
|
||||
export interface PickedFile {
|
||||
file: File;
|
||||
@@ -40,12 +67,13 @@ export function albumFolder(relPath: string): string {
|
||||
return at < 0 ? '' : path.slice(0, at);
|
||||
}
|
||||
|
||||
export function coverBase64(file: File): Promise<string> {
|
||||
export async function coverBase64(file: File): Promise<string> {
|
||||
const prepared = await prepareCover(file);
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result).split(',')[1]!);
|
||||
reader.onerror = () => reject(reader.error);
|
||||
reader.readAsDataURL(file);
|
||||
reader.readAsDataURL(prepared);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user