Files
RenewCMS/web/src/composables/useMedia.ts
T

289 lines
7.4 KiB
TypeScript

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<MediaItem[]>([])
const total = ref(0)
const counts = ref<MediaCounts>({ all: 0, unused: 0, duplicates: 0 })
const status = ref<MediaLoadStatus>('loading')
const showSkeleton = ref(false)
let skeletonTimer: ReturnType<typeof setTimeout> | 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<MediaListResponse>(`/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<MediaLoadOutcome> {
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<MediaUsageItem[]>(`/v1/media/${id}/usage`)
}
async function findCopies(item: MediaItem) {
const response = await request<MediaListResponse>(
`/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<MediaItem>(`/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<void>(`/v1/media/${id}`, { method: 'DELETE' })
bumpMediaRevision()
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')
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<MediaMergeResult>('/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,
}
}