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,97 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
accessibleName,
|
||||
formatDimensions,
|
||||
formatSize,
|
||||
formatType,
|
||||
isValidFilenameStem,
|
||||
splitFilename,
|
||||
usedInLabel,
|
||||
} from '../mediaFormat'
|
||||
|
||||
const base = {
|
||||
id: 1,
|
||||
filename: 'north-wall.jpg',
|
||||
url: '/media/north-wall.jpg',
|
||||
size: 4089446,
|
||||
content_type: 'image/jpeg',
|
||||
width: 4032,
|
||||
height: 3024,
|
||||
sha256: 'a',
|
||||
uploaded_by: 'Marta Alves',
|
||||
created_at: '2026-09-12T12:00:00Z',
|
||||
used_in: 2,
|
||||
duplicate: false,
|
||||
}
|
||||
|
||||
describe('formatSize', () => {
|
||||
it.each([
|
||||
[8192, '8 KB'],
|
||||
[880640, '860 KB'],
|
||||
[4089446, '3.9 MB'],
|
||||
[1048576, '1.0 MB'],
|
||||
[1048575, '1024 KB'],
|
||||
])('reads %i bytes as %s', (bytes, expected) => {
|
||||
expect(formatSize(bytes)).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatDimensions', () => {
|
||||
it('reads width and height with a multiplication sign', () => {
|
||||
expect(formatDimensions(4032, 3024)).toBe('4032 × 3024')
|
||||
})
|
||||
|
||||
it('reads Vector when width and height are null', () => {
|
||||
expect(formatDimensions(null, null)).toBe('Vector')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatType', () => {
|
||||
it.each([
|
||||
['image/jpeg', 'JPEG'],
|
||||
['image/png', 'PNG'],
|
||||
['image/webp', 'WEBP'],
|
||||
['image/svg+xml', 'SVG'],
|
||||
])('reads %s as %s', (contentType, expected) => {
|
||||
expect(formatType(contentType)).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('usedInLabel and accessibleName', () => {
|
||||
it('pluralises the article count', () => {
|
||||
expect(usedInLabel(1)).toBe('used in 1 article')
|
||||
expect(usedInLabel(2)).toBe('used in 2 articles')
|
||||
expect(usedInLabel(0)).toBe('unused')
|
||||
})
|
||||
|
||||
it('builds the card name from filename, size and usage', () => {
|
||||
expect(accessibleName(base)).toBe('north-wall.jpg, 3.9 MB, used in 2 articles')
|
||||
expect(accessibleName({ ...base, used_in: 1 })).toBe(
|
||||
'north-wall.jpg, 3.9 MB, used in 1 article',
|
||||
)
|
||||
expect(accessibleName({ ...base, used_in: 0 })).toBe('north-wall.jpg, 3.9 MB, unused')
|
||||
})
|
||||
})
|
||||
|
||||
describe('splitFilename', () => {
|
||||
it('separates the stem from the extension', () => {
|
||||
expect(splitFilename('north-wall.jpg')).toEqual({ stem: 'north-wall', extension: '.jpg' })
|
||||
expect(splitFilename('a.b.webp')).toEqual({ stem: 'a.b', extension: '.webp' })
|
||||
expect(splitFilename('noextension')).toEqual({ stem: 'noextension', extension: '' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('isValidFilenameStem', () => {
|
||||
it.each(['north-wall', 'north-wall-01', 'Été_2026', ' padded '])('accepts %j', (value) => {
|
||||
expect(isValidFilenameStem(value)).toBe(true)
|
||||
})
|
||||
|
||||
it.each(['', ' ', '---', '!!!', 'a b', 'a.b', '___'])('rejects %j', (value) => {
|
||||
expect(isValidFilenameStem(value)).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects more than 100 characters', () => {
|
||||
expect(isValidFilenameStem('a'.repeat(100))).toBe(true)
|
||||
expect(isValidFilenameStem('a'.repeat(101))).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { DEFAULT_CONTROLS, parseMediaControls, toRequestQuery, toRouteQuery } from '../mediaQuery'
|
||||
|
||||
describe('parseMediaControls', () => {
|
||||
it('uses the defaults for an empty query', () => {
|
||||
expect(parseMediaControls({})).toEqual(DEFAULT_CONTROLS)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[{ filter: 'unused' }, { filter: 'unused' }],
|
||||
[{ sort: 'largest' }, { sort: 'largest' }],
|
||||
[{ sort: 'name' }, { sort: 'name' }],
|
||||
[{ sort: 'oldest' }, { sort: 'oldest' }],
|
||||
[{ q: 'wall' }, { q: 'wall' }],
|
||||
[{ view: 'list' }, { view: 'list' }],
|
||||
[{ filter: 'bogus', sort: 'zzz' }, {}],
|
||||
[{ filter: 'duplicates' }, {}],
|
||||
[{ view: 'grid' }, {}],
|
||||
[{ sort: 'newest' }, {}],
|
||||
])('maps %j', (query, expected) => {
|
||||
expect(parseMediaControls(query)).toEqual({ ...DEFAULT_CONTROLS, ...expected })
|
||||
})
|
||||
|
||||
it('takes the first value of a repeated parameter and trims the search', () => {
|
||||
expect(parseMediaControls({ q: [' wall ', 'x'] }).q).toBe('wall')
|
||||
})
|
||||
})
|
||||
|
||||
describe('toRouteQuery', () => {
|
||||
it('drops every default value', () => {
|
||||
expect(toRouteQuery(DEFAULT_CONTROLS)).toEqual({})
|
||||
})
|
||||
|
||||
it('keeps only the non-default values', () => {
|
||||
expect(
|
||||
toRouteQuery({ q: 'wall', filter: 'unused', sort: 'largest', view: 'list' }),
|
||||
).toEqual({ q: 'wall', filter: 'unused', sort: 'largest', view: 'list' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('toRequestQuery', () => {
|
||||
it.each([
|
||||
[{}, 'filter=all&sort=newest&limit=100'],
|
||||
[{ filter: 'unused' }, 'filter=unused&sort=newest&limit=100'],
|
||||
[{ sort: 'largest' }, 'filter=all&sort=largest&limit=100'],
|
||||
[{ q: 'wall' }, 'q=wall&filter=all&sort=newest&limit=100'],
|
||||
[{ q: 'a b&c', filter: 'unused' }, 'q=a%20b%26c&filter=unused&sort=newest&limit=100'],
|
||||
])('builds the request for %j', (overrides, expected) => {
|
||||
expect(toRequestQuery({ ...DEFAULT_CONTROLS, ...overrides })).toBe(expected)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
import { formatAbsoluteDateFromIso } from '@/lib/relativeDate'
|
||||
|
||||
const BYTES_PER_KB = 1024
|
||||
const BYTES_PER_MB = BYTES_PER_KB * 1024
|
||||
const FILENAME_MAX_LENGTH = 100
|
||||
const FILENAME_PATTERN = /^[\p{L}\p{N}_-]+$/u
|
||||
const LETTER_OR_NUMBER = /[\p{L}\p{N}]/u
|
||||
const VECTOR_DIMENSIONS = 'Vector'
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
'image/jpeg': 'JPEG',
|
||||
'image/png': 'PNG',
|
||||
'image/webp': 'WEBP',
|
||||
'image/svg+xml': 'SVG',
|
||||
}
|
||||
|
||||
export interface MediaNameSource {
|
||||
filename: string
|
||||
size: number
|
||||
used_in: number
|
||||
}
|
||||
|
||||
export function formatSize(bytes: number) {
|
||||
if (bytes < BYTES_PER_MB) {
|
||||
return `${Math.round(bytes / BYTES_PER_KB)} KB`
|
||||
}
|
||||
return `${(bytes / BYTES_PER_MB).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
export function formatDimensions(width: number | null, height: number | null) {
|
||||
if (width === null || height === null) {
|
||||
return VECTOR_DIMENSIONS
|
||||
}
|
||||
return `${width} × ${height}`
|
||||
}
|
||||
|
||||
export function formatType(contentType: string) {
|
||||
return TYPE_LABELS[contentType] ?? (contentType.split('/')[1] ?? contentType).toUpperCase()
|
||||
}
|
||||
|
||||
export const formatMediaDate = formatAbsoluteDateFromIso
|
||||
|
||||
export function pluralArticles(count: number) {
|
||||
return `${count} ${count === 1 ? 'article' : 'articles'}`
|
||||
}
|
||||
|
||||
export function usedInLabel(count: number) {
|
||||
return count > 0 ? `used in ${pluralArticles(count)}` : 'unused'
|
||||
}
|
||||
|
||||
export function accessibleName(item: MediaNameSource) {
|
||||
return `${item.filename}, ${formatSize(item.size)}, ${usedInLabel(item.used_in)}`
|
||||
}
|
||||
|
||||
export function splitFilename(filename: string) {
|
||||
const dot = filename.lastIndexOf('.')
|
||||
if (dot <= 0) {
|
||||
return { stem: filename, extension: '' }
|
||||
}
|
||||
return { stem: filename.slice(0, dot), extension: filename.slice(dot) }
|
||||
}
|
||||
|
||||
export function isValidFilenameStem(value: string) {
|
||||
const stem = value.trim()
|
||||
return (
|
||||
stem.length > 0 &&
|
||||
stem.length <= FILENAME_MAX_LENGTH &&
|
||||
FILENAME_PATTERN.test(stem) &&
|
||||
LETTER_OR_NUMBER.test(stem)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { LocationQuery } from 'vue-router'
|
||||
|
||||
export type MediaFilter = 'all' | 'unused'
|
||||
export type MediaSort = 'newest' | 'oldest' | 'name' | 'largest'
|
||||
export type MediaView = 'grid' | 'list'
|
||||
|
||||
export interface MediaControls {
|
||||
q: string
|
||||
filter: MediaFilter
|
||||
sort: MediaSort
|
||||
view: MediaView
|
||||
}
|
||||
|
||||
export const MEDIA_PAGE_LIMIT = 100
|
||||
|
||||
export const DEFAULT_CONTROLS: MediaControls = {
|
||||
q: '',
|
||||
filter: 'all',
|
||||
sort: 'newest',
|
||||
view: 'grid',
|
||||
}
|
||||
|
||||
const FILTERS: MediaFilter[] = ['all', 'unused']
|
||||
const SORTS: MediaSort[] = ['newest', 'oldest', 'name', 'largest']
|
||||
const VIEWS: MediaView[] = ['grid', 'list']
|
||||
|
||||
function firstValue(value: LocationQuery[string] | undefined) {
|
||||
return (Array.isArray(value) ? value[0] : value) ?? ''
|
||||
}
|
||||
|
||||
function pick<T extends string>(allowed: T[], value: string, fallback: T) {
|
||||
return (allowed as string[]).includes(value) ? (value as T) : fallback
|
||||
}
|
||||
|
||||
export function parseMediaControls(query: LocationQuery): MediaControls {
|
||||
return {
|
||||
q: firstValue(query.q).trim(),
|
||||
filter: pick(FILTERS, firstValue(query.filter), DEFAULT_CONTROLS.filter),
|
||||
sort: pick(SORTS, firstValue(query.sort), DEFAULT_CONTROLS.sort),
|
||||
view: pick(VIEWS, firstValue(query.view), DEFAULT_CONTROLS.view),
|
||||
}
|
||||
}
|
||||
|
||||
export function toRouteQuery(controls: MediaControls) {
|
||||
const query: Record<string, string> = {}
|
||||
if (controls.q) {
|
||||
query.q = controls.q
|
||||
}
|
||||
if (controls.filter !== DEFAULT_CONTROLS.filter) {
|
||||
query.filter = controls.filter
|
||||
}
|
||||
if (controls.sort !== DEFAULT_CONTROLS.sort) {
|
||||
query.sort = controls.sort
|
||||
}
|
||||
if (controls.view !== DEFAULT_CONTROLS.view) {
|
||||
query.view = controls.view
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
export function toRequestQuery(controls: MediaControls) {
|
||||
const parts = [
|
||||
...(controls.q ? [`q=${encodeURIComponent(controls.q)}`] : []),
|
||||
`filter=${controls.filter}`,
|
||||
`sort=${controls.sort}`,
|
||||
`limit=${MEDIA_PAGE_LIMIT}`,
|
||||
]
|
||||
return parts.join('&')
|
||||
}
|
||||
Reference in new issue
Block a user