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

+23 -15
View File
@@ -79,6 +79,27 @@ if (typeof window !== 'undefined') {
})
}
export function redirectToLogin() {
const currentPath = router.currentRoute.value.fullPath
useSessionStore().clear()
authRedirectPending.value = true
router
.push({
path: '/login',
query: { redirect: currentPath, reason: 'expired' },
})
.finally(() => {
authRedirectPending.value = false
})
}
export function redirectUnverified(onForbidden: OnForbidden = 'redirect') {
const session = useSessionStore()
if (onForbidden !== 'passthrough' && session.session?.verified === false) {
router.push('/register/pending')
}
}
export function useApi() {
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
const { onUnauthorized = 'redirect', onForbidden = 'redirect', ...init } = options
@@ -95,25 +116,12 @@ export function useApi() {
if (!response.ok) {
if (response.status === 401 && onUnauthorized === 'redirect') {
const currentPath = router.currentRoute.value.fullPath
useSessionStore().clear()
authRedirectPending.value = true
router
.push({
path: '/login',
query: { redirect: currentPath, reason: 'expired' },
})
.finally(() => {
authRedirectPending.value = false
})
redirectToLogin()
throw new ApiError(401)
}
if (response.status === 403) {
const session = useSessionStore()
if (onForbidden !== 'passthrough' && session.session?.verified === false) {
router.push('/register/pending')
}
redirectUnverified(onForbidden)
throw new ApiError(403)
}
+48
View File
@@ -30,6 +30,17 @@ export interface MediaUsageItem {
published: boolean
}
export interface MediaMergeResult {
articles_updated: number
merged: number
}
export interface BulkDeleteResult {
deleted: MediaItem[]
failed: MediaItem[]
handled: boolean
}
interface MediaListResponse {
items: MediaItem[]
next_cursor: string | null
@@ -142,6 +153,40 @@ export function useMedia() {
removeLocal(id)
}
async function deleteMany(targets: MediaItem[]): Promise<BulkDeleteResult> {
const outcomes = await Promise.all(
targets.map(async (item) => {
try {
await request<void>(`/v1/media/${item.id}`, { method: 'DELETE' })
return { item, outcome: 'deleted' as const }
} catch (error) {
const failure = classifyMediaError(error)
return {
item,
outcome: failure === 'not-found' ? ('deleted' as const) : failure,
}
}
}),
)
const pick = (outcome: string) =>
outcomes.filter((entry) => entry.outcome === outcome).map((entry) => entry.item)
const deleted = pick('deleted')
items.value = items.value.filter((entry) => !deleted.some((gone) => gone.id === entry.id))
return { deleted, failed: pick('failed'), handled: pick('handled').length > 0 }
}
function mergeCopies(keepId: number, mergeIds: number[]) {
return request<MediaMergeResult>('/v1/media/merge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ keep_id: keepId, merge_ids: mergeIds }),
})
}
function removeLocalMany(ids: number[]) {
items.value = items.value.filter((entry) => !ids.includes(entry.id))
}
function copyUrl(item: MediaItem) {
return navigator.clipboard.writeText(`${window.location.origin}${item.url}`)
}
@@ -159,6 +204,9 @@ export function useMedia() {
renameItem,
deleteItem,
removeLocal,
removeLocalMany,
deleteMany,
mergeCopies,
copyUrl,
}
}
+67
View File
@@ -0,0 +1,67 @@
import { computed, ref, watch, type Ref } from 'vue'
import type { MediaItem } from '@/composables/useMedia'
export type SelectAllState = 'none' | 'partial' | 'all'
export function useMediaSelection(items: Ref<MediaItem[]>) {
const selectedIds = ref<number[]>([])
const count = computed(() => selectedIds.value.length)
const selectedItems = computed(() =>
items.value.filter((item) => selectedIds.value.includes(item.id)),
)
const allState = computed<SelectAllState>(() => {
if (count.value === 0) {
return 'none'
}
return count.value >= items.value.length ? 'all' : 'partial'
})
function isSelected(id: number) {
return selectedIds.value.includes(id)
}
function select(id: number) {
if (!isSelected(id)) {
selectedIds.value = [...selectedIds.value, id]
}
}
function toggle(id: number) {
selectedIds.value = isSelected(id)
? selectedIds.value.filter((entry) => entry !== id)
: [...selectedIds.value, id]
}
function replace(ids: number[]) {
selectedIds.value = ids
}
function clear() {
selectedIds.value = []
}
function toggleAll() {
selectedIds.value = allState.value === 'all' ? [] : items.value.map((item) => item.id)
}
watch(items, (loaded) => {
const kept = selectedIds.value.filter((id) => loaded.some((item) => item.id === id))
if (kept.length !== selectedIds.value.length) {
selectedIds.value = kept
}
})
return {
selectedIds,
selectedItems,
count,
allState,
isSelected,
select,
toggle,
replace,
clear,
toggleAll,
}
}
+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,
}
}
+3
View File
@@ -0,0 +1,3 @@
import { ref } from 'vue'
export const toastsAboveBulkBar = ref(false)