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

@@ -0,0 +1,259 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ApiError, NetworkError, useApi } from '@/composables/useApi'
import { classifyMediaError, useMedia, type MediaItem } from '../useMedia'
import { DEFAULT_CONTROLS } from '@/lib/mediaQuery'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
function item(id: number, overrides: Partial<MediaItem> = {}): MediaItem {
return {
id,
filename: `image-${id}.jpg`,
url: `/media/image-${id}.jpg`,
size: 2097152,
content_type: 'image/jpeg',
width: 3600,
height: 2400,
sha256: `hash-${id}`,
uploaded_by: 'jonas',
created_at: '2026-09-12T12:00:00Z',
used_in: 0,
duplicate: false,
...overrides,
}
}
function listBody(items: MediaItem[]) {
return {
items,
next_cursor: null,
counts: { all: items.length, unused: 1, duplicates: 0 },
}
}
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason: unknown) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
let request: ReturnType<typeof vi.fn>
beforeEach(() => {
request = vi.fn()
vi.mocked(useApi).mockReturnValue({ getMe: vi.fn(), request })
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
describe('useMedia list state', () => {
it('requests the list with the controls and limit=100 and stores items and counts', async () => {
request.mockResolvedValue(listBody([item(1), item(2)]))
const media = useMedia()
await media.load({ ...DEFAULT_CONTROLS, q: 'wall', filter: 'unused' })
expect(request).toHaveBeenCalledWith(
'/v1/media/?q=wall&filter=unused&sort=newest&limit=100',
)
expect(media.items.value.map((entry) => entry.id)).toEqual([1, 2])
expect(media.counts.value).toEqual({ all: 2, unused: 1, duplicates: 0 })
expect(media.status.value).toBe('ready')
})
it('shows the skeleton only after 150 ms of loading', async () => {
const pending = deferred<unknown>()
request.mockReturnValue(pending.promise)
const media = useMedia()
const loading = media.load(DEFAULT_CONTROLS)
expect(media.status.value).toBe('loading')
expect(media.showSkeleton.value).toBe(false)
vi.advanceTimersByTime(149)
expect(media.showSkeleton.value).toBe(false)
vi.advanceTimersByTime(1)
expect(media.showSkeleton.value).toBe(true)
pending.resolve(listBody([item(1)]))
await loading
expect(media.showSkeleton.value).toBe(false)
expect(media.status.value).toBe('ready')
})
it.each([
['a 500 response', () => new ApiError(500)],
['a network failure', () => new NetworkError()],
])('moves to error on %s and recovers on the next load', async (_name, failure) => {
request.mockRejectedValueOnce(failure())
const media = useMedia()
await media.load(DEFAULT_CONTROLS)
expect(media.status.value).toBe('error')
request.mockResolvedValueOnce(listBody([item(1)]))
await media.load(DEFAULT_CONTROLS)
expect(media.status.value).toBe('ready')
expect(media.items.value).toHaveLength(1)
})
it.each([401, 403])(
'marks a %i as handled by useApi, not as a local failure',
async (status) => {
request.mockRejectedValueOnce(new ApiError(status))
const media = useMedia()
await media.load(DEFAULT_CONTROLS)
expect(media.status.value).toBe('handled')
},
)
it('a silent reload keeps the status and ignores failures', async () => {
request.mockResolvedValueOnce(listBody([item(1)]))
const media = useMedia()
await media.load(DEFAULT_CONTROLS)
const pending = deferred<unknown>()
request.mockReturnValueOnce(pending.promise)
const silent = media.load(DEFAULT_CONTROLS, { silent: true })
expect(media.status.value).toBe('ready')
vi.advanceTimersByTime(500)
expect(media.showSkeleton.value).toBe(false)
pending.reject(new ApiError(500))
await silent
expect(media.status.value).toBe('ready')
expect(media.items.value).toHaveLength(1)
})
it('a silent reload refreshes items and counts', async () => {
request.mockResolvedValueOnce(listBody([item(1), item(2)]))
const media = useMedia()
await media.load(DEFAULT_CONTROLS)
request.mockResolvedValueOnce({
items: [item(1)],
next_cursor: null,
counts: { all: 1, unused: 0, duplicates: 0 },
})
await media.load(DEFAULT_CONTROLS, { silent: true })
expect(media.items.value).toHaveLength(1)
expect(media.counts.value.all).toBe(1)
})
it('ignores a response that a newer load has replaced', async () => {
const first = deferred<unknown>()
const second = deferred<unknown>()
request.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise)
const media = useMedia()
const older = media.load(DEFAULT_CONTROLS)
const newer = media.load({ ...DEFAULT_CONTROLS, q: 'wall' })
second.resolve(listBody([item(2)]))
await newer
first.resolve(listBody([item(1)]))
await older
expect(media.items.value.map((entry) => entry.id)).toEqual([2])
})
})
describe('useMedia requests', () => {
it('fetches the usage of an image', async () => {
const usage = [{ id: 10, title: 'How we price a bathroom renovation', published: true }]
request.mockResolvedValue(usage)
const media = useMedia()
await expect(media.fetchUsage(1)).resolves.toEqual(usage)
expect(request).toHaveBeenCalledWith('/v1/media/1/usage')
})
it('finds the other copies by hash and excludes the image itself', async () => {
request.mockResolvedValue(listBody([item(12), item(5), item(6)]))
const media = useMedia()
const others = await media.findCopies(item(12, { sha256: 'abc123' }))
expect(request).toHaveBeenCalledWith('/v1/media/?hash=abc123')
expect(others.map((entry) => entry.id)).toEqual([5, 6])
})
it('renames with the filename only and replaces the item in place', async () => {
request.mockResolvedValueOnce(listBody([item(1), item(2), item(3)]))
const media = useMedia()
await media.load(DEFAULT_CONTROLS)
request.mockResolvedValueOnce(item(2, { filename: 'kept-name.jpg' }))
const renamed = await media.renameItem(2, 'kept-name')
expect(request).toHaveBeenLastCalledWith('/v1/media/2', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename: 'kept-name' }),
})
expect(renamed.filename).toBe('kept-name.jpg')
expect(media.items.value.map((entry) => entry.filename)).toEqual([
'image-1.jpg',
'kept-name.jpg',
'image-3.jpg',
])
})
it('deletes and removes the item locally', async () => {
request.mockResolvedValueOnce(listBody([item(1), item(2)]))
const media = useMedia()
await media.load(DEFAULT_CONTROLS)
request.mockResolvedValueOnce(undefined)
await media.deleteItem(1)
expect(request).toHaveBeenLastCalledWith('/v1/media/1', { method: 'DELETE' })
expect(media.items.value.map((entry) => entry.id)).toEqual([2])
})
it('removeLocal drops an item without a request', async () => {
request.mockResolvedValueOnce(listBody([item(1), item(2)]))
const media = useMedia()
await media.load(DEFAULT_CONTROLS)
request.mockClear()
media.removeLocal(2)
expect(media.items.value.map((entry) => entry.id)).toEqual([1])
expect(request).not.toHaveBeenCalled()
})
it('copies the absolute address of the image', async () => {
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true })
const media = useMedia()
await media.copyUrl(item(1, { url: '/media/north-wall.jpg' }))
expect(writeText).toHaveBeenCalledWith(`${window.location.origin}/media/north-wall.jpg`)
})
})
describe('classifyMediaError', () => {
it.each([
[new ApiError(401), 'handled'],
[new ApiError(403), 'handled'],
[new ApiError(404), 'not-found'],
[new ApiError(400), 'failed'],
[new ApiError(500), 'failed'],
[new NetworkError(), 'failed'],
[new Error('x'), 'failed'],
])('classifies %s', (error, expected) => {
expect(classifyMediaError(error)).toBe(expected)
})
})
+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,
}
}