import { computed, onBeforeUnmount, ref, watch } from 'vue' import { redirectToLogin, redirectUnverified, useApi } from '@/composables/useApi' import { classifyMediaError, type MediaItem } from '@/composables/useMedia' import { useRetryAfter } from '@/composables/useRetryAfter' import { hashFile } from '@/lib/fileHash' import { extensionMatchesType, validateImageFile } from '@/lib/imageUpload' import { describeUpload, parseRetryAfter } from '@/lib/mediaUploadRows' import type { UploadRejection, UploadRowStatus } from '@/lib/mediaUploadRows' const MAX_CONCURRENT_UPLOADS = 2 const UPLOAD_URL = '/v1/media/' const HTTP_CREATED = 201 const HTTP_BAD_REQUEST = 400 const HTTP_FORBIDDEN = 403 const HTTP_UNAUTHORIZED = 401 const HTTP_TOO_LARGE = 413 const HTTP_UNSUPPORTED = 415 const HTTP_RATE_LIMITED = 429 const TONE_COUNT = 4 export interface UploadRow { key: number file: File name: string size: number status: UploadRowStatus loaded: number total: number rejection: UploadRejection | null existing: MediaItem | null previewUrl: string | null skipLookup: boolean tone: number } interface UploadOptions { onAdded?: () => void } function toneOf(name: string) { return Array.from(name).reduce((sum, letter) => sum + letter.charCodeAt(0), 0) % TONE_COUNT } export function oldestOf(items: MediaItem[]) { return [...items].sort( (first, second) => Date.parse(first.created_at) - Date.parse(second.created_at) || first.id - second.id, )[0] } function mismatchRejection(file: File): UploadRejection | null { return extensionMatchesType(file) ? null : 'type' } function createPreview(file: File) { return typeof URL.createObjectURL === 'function' ? URL.createObjectURL(file) : null } function releasePreview(row: UploadRow) { if (row.previewUrl && typeof URL.revokeObjectURL === 'function') { URL.revokeObjectURL(row.previewUrl) } } export function useMediaUpload(options: UploadOptions = {}) { const { request } = useApi() const retry = useRetryAfter() const rows = ref([]) const slots = new Set() const requests = new Map() let nextKey = 1 let halted = false const header = computed(() => describeUpload(rows.value)) const limited = computed(() => retry.active.value) const countdown = computed(() => retry.formatted.value) function createRow(file: File): UploadRow { const verdict = validateImageFile(file) const rejection = verdict.ok ? mismatchRejection(file) : verdict.kind return { key: nextKey++, file, name: file.name, size: file.size, status: rejection ? 'rejected' : 'waiting', loaded: 0, total: file.size, rejection, existing: null, previewUrl: rejection ? null : createPreview(file), skipLookup: false, tone: toneOf(file.name), } } function find(key: number) { return rows.value.find((row) => row.key === key) } function release(key: number) { slots.delete(key) requests.delete(key) } function settle(key: number) { release(key) pump() } function halt(row: UploadRow) { row.status = 'waiting' release(row.key) halted = true } function pump() { if (halted || limited.value) { return } for (const row of rows.value) { if (slots.size >= MAX_CONCURRENT_UPLOADS) { return } if (row.status === 'waiting' && !slots.has(row.key)) { slots.add(row.key) void process(row) } } } async function findExisting(file: File): Promise { const hash = await hashFile(file) if (hash === null) { return null } try { const response = await request<{ items: MediaItem[] }>(`/v1/media/?hash=${hash}`) return oldestOf(response.items) ?? null } catch (error) { return classifyMediaError(error) === 'handled' ? 'halted' : null } } async function process(row: UploadRow) { if (!row.skipLookup) { const match = await findExisting(row.file) if (!find(row.key)) { settle(row.key) return } if (match === 'halted') { halt(row) return } if (match) { row.status = 'duplicate' row.existing = match settle(row.key) return } } send(row) } function reject(row: UploadRow, kind: UploadRejection) { row.status = 'rejected' row.rejection = kind settle(row.key) } function pause(row: UploadRow, header: string | null) { row.status = 'waiting' release(row.key) retry.start(parseRetryAfter(header)) } function onLoaded(row: UploadRow, xhr: XMLHttpRequest) { requests.delete(row.key) switch (xhr.status) { case HTTP_CREATED: row.status = 'added' options.onAdded?.() settle(row.key) return case HTTP_BAD_REQUEST: reject(row, 'unreadable') return case HTTP_TOO_LARGE: reject(row, 'size') return case HTTP_UNSUPPORTED: reject(row, 'type') return case HTTP_UNAUTHORIZED: redirectToLogin() halt(row) return case HTTP_FORBIDDEN: redirectUnverified() halt(row) return case HTTP_RATE_LIMITED: pause(row, xhr.getResponseHeader('Retry-After')) return default: row.status = 'failed' settle(row.key) } } function send(row: UploadRow) { row.status = 'uploading' row.loaded = 0 row.total = row.size const xhr = new XMLHttpRequest() requests.set(row.key, xhr) xhr.open('POST', UPLOAD_URL) xhr.withCredentials = true xhr.upload.addEventListener('progress', (event) => { row.loaded = event.loaded row.total = event.total || row.size }) xhr.addEventListener('load', () => onLoaded(row, xhr)) xhr.addEventListener('error', () => { row.status = 'failed' settle(row.key) }) const form = new FormData() form.append('file', row.file) xhr.send(form) } function addFiles(files: File[]) { if (limited.value || files.length === 0) { return } rows.value.push(...files.map(createRow)) pump() } function remove(key: number) { const row = find(key) if (row) { releasePreview(row) } rows.value = rows.value.filter((entry) => entry.key !== key) } function cancel(key: number) { requests.get(key)?.abort() release(key) remove(key) pump() } function cancelAll() { for (const row of [...rows.value]) { if (row.status === 'uploading' || row.status === 'waiting') { requests.get(row.key)?.abort() release(row.key) remove(row.key) } } } function retryRow(key: number) { const row = find(key) if (!row || (row.status !== 'failed' && row.status !== 'duplicate')) { return } row.status = 'waiting' row.skipLookup = true row.existing = null pump() } function dismiss() { rows.value.forEach(releasePreview) rows.value = [] } watch(limited, (active) => { if (!active) { pump() } }) onBeforeUnmount(() => { requests.forEach((xhr) => xhr.abort()) requests.clear() rows.value.forEach(releasePreview) }) return { rows, header, limited, countdown, addFiles, remove, cancel, cancelAll, retryRow, dismiss, } }