289 lines
7.4 KiB
TypeScript
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,
|
|
}
|
|
}
|