import { ref } from 'vue' import { tryOnBeforeUnmount } from '@vueuse/core' import { ApiError, useApi } from '@/composables/useApi' import { MEDIA_FULL_SET_PAGE_SIZE, isFullSet, toHashRequestQuery, toRequestQuery, type MediaControls, } from '@/lib/mediaQuery' import { pageCountFor } from '@/lib/pagination' import { bumpMediaRevision } from '@/lib/mediaRevision' export interface MediaItem { id: number filename: string url: string size: number content_type: string width: number | null height: number | null sha256: string uploaded_by: string created_at: string used_in: number duplicate: boolean variant_count: number variant_size: number } export interface MediaCounts { all: number unused: number duplicates: number } export interface MediaUsageItem { id: number title: string published: boolean } export interface MediaMergeResult { articles_updated: number merged: number } export interface BulkDeleteResult { deleted: MediaItem[] failed: MediaItem[] handled: boolean } export interface MediaListResponse { items: MediaItem[] total: number page: number size: number counts: MediaCounts } export type MediaLoadOutcome = | { kind: 'loaded'; page: number; redirected: boolean } | { kind: 'failed'; handled: boolean } | { kind: 'stale' } interface MediaLoadOptions { silent?: boolean keepOnError?: boolean } export type MediaLoadStatus = 'loading' | 'ready' | 'error' | 'handled' export type MediaFailure = 'handled' | 'not-found' | 'failed' export const MEDIA_SKELETON_DELAY_MS = 150 const AUTH_HANDLED_STATUSES = [401, 403] const NOT_FOUND_STATUS = 404 export function classifyMediaError(error: unknown): MediaFailure { if (error instanceof ApiError && AUTH_HANDLED_STATUSES.includes(error.status)) { return 'handled' } if (error instanceof ApiError && error.status === NOT_FOUND_STATUS) { return 'not-found' } return 'failed' } function isPastLastPage(response: MediaListResponse, page: number, size: number) { return ( response.items.length === 0 && response.total > 0 && page > pageCountFor(response.total, size) ) } export function useMedia() { const { request } = useApi() const items = ref([]) const total = ref(0) const counts = ref({ all: 0, unused: 0, duplicates: 0 }) const status = ref('loading') const showSkeleton = ref(false) let skeletonTimer: ReturnType | null = null let latestLoad = 0 function clearSkeletonTimer() { if (skeletonTimer !== null) { clearTimeout(skeletonTimer) skeletonTimer = null } } function startLoading() { status.value = 'loading' showSkeleton.value = false clearSkeletonTimer() skeletonTimer = setTimeout(() => { showSkeleton.value = true }, MEDIA_SKELETON_DELAY_MS) } function requestPage(controls: MediaControls) { return request(`/v1/media/?${toRequestQuery(controls)}`) } async function requestFullSet(controls: MediaControls) { const query = { ...controls, page: 1, size: MEDIA_FULL_SET_PAGE_SIZE } const first = await requestPage(query) const loaded = [...first.items] let latest = first while (loaded.length < first.total && latest.items.length > 0) { query.page += 1 latest = await requestPage(query) loaded.push(...latest.items) } return { ...latest, items: loaded, total: loaded.length, page: 1 } } async function requestList(controls: MediaControls) { if (isFullSet(controls)) { return { response: await requestFullSet(controls), page: 1, redirected: false } } const response = await requestPage(controls) if (!isPastLastPage(response, controls.page, controls.size)) { return { response, page: response.total === 0 ? 1 : controls.page, redirected: false } } const page = pageCountFor(response.total, controls.size) return { response: await requestPage({ ...controls, page }), page, redirected: true } } async function load( controls: MediaControls, options: MediaLoadOptions = {}, ): Promise { const silent = options.silent === true if (!silent) { latestLoad += 1 startLoading() } const ticket = latestLoad try { const { response, page, redirected } = await requestList(controls) if (ticket !== latestLoad) { return { kind: 'stale' } } items.value = response.items total.value = response.total counts.value = response.counts status.value = 'ready' return { kind: 'loaded', page, redirected } } catch (error) { if (ticket !== latestLoad) { return { kind: 'stale' } } const handled = classifyMediaError(error) === 'handled' if (!silent) { status.value = handled ? 'handled' : options.keepOnError ? 'ready' : 'error' } return { kind: 'failed', handled } } finally { if (!silent && ticket === latestLoad) { clearSkeletonTimer() showSkeleton.value = false } } } async function fetchUsage(id: number) { return request(`/v1/media/${id}/usage`) } async function findCopies(item: MediaItem) { const response = await request( `/v1/media/?${toHashRequestQuery(item.sha256)}`, ) return response.items.filter((copy) => copy.id !== item.id) } function replaceLocal(updated: MediaItem) { items.value = items.value.map((entry) => (entry.id === updated.id ? updated : entry)) } function removeLocal(id: number) { items.value = items.value.filter((entry) => entry.id !== id) } async function renameItem(id: number, filename: string) { const updated = await request(`/v1/media/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filename }), }) replaceLocal(updated) return updated } async function deleteItem(id: number) { await request(`/v1/media/${id}`, { method: 'DELETE' }) bumpMediaRevision() removeLocal(id) } async function deleteMany(targets: MediaItem[]): Promise { const outcomes = await Promise.all( targets.map(async (item) => { try { await request(`/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') if (deleted.length > 0) { bumpMediaRevision() } items.value = items.value.filter((entry) => !deleted.some((gone) => gone.id === entry.id)) return { deleted, failed: pick('failed'), handled: pick('handled').length > 0 } } async function mergeCopies(keepId: number, mergeIds: number[]) { const result = await request('/v1/media/merge', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ keep_id: keepId, merge_ids: mergeIds }), }) bumpMediaRevision() return result } 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}`) } tryOnBeforeUnmount(clearSkeletonTimer) return { items, total, counts, status, showSkeleton, load, fetchUsage, findCopies, renameItem, deleteItem, removeLocal, removeLocalMany, deleteMany, mergeCopies, copyUrl, } }