feat: upload flow revamp (0.20.0)
- `/upload` is now manga-only with optional N initial chapters staged inline. - Additional chapters from a new `/manga/[id]/upload-chapter` route, reached via an "Upload chapter" button on the manga page. - New `ChapterPagesEditor` component: thumbnails next to each row, click-to-preview-modal, drag-drop + reorder. - Pages renamed to `page-NNN.<ext>` before multipart submission; original filenames shown as dimmed reference text during upload and dropped on submit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -55,3 +55,66 @@ export async function getChapterPages(
|
||||
);
|
||||
return r.pages;
|
||||
}
|
||||
|
||||
export type NewChapter = {
|
||||
number: number;
|
||||
title?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* `POST /api/v1/mangas/:id/chapters` is multipart: a `metadata` part
|
||||
* (JSON) plus one or more ordered `page` parts. Each page file is
|
||||
* renamed to `page-NNN.<ext>` before submission so the user's
|
||||
* original filenames (often personally-identifying or just messy:
|
||||
* `IMG_2837.HEIC`, `~/scans/full chapter pack/`) don't end up in
|
||||
* request bodies or server logs. The bytes are unchanged — the
|
||||
* backend still sniffs the MIME from magic bytes and stores under
|
||||
* its own `{nnnn}.{ext}` scheme.
|
||||
*/
|
||||
export async function createChapter(
|
||||
mangaId: string,
|
||||
metadata: NewChapter,
|
||||
pages: File[]
|
||||
): Promise<Chapter> {
|
||||
const form = new FormData();
|
||||
form.append(
|
||||
'metadata',
|
||||
new Blob([JSON.stringify(metadata)], { type: 'application/json' })
|
||||
);
|
||||
pages.forEach((file, i) => {
|
||||
const ext = extensionFor(file);
|
||||
const renamed = new File(
|
||||
[file],
|
||||
`page-${String(i + 1).padStart(3, '0')}${ext}`,
|
||||
{ type: file.type }
|
||||
);
|
||||
form.append('page', renamed);
|
||||
});
|
||||
return request<Chapter>(
|
||||
`/v1/mangas/${encodeURIComponent(mangaId)}/chapters`,
|
||||
{ method: 'POST', body: form }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick a sensible extension for the renamed multipart part. Prefer
|
||||
* the original filename's extension when present (jpg/jpeg/png/webp/
|
||||
* gif/avif), otherwise derive from the MIME type. Falls back to an
|
||||
* empty string so the renamed file is just `page-001` — the
|
||||
* server sniffs bytes anyway.
|
||||
*/
|
||||
function extensionFor(file: File): string {
|
||||
const dot = file.name.lastIndexOf('.');
|
||||
if (dot > 0) {
|
||||
const ext = file.name.slice(dot).toLowerCase();
|
||||
if (/^\.(jpe?g|png|webp|gif|avif)$/.test(ext)) return ext;
|
||||
}
|
||||
const fromMime: Record<string, string> = {
|
||||
'image/jpeg': '.jpg',
|
||||
'image/png': '.png',
|
||||
'image/webp': '.webp',
|
||||
'image/gif': '.gif',
|
||||
'image/avif': '.avif'
|
||||
};
|
||||
return fromMime[file.type] ?? '';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user