feat(SPA): media page T0

This commit is contained in:
fsylvain committed 2026-09-30 19:45:47 +02:00
1 parent 0d99544579
commit 67d456baf6
32 files changed
+4874 -40

No files matched your search

+164
View File
@@ -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,
}
}