feat: media page T1

This commit is contained in:
fsylvain committed 2026-10-01 01:36:35 +02:00
1 parent 67d456baf6
commit 008b0dfbb9
46 files changed
+5742 -204

No files matched your search

+307
View File
@@ -0,0 +1,307 @@
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
}
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<UploadRow[]>([])
const slots = new Set<number>()
const requests = new Map<number, XMLHttpRequest>()
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<MediaItem | null | 'halted'> {
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,
}
}