feat: media page T1

This commit is contained in:
fsylvain committed 2026-10-01 01:36:35 +02:00
1 parent 67d456baf6
commit 008b0dfbb9
46 files changed
+5742 -204

No files matched your search

+48
View File
@@ -0,0 +1,48 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { hashFile } from '../fileHash'
const SHA256_OF_ABC = 'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad'
function fileOf(bytes: number[]) {
return new File([new Uint8Array(bytes)], 'a.png', { type: 'image/png' })
}
afterEach(() => {
vi.unstubAllGlobals()
})
describe('hashFile', () => {
it('resolves the lowercase 64-character hex of the digest', async () => {
const digest = vi.fn(async (_algorithm: string, data: ArrayBuffer) => {
expect(Array.from(new Uint8Array(data))).toEqual([97, 98, 99])
return new Uint8Array(
SHA256_OF_ABC.match(/../g)!.map((pair) => Number.parseInt(pair, 16)),
).buffer
})
vi.stubGlobal('crypto', { subtle: { digest } })
const hex = await hashFile(fileOf([97, 98, 99]))
expect(digest).toHaveBeenCalledWith('SHA-256', expect.anything())
expect(hex).toBe(SHA256_OF_ABC)
expect(hex).toMatch(/^[0-9a-f]{64}$/)
})
it('resolves null when crypto.subtle is unavailable', async () => {
vi.stubGlobal('crypto', { subtle: undefined })
expect(await hashFile(fileOf([1]))).toBeNull()
})
it('resolves null when the digest rejects', async () => {
vi.stubGlobal('crypto', { subtle: { digest: vi.fn().mockRejectedValue(new Error('no')) } })
expect(await hashFile(fileOf([1]))).toBeNull()
})
it('imports nothing from the Media page', async () => {
const source = (await import('../fileHash?raw')).default
expect(source).not.toMatch(/import .*Media/)
expect(source).not.toMatch(/from '@\/(components|views|composables)/)
})
})
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest'
import { extensionMatchesType, validateImageFile } from '../imageUpload'
function fileOf(name: string, type: string, size = 100) {
const file = new File(['x'], name, { type })
Object.defineProperty(file, 'size', { value: size })
return file
}
describe('validateImageFile', () => {
it('checks the size first, 10 MB allowed and one byte more too large', () => {
expect(validateImageFile(fileOf('a.png', 'image/png', 10485760))).toEqual({ ok: true })
expect(validateImageFile(fileOf('a.png', 'image/png', 10485761))).toEqual({
ok: false,
kind: 'size',
})
expect(validateImageFile(fileOf('a.tif', 'image/tiff', 10485761))).toEqual({
ok: false,
kind: 'size',
})
})
it('needs an allowed MIME type and an allowed extension', () => {
expect(validateImageFile(fileOf('a.gif', 'image/gif'))).toEqual({ ok: false, kind: 'type' })
expect(validateImageFile(fileOf('a.jpg', 'text/plain'))).toEqual({
ok: false,
kind: 'type',
})
})
})
describe('extensionMatchesType', () => {
it.each([
['a.png', 'image/png', true],
['a.JPG', 'image/jpeg', true],
['a.jpeg', 'image/jpeg', true],
['a.webp', 'image/webp', true],
['a.svg', 'image/svg+xml', true],
['pic.jpg', 'image/png', false],
['a.svg', 'image/jpeg', false],
['a.tif', 'image/tiff', false],
])('%s as %s is %s', (name, type, expected) => {
expect(extensionMatchesType(fileOf(name, type))).toBe(expected)
})
})
@@ -0,0 +1,98 @@
import { describe, expect, it } from 'vitest'
import type { MediaItem } from '@/composables/useMedia'
import { groupDuplicates, mergeArticleCount } from '../mediaDuplicates'
function copy(id: number, usedIn: number, createdAt: string, overrides: Partial<MediaItem> = {}) {
return {
id,
filename: `copy-${id}.jpg`,
url: `/media/copy-${id}.jpg`,
size: 2516582,
content_type: 'image/jpeg',
width: 3600,
height: 2400,
sha256: 'aa',
uploaded_by: 'Inès Duarte',
created_at: createdAt,
used_in: usedIn,
duplicate: true,
...overrides,
} satisfies MediaItem
}
describe('groupDuplicates', () => {
it.each([
[
'the most used copy',
[
copy(1, 4, '2026-06-14T12:00:00Z'),
copy(2, 3, '2026-09-02T12:00:00Z'),
copy(3, 2, '2026-08-30T12:00:00Z'),
],
1,
],
[
'the oldest copy at equal use',
[copy(1, 2, '2026-08-29T12:00:00Z'), copy(2, 2, '2026-08-21T12:00:00Z')],
2,
],
[
'the oldest unused copy',
[copy(1, 0, '2026-09-03T12:00:00Z'), copy(2, 0, '2026-09-01T12:00:00Z')],
2,
],
[
'the lowest id at equal use and date',
[copy(9, 1, '2026-09-03T12:00:00Z'), copy(4, 1, '2026-09-03T12:00:00Z')],
4,
],
])('suggests %s', (_name, copies, suggestedId) => {
const [group] = groupDuplicates(copies)
expect(group!.suggested.id).toBe(suggestedId)
expect(group!.copies[0]!.id).toBe(suggestedId)
})
it('groups by sha256, orders copies and puts the oldest suggested copy first', () => {
const groups = groupDuplicates([
copy(7, 2, '2026-08-29T12:00:00Z', { sha256: 'bb', size: 3250585 }),
copy(5, 3, '2026-09-02T12:00:00Z'),
copy(9, 1, '2026-08-21T12:00:00Z', { sha256: 'bb', size: 3250585 }),
copy(12, 4, '2026-06-14T12:00:00Z'),
copy(6, 2, '2026-08-30T12:00:00Z'),
])
expect(groups.map((group) => group.copies.map((item) => item.id))).toEqual([
[12, 5, 6],
[7, 9],
])
expect(groups.map((group) => group.header)).toEqual([
'3 identical images · 2.4 MB each · 3600 × 2400',
'2 identical images · 3.1 MB each · 3600 × 2400',
])
})
it('reads Vector for SVG groups and ignores single copies', () => {
const groups = groupDuplicates([
copy(1, 0, '2026-09-01T12:00:00Z', { width: null, height: null, size: 8192 }),
copy(2, 0, '2026-09-02T12:00:00Z', { width: null, height: null, size: 8192 }),
copy(3, 0, '2026-09-02T12:00:00Z', { sha256: 'zz' }),
])
expect(groups).toHaveLength(1)
expect(groups[0]!.header).toBe('2 identical images · 8 KB each · Vector')
})
})
describe('mergeArticleCount', () => {
it('sums the use of every copy except the kept one', () => {
const copies = [
copy(12, 4, '2026-06-14T12:00:00Z'),
copy(5, 3, '2026-09-02T12:00:00Z'),
copy(6, 2, '2026-08-30T12:00:00Z'),
]
expect(mergeArticleCount(copies, 12)).toBe(5)
expect(mergeArticleCount(copies, 6)).toBe(7)
})
})
+2 -1
View File
@@ -14,7 +14,7 @@ describe('parseMediaControls', () => {
[{ q: 'wall' }, { q: 'wall' }],
[{ view: 'list' }, { view: 'list' }],
[{ filter: 'bogus', sort: 'zzz' }, {}],
[{ filter: 'duplicates' }, {}],
[{ filter: 'duplicates' }, { filter: 'duplicates' }],
[{ view: 'grid' }, {}],
[{ sort: 'newest' }, {}],
])('maps %j', (query, expected) => {
@@ -42,6 +42,7 @@ describe('toRequestQuery', () => {
it.each([
[{}, 'filter=all&sort=newest&limit=100'],
[{ filter: 'unused' }, 'filter=unused&sort=newest&limit=100'],
[{ filter: 'duplicates' }, 'filter=duplicates&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'],
@@ -0,0 +1,101 @@
import { describe, expect, it } from 'vitest'
import {
describeUpload,
parseRetryAfter,
rejectionText,
rejectionTitle,
summaryLine,
type UploadRowStatus,
} from '../mediaUploadRows'
function rows(...statuses: UploadRowStatus[]) {
return statuses.map((status) => ({ status }))
}
describe('summaryLine', () => {
it.each([
[2, 1, 2, '2 uploaded · 1 already in your library · 2 need attention'],
[3, 0, 0, '3 uploaded'],
[0, 1, 0, '1 already in your library'],
[1, 0, 1, '1 uploaded · 1 need attention'],
])('%i added, %i duplicate, %i attention reads "%s"', (added, duplicate, attention, line) => {
expect(summaryLine({ added, duplicate, attention })).toBe(line)
})
})
describe('describeUpload', () => {
it('counts settled rows while working', () => {
const header = describeUpload(
rows('added', 'duplicate', 'rejected', 'failed', 'uploading', 'waiting'),
)
expect(header).toEqual({
phase: 'working',
title: 'Uploading 6 images',
sub: '4 of 6 done',
summary: null,
})
})
it('uses singular forms', () => {
expect(describeUpload(rows('uploading')).title).toBe('Uploading 1 image')
expect(describeUpload(rows('added')).sub).toBe('1 image')
})
it('settles with a summary', () => {
const header = describeUpload(rows('added', 'added', 'duplicate', 'rejected', 'failed'))
expect(header).toEqual({
phase: 'settled',
title: 'Upload finished',
sub: '5 images',
summary: '2 uploaded · 1 already in your library · 2 need attention',
})
})
it('reads Nothing was uploaded without a summary when nothing was added', () => {
expect(describeUpload(rows('rejected', 'rejected', 'rejected'))).toEqual({
phase: 'settled',
title: 'Nothing was uploaded',
sub: '3 files were rejected',
summary: null,
})
expect(describeUpload(rows('rejected', 'failed'))).toMatchObject({
title: 'Nothing was uploaded',
sub: '2 files need attention',
summary: null,
})
expect(describeUpload(rows('failed'))).toMatchObject({ sub: '1 file needs attention' })
expect(describeUpload(rows('rejected'))).toMatchObject({ sub: '1 file was rejected' })
})
})
describe('rejection copy', () => {
it.each([
['size', 'scan.tif', 14.2 * 1024 * 1024, 'scan.tif is 14.2 MB'],
['size', 'photo.png', 10.1 * 1024 * 1024, 'photo.png is 10.1 MB'],
['type', 'notes.pdf', 215040, "notes.pdf isn't an image"],
['unreadable', 'broken.png', 3145728, "broken.png couldn't be read"],
] as const)('titles a %s rejection of %s', (kind, name, bytes, title) => {
expect(rejectionTitle(kind, name, bytes)).toBe(title)
})
it('picks the text per kind', () => {
expect(rejectionText('size')).toBe(
'Images can be up to 10 MB. Use a PNG, JPEG, WEBP or SVG image.',
)
expect(rejectionText('type')).toBe(rejectionText('size'))
expect(rejectionText('unreadable')).toBe(
"The server couldn't read this image. Save it again as a PNG or JPEG, then try again.",
)
})
})
describe('parseRetryAfter', () => {
it('reads whole seconds and falls back to 60', () => {
expect(parseRetryAfter('42')).toBe(42)
expect(parseRetryAfter(null)).toBe(60)
expect(parseRetryAfter('soon')).toBe(60)
expect(parseRetryAfter('')).toBe(60)
})
})
+33
View File
@@ -0,0 +1,33 @@
const HASH_ALGORITHM = 'SHA-256'
const HEX_RADIX = 16
const HEX_PAIR_WIDTH = 2
function readBytes(file: File): Promise<ArrayBuffer> {
if (typeof file.arrayBuffer === 'function') {
return file.arrayBuffer()
}
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result as ArrayBuffer)
reader.onerror = () => reject(reader.error)
reader.readAsArrayBuffer(file)
})
}
function toHex(digest: ArrayBuffer) {
return Array.from(new Uint8Array(digest), (byte) =>
byte.toString(HEX_RADIX).padStart(HEX_PAIR_WIDTH, '0'),
).join('')
}
export async function hashFile(file: File): Promise<string | null> {
const subtle = globalThis.crypto?.subtle
if (!subtle) {
return null
}
try {
return toHex(await subtle.digest(HASH_ALGORITHM, await readBytes(file)))
} catch {
return null
}
}
+11
View File
@@ -27,6 +27,17 @@ export function validateImageFile(file: File): ImageValidationResult {
return { ok: true }
}
const EXTENSIONS_BY_MIME_TYPE: Record<string, string[]> = {
'image/png': ['.png'],
'image/jpeg': ['.jpg', '.jpeg'],
'image/webp': ['.webp'],
'image/svg+xml': ['.svg'],
}
export function extensionMatchesType(file: File) {
return EXTENSIONS_BY_MIME_TYPE[file.type]?.includes(fileExtension(file.name)) ?? false
}
export function formatMegabytes(bytes: number) {
return (bytes / (1024 * 1024)).toFixed(1)
}
+42
View File
@@ -0,0 +1,42 @@
import type { MediaItem } from '@/composables/useMedia'
import { formatDimensions, formatSize } from '@/lib/mediaFormat'
export interface DuplicateGroup {
hash: string
copies: MediaItem[]
suggested: MediaItem
header: string
}
function byAge(first: MediaItem, second: MediaItem) {
return Date.parse(first.created_at) - Date.parse(second.created_at) || first.id - second.id
}
function byPreference(first: MediaItem, second: MediaItem) {
return second.used_in - first.used_in || byAge(first, second)
}
export function groupHeader(copies: MediaItem[]) {
const [first] = copies
return `${copies.length} identical images · ${formatSize(first!.size)} each · ${formatDimensions(first!.width, first!.height)}`
}
export function groupDuplicates(items: MediaItem[]): DuplicateGroup[] {
const byHash = new Map<string, MediaItem[]>()
for (const item of items) {
byHash.set(item.sha256, [...(byHash.get(item.sha256) ?? []), item])
}
return [...byHash.entries()]
.filter(([, copies]) => copies.length > 1)
.map(([hash, copies]) => {
const sorted = [...copies].sort(byPreference)
return { hash, copies: sorted, suggested: sorted[0]!, header: groupHeader(sorted) }
})
.sort((first, second) => byAge(first.suggested, second.suggested))
}
export function mergeArticleCount(copies: MediaItem[], keepId: number) {
return copies
.filter((copy) => copy.id !== keepId)
.reduce((total, copy) => total + copy.used_in, 0)
}
+7
View File
@@ -69,3 +69,10 @@ export function isValidFilenameStem(value: string) {
LETTER_OR_NUMBER.test(stem)
)
}
export function joinNames(names: string[]) {
if (names.length <= 1) {
return names.join('')
}
return `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`
}
+2 -2
View File
@@ -1,6 +1,6 @@
import type { LocationQuery } from 'vue-router'
export type MediaFilter = 'all' | 'unused'
export type MediaFilter = 'all' | 'unused' | 'duplicates'
export type MediaSort = 'newest' | 'oldest' | 'name' | 'largest'
export type MediaView = 'grid' | 'list'
@@ -20,7 +20,7 @@ export const DEFAULT_CONTROLS: MediaControls = {
view: 'grid',
}
const FILTERS: MediaFilter[] = ['all', 'unused']
const FILTERS: MediaFilter[] = ['all', 'unused', 'duplicates']
const SORTS: MediaSort[] = ['newest', 'oldest', 'name', 'largest']
const VIEWS: MediaView[] = ['grid', 'list']
+118
View File
@@ -0,0 +1,118 @@
import { formatMegabytes } from '@/lib/imageUpload'
export type UploadRowStatus =
| 'waiting'
| 'uploading'
| 'added'
| 'duplicate'
| 'rejected'
| 'failed'
export type UploadRejection = 'size' | 'type' | 'unreadable'
export type UploadPhase = 'working' | 'settled'
export interface UploadRowLike {
status: UploadRowStatus
}
export interface UploadHeader {
phase: UploadPhase
title: string
sub: string
summary: string | null
}
export const REJECTION_TEXT = 'Images can be up to 10 MB. Use a PNG, JPEG, WEBP or SVG image.'
export const UNREADABLE_TEXT =
"The server couldn't read this image. Save it again as a PNG or JPEG, then try again."
export const FAILED_TITLE = 'Upload failed'
export const FAILED_TEXT = "The server didn't accept the file. Nothing was added to your library."
export const ADDED_TEXT = 'Added to your library'
export const DUPLICATE_TITLE = 'Already in your library'
export const DROP_TITLE = 'Drop images to upload'
export const DROP_TEXT = 'PNG, JPEG, WEBP, SVG · max 10 MB each'
export const RATE_LIMITED_TITLE = 'Too many uploads'
export const RATE_LIMITED_TEXT = 'For your security, wait a moment before uploading again.'
export const DEFAULT_RETRY_AFTER_SECONDS = 60
export const UPLOAD_ACCEPT = 'image/png,image/jpeg,image/webp,image/svg+xml'
const SETTLED: UploadRowStatus[] = ['added', 'duplicate', 'rejected', 'failed']
export function isSettled(status: UploadRowStatus) {
return SETTLED.includes(status)
}
function imageCount(total: number) {
return `${total} ${total === 1 ? 'image' : 'images'}`
}
function fileCount(total: number) {
return `${total} ${total === 1 ? 'file' : 'files'}`
}
export function rejectionTitle(kind: UploadRejection, filename: string, bytes: number) {
if (kind === 'size') {
return `${filename} is ${formatMegabytes(bytes)} MB`
}
if (kind === 'unreadable') {
return `${filename} couldn't be read`
}
return `${filename} isn't an image`
}
export function rejectionText(kind: UploadRejection) {
return kind === 'unreadable' ? UNREADABLE_TEXT : REJECTION_TEXT
}
export function duplicateText(
existing: { filename: string; created_at: string; uploaded_by: string },
formatDate: (iso: string) => string,
) {
const by = existing.uploaded_by ? ` by ${existing.uploaded_by}` : ''
return `Same as "${existing.filename}", uploaded ${formatDate(existing.created_at)}${by}.`
}
export function summaryLine(counts: { added: number; duplicate: number; attention: number }) {
const parts = [
...(counts.added > 0 ? [`${counts.added} uploaded`] : []),
...(counts.duplicate > 0 ? [`${counts.duplicate} already in your library`] : []),
...(counts.attention > 0 ? [`${counts.attention} need attention`] : []),
]
return parts.join(' · ')
}
export function describeUpload(rows: UploadRowLike[]): UploadHeader {
const total = rows.length
const count = (status: UploadRowStatus) => rows.filter((row) => row.status === status).length
const settled = rows.filter((row) => isSettled(row.status)).length
if (settled < total) {
return {
phase: 'working',
title: `Uploading ${imageCount(total)}`,
sub: `${settled} of ${total} done`,
summary: null,
}
}
const added = count('added')
const duplicate = count('duplicate')
const attention = count('rejected') + count('failed')
if (added === 0 && duplicate === 0) {
const sub =
count('rejected') === total
? `${fileCount(total)} ${total === 1 ? 'was' : 'were'} rejected`
: `${fileCount(total)} ${total === 1 ? 'needs' : 'need'} attention`
return { phase: 'settled', title: 'Nothing was uploaded', sub, summary: null }
}
return {
phase: 'settled',
title: 'Upload finished',
sub: imageCount(total),
summary: summaryLine({ added, duplicate, attention }),
}
}
export function parseRetryAfter(header: string | null) {
if (header === null || !/^\d+$/.test(header.trim())) {
return DEFAULT_RETRY_AFTER_SECONDS
}
return Math.max(1, Number.parseInt(header, 10))
}