feat(SPA): media page T0
This commit is contained in:
1 parent
0d99544579
commit
67d456baf6
32 files changed
+4874
-40
No files matched your search
@@ -0,0 +1,164 @@
|
||||
import { ref } from 'vue'
|
||||
import { tryOnBeforeUnmount } from '@vueuse/core'
|
||||
import { ApiError, useApi } from '@/composables/useApi'
|
||||
import { toRequestQuery, type MediaControls } from '@/lib/mediaQuery'
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
export interface MediaCounts {
|
||||
all: number
|
||||
unused: number
|
||||
duplicates: number
|
||||
}
|
||||
|
||||
export interface MediaUsageItem {
|
||||
id: number
|
||||
title: string
|
||||
published: boolean
|
||||
}
|
||||
|
||||
interface MediaListResponse {
|
||||
items: MediaItem[]
|
||||
next_cursor: string | null
|
||||
counts: MediaCounts
|
||||
}
|
||||
|
||||
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'
|
||||
}
|
||||
|
||||
export function useMedia() {
|
||||
const { request } = useApi()
|
||||
|
||||
const items = ref<MediaItem[]>([])
|
||||
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)
|
||||
}
|
||||
|
||||
async function load(controls: MediaControls, options: { silent?: boolean } = {}) {
|
||||
const silent = options.silent === true
|
||||
if (!silent) {
|
||||
latestLoad += 1
|
||||
startLoading()
|
||||
}
|
||||
const ticket = latestLoad
|
||||
try {
|
||||
const response = await request<MediaListResponse>(
|
||||
`/v1/media/?${toRequestQuery(controls)}`,
|
||||
)
|
||||
if (ticket !== latestLoad) {
|
||||
return
|
||||
}
|
||||
items.value = response.items
|
||||
counts.value = response.counts
|
||||
status.value = 'ready'
|
||||
} catch (error) {
|
||||
if (silent || ticket !== latestLoad) {
|
||||
return
|
||||
}
|
||||
status.value = classifyMediaError(error) === 'handled' ? 'handled' : 'error'
|
||||
} 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/?hash=${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' })
|
||||
removeLocal(id)
|
||||
}
|
||||
|
||||
function copyUrl(item: MediaItem) {
|
||||
return navigator.clipboard.writeText(`${window.location.origin}${item.url}`)
|
||||
}
|
||||
|
||||
tryOnBeforeUnmount(clearSkeletonTimer)
|
||||
|
||||
return {
|
||||
items,
|
||||
counts,
|
||||
status,
|
||||
showSkeleton,
|
||||
load,
|
||||
fetchUsage,
|
||||
findCopies,
|
||||
renameItem,
|
||||
deleteItem,
|
||||
removeLocal,
|
||||
copyUrl,
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user