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,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)
|
||||
})
|
||||
})
|
||||
@@ -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