feat: sites and authors integration into existing screens (& formated all frontend files kinda accidentally)

This commit is contained in:
Florian Sylvain committed 2026-10-15 05:25:44 +02:00
1 parent 3d81d2c717
commit 6b8990c55e
239 files changed
+9194 -1354

No files matched your search

+41 -7
View File
@@ -38,7 +38,11 @@ function entries(query: string) {
describe('Queries', () => {
it('always sends range, page and size and nothing else by default', () => {
expect(entries(listQuery(filters(), 1, 25))).toEqual({ range: '7d', page: '1', size: '25' })
expect(entries(listQuery(filters(), 1, 25))).toEqual({
range: '7d',
page: '1',
size: '25',
})
})
it('adds q, actor, category, outcome and security only when set', () => {
@@ -69,18 +73,36 @@ describe('Queries', () => {
it('sends from and to only with the custom range', () => {
expect(
entries(
listQuery(filters({ range: '30d', from: '2026-09-01', to: '2026-09-30' }), 1, 25),
listQuery(
filters({
range: '30d',
from: '2026-09-01',
to: '2026-09-30',
}),
1,
25,
),
),
).toEqual({ range: '30d', page: '1', size: '25' })
expect(
entries(
listQuery(
filters({ range: 'custom', from: '2026-09-01', to: '2026-09-30' }),
filters({
range: 'custom',
from: '2026-09-01',
to: '2026-09-30',
}),
1,
25,
),
),
).toEqual({ range: 'custom', from: '2026-09-01', to: '2026-09-30', page: '1', size: '25' })
).toEqual({
range: 'custom',
from: '2026-09-01',
to: '2026-09-30',
page: '1',
size: '25',
})
})
it('never sends an actor for a regular user', () => {
@@ -153,10 +175,18 @@ describe('Chips', () => {
})
it('resets one filter at a time', () => {
const all = filters({ q: 'a', actor: 'system', actorLabel: 'System', range: '30d' })
const all = filters({
q: 'a',
actor: 'system',
actorLabel: 'System',
range: '30d',
})
expect(clearChip(all, 'q').q).toBe('')
expect(clearChip(all, 'actor')).toMatchObject({ actor: '', actorLabel: '' })
expect(clearChip(all, 'actor')).toMatchObject({
actor: '',
actorLabel: '',
})
expect(clearChip(all, 'range').range).toBe('7d')
})
})
@@ -280,7 +310,11 @@ describe('Summary strip', () => {
it('words singulars, the French forms and the flag count', () => {
const single = stripCards(
{ ...summaryDefault, active_people: { users: 1, api_keys: 1 }, security: 1 },
{
...summaryDefault,
active_people: { users: 1, api_keys: 1 },
security: 1,
},
'7d',
)
expect(single[1]!.subline).toBe('1 person, 1 API key')
@@ -38,7 +38,11 @@ const rows: [string, Partial<ActivityItem>, string, string | null][] = [
'media.variant_purge_unused success',
{
action: 'media.variant_purge_unused',
params: { purged_files: 312, purged_bytes: 42991616, older_than_days: 30 },
params: {
purged_files: 312,
purged_bytes: 42991616,
older_than_days: 30,
},
},
'Purged unused variants',
'Removed 312 files (41 MB) not requested for 30 days',
@@ -97,14 +101,20 @@ describe('The Activity screen words each cache purge scope', () => {
it('A partial purge adds the failed count to the detail line', () => {
const subject = purgeItem({
params: { purged_files: 640, purged_bytes: 111149056, failed_files: 644 },
params: {
purged_files: 640,
purged_bytes: 111149056,
failed_files: 644,
},
})
expect(detailLine(subject)).toBe('Removed 640 files (106 MB) · 644 failed')
})
it('The singular reads 1 file', () => {
const subject = purgeItem({ params: { purged_files: 1, purged_bytes: 2048 } })
const subject = purgeItem({
params: { purged_files: 1, purged_bytes: 2048 },
})
expect(detailLine(subject)).toBe('Removed 1 file (2 KB)')
})
+37 -7
View File
@@ -16,7 +16,12 @@ function item(overrides: Partial<ActivityItem>): ActivityItem {
action: 'article.publish',
category: 'articles',
actor: { type: 'user', id: 3, name: 'Marta Alves' },
target: { type: 'article', id: '8', name: 'Spring release', exists: true },
target: {
type: 'article',
id: '8',
name: 'Spring release',
exists: true,
},
outcome: 'success',
failure_reason: null,
security: false,
@@ -55,7 +60,11 @@ describe('Sentences: English and French come from the same code and params', ()
],
[
'media.purge',
{ action: 'media.purge', target: null, params: { merged: 3, articles_updated: 5 } },
{
action: 'media.purge',
target: null,
params: { merged: 3, articles_updated: 5 },
},
'Purged duplicate images: 3 merged, 5 articles updated',
'Doublons purgés : 3 fusionnées, 5 articles mis à jour',
],
@@ -81,13 +90,21 @@ describe('Sentences: English and French come from the same code and params', ()
],
[
'system.activity_cleanup singular',
{ action: 'system.activity_cleanup', target: null, params: { removed: 1 } },
{
action: 'system.activity_cleanup',
target: null,
params: { removed: 1 },
},
'Removed 1 old event from the activity log',
"1 ancien événement supprimé du journal d'activité",
],
[
'system.activity_cleanup plural',
{ action: 'system.activity_cleanup', target: null, params: { removed: 1284 } },
{
action: 'system.activity_cleanup',
target: null,
params: { removed: 1284 },
},
'Removed 1,284 old events from the activity log',
"1 284 anciens événements supprimés du journal d'activité",
],
@@ -125,7 +142,12 @@ describe('Sentences: English and French come from the same code and params', ()
'system.restart_requested update',
{
action: 'system.restart_requested',
target: { type: 'release', id: 'v1.4.0', name: 'v1.4.0', exists: true },
target: {
type: 'release',
id: 'v1.4.0',
name: 'v1.4.0',
exists: true,
},
params: { kind: 'update', tag: 'v1.4.0' },
},
'Restarted to apply the update v1.4.0',
@@ -133,7 +155,11 @@ describe('Sentences: English and French come from the same code and params', ()
],
[
'system.restart_requested restart',
{ action: 'system.restart_requested', target: null, params: { kind: 'restart' } },
{
action: 'system.restart_requested',
target: null,
params: { kind: 'restart' },
},
'Requested a restart',
'Redémarrage demandé',
],
@@ -155,7 +181,11 @@ describe('Sentences: English and French come from the same code and params', ()
action: 'auth.login_failed',
target: null,
outcome: 'failed',
actor: { type: 'anonymous', id: null, name: 'alex@example.com' },
actor: {
type: 'anonymous',
id: null,
name: 'alex@example.com',
},
},
'Failed login attempt for alex@example.com',
'Échec de connexion pour alex@example.com',
+44 -9
View File
@@ -51,7 +51,11 @@ describe('Selecting an inserted image reveals its delete button', () => {
const { wrapper, getEditor } = mountHarness()
activeWrapper = wrapper
const editor = getEditor()
editor.commands.setArticleImage({ src: 'photo.png', alt: '', imageId: 10 })
editor.commands.setArticleImage({
src: 'photo.png',
alt: '',
imageId: 10,
})
const pos = findArticleImagePos(editor, 10) as number
editor.commands.setNodeSelection(pos)
@@ -69,7 +73,11 @@ describe('The remove button is labelled and neutral', () => {
const { wrapper, getEditor } = mountHarness()
activeWrapper = wrapper
const editor = getEditor()
editor.commands.setArticleImage({ src: 'photo.png', alt: '', imageId: 10 })
editor.commands.setArticleImage({
src: 'photo.png',
alt: '',
imageId: 10,
})
editor.commands.setNodeSelection(findArticleImagePos(editor, 10) as number)
await flush()
@@ -106,7 +114,11 @@ describe('Activating the delete button triggers removal without deleting the nod
const { wrapper, getEditor, onRequestRemove } = mountHarness()
activeWrapper = wrapper
const editor = getEditor()
editor.commands.setArticleImage({ src: 'photo.png', alt: '', imageId: 10 })
editor.commands.setArticleImage({
src: 'photo.png',
alt: '',
imageId: 10,
})
const pos = findArticleImagePos(editor, 10) as number
editor.commands.setNodeSelection(pos)
await flush()
@@ -123,7 +135,11 @@ describe('Pressing Backspace on a selected image triggers removal without deleti
const { wrapper, getEditor, onRequestRemove } = mountHarness()
activeWrapper = wrapper
const editor = getEditor()
editor.commands.setArticleImage({ src: 'photo.png', alt: '', imageId: 10 })
editor.commands.setArticleImage({
src: 'photo.png',
alt: '',
imageId: 10,
})
const pos = findArticleImagePos(editor, 10) as number
editor.commands.setNodeSelection(pos)
@@ -139,7 +155,11 @@ describe('Pressing Delete on a selected image triggers removal without deleting
const { wrapper, getEditor, onRequestRemove } = mountHarness()
activeWrapper = wrapper
const editor = getEditor()
editor.commands.setArticleImage({ src: 'photo.png', alt: '', imageId: 10 })
editor.commands.setArticleImage({
src: 'photo.png',
alt: '',
imageId: 10,
})
const pos = findArticleImagePos(editor, 10) as number
editor.commands.setNodeSelection(pos)
@@ -175,7 +195,11 @@ describe('The custom image node view renders as a block-level element', () => {
const { wrapper, getEditor } = mountHarness()
activeWrapper = wrapper
const editor = getEditor()
editor.commands.setArticleImage({ src: 'photo.png', alt: '', imageId: 10 })
editor.commands.setArticleImage({
src: 'photo.png',
alt: '',
imageId: 10,
})
await flush()
const node = document.body.querySelector<HTMLElement>(
@@ -191,7 +215,11 @@ describe('Selecting an image shows a visible selection outline', () => {
const { wrapper, getEditor } = mountHarness()
activeWrapper = wrapper
const editor = getEditor()
editor.commands.setArticleImage({ src: 'photo.png', alt: '', imageId: 10 })
editor.commands.setArticleImage({
src: 'photo.png',
alt: '',
imageId: 10,
})
const pos = findArticleImagePos(editor, 10) as number
editor.commands.setNodeSelection(pos)
@@ -236,7 +264,11 @@ describe('The image node is not natively draggable', () => {
const { wrapper, getEditor } = mountHarness()
activeWrapper = wrapper
const editor = getEditor()
editor.commands.setArticleImage({ src: 'photo.png', alt: '', imageId: 10 })
editor.commands.setArticleImage({
src: 'photo.png',
alt: '',
imageId: 10,
})
await flush()
const node = document.body.querySelector<HTMLElement>(
@@ -294,7 +326,10 @@ describe('An image that fails to load', () => {
const { wrapper, getEditor } = mountHarness()
activeWrapper = wrapper
const editor = getEditor()
editor.commands.setArticleImage({ src: '/static/uploadedImages/gone.png', alt: '' })
editor.commands.setArticleImage({
src: '/static/uploadedImages/gone.png',
alt: '',
})
await flush()
document.body.querySelector('img')!.dispatchEvent(new Event('error'))
+63 -7
View File
@@ -3,7 +3,14 @@ import { nextSort, parseArticleQuery, sameArticleQuery, toRouteQuery } from '@/l
describe('parseArticleQuery', () => {
it('falls back to page 1, size 25, no search and the All tab', () => {
expect(parseArticleQuery({})).toEqual({ page: 1, size: 25, q: '', status: '', sort: '' })
expect(parseArticleQuery({})).toEqual({
page: 1,
size: 25,
q: '',
status: '',
sort: '',
site: '',
})
})
it('reads valid values', () => {
@@ -15,7 +22,14 @@ describe('parseArticleQuery', () => {
status: 'draft',
sort: 'title:asc',
}),
).toEqual({ page: 7, size: 50, q: 'plaster', status: 'draft', sort: 'title:asc' })
).toEqual({
page: 7,
size: 50,
q: 'plaster',
status: 'draft',
sort: 'title:asc',
site: '',
})
})
it.each(['abc', '0', '-2', '2.5', '', '1e2'])('treats page "%s" as 1', (page) => {
@@ -53,9 +67,11 @@ describe('sort parameter', () => {
})
it('omits the default from the route and treats it as equal to no sort', () => {
const base = { page: 1, size: 25, q: '', status: '' as const }
const base = { page: 1, size: 25, q: '', status: '' as const, site: '' }
expect(toRouteQuery({ ...base, sort: 'updated_at:desc' })).toEqual({})
expect(toRouteQuery({ ...base, sort: 'title:asc' })).toEqual({ sort: 'title:asc' })
expect(toRouteQuery({ ...base, sort: 'title:asc' })).toEqual({
sort: 'title:asc',
})
expect(sameArticleQuery({ ...base, sort: '' }, { ...base, sort: 'updated_at:desc' })).toBe(
true,
)
@@ -74,12 +90,28 @@ describe('sort parameter', () => {
describe('toRouteQuery', () => {
it('omits every default', () => {
expect(toRouteQuery({ page: 1, size: 25, q: '', status: '', sort: '' })).toEqual({})
expect(
toRouteQuery({
page: 1,
size: 25,
q: '',
status: '',
sort: '',
site: '',
}),
).toEqual({})
})
it('keeps what differs from the defaults', () => {
expect(
toRouteQuery({ page: 2, size: 5, q: 'plaster', status: 'online', sort: 'title:desc' }),
toRouteQuery({
page: 2,
size: 5,
q: 'plaster',
status: 'online',
sort: 'title:desc',
site: '',
}),
).toEqual({
page: '2',
size: '5',
@@ -92,7 +124,14 @@ describe('toRouteQuery', () => {
describe('sameArticleQuery', () => {
it('compares every field', () => {
const base = { page: 1, size: 25, q: '', status: '' as const, sort: '' }
const base = {
page: 1,
size: 25,
q: '',
status: '' as const,
sort: '',
site: '',
}
expect(sameArticleQuery(base, { ...base })).toBe(true)
expect(sameArticleQuery(base, { ...base, page: 2 })).toBe(false)
expect(sameArticleQuery(base, { ...base, size: 5 })).toBe(false)
@@ -100,3 +139,20 @@ describe('sameArticleQuery', () => {
expect(sameArticleQuery(base, { ...base, status: 'draft' })).toBe(false)
})
})
describe('site parameter', () => {
it('reads the site key and writes it back to the route', () => {
const parsed = parseArticleQuery({ site: 'journal' })
expect(parsed.site).toBe('journal')
expect(toRouteQuery(parsed)).toEqual({ site: 'journal' })
})
it('omits an empty site and tells two queries apart by their site', () => {
const base = { page: 1, size: 25, q: '', status: '' as const, sort: '' }
expect(toRouteQuery({ ...base, site: '' })).toEqual({})
expect(sameArticleQuery({ ...base, site: 'journal' }, { ...base, site: '' })).toBe(false)
expect(sameArticleQuery({ ...base, site: 'journal' }, { ...base, site: 'journal' })).toBe(
true,
)
})
})
@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest'
import { baseSlugOf } from '@/lib/authorProfile'
describe('The base slug of an adjusted slug', () => {
it.each([
['marta-alves-2', 'marta-alves'],
['ines-duarte-3', 'ines-duarte'],
['jean-luc-o-neil-2', 'jean-luc-o-neil'],
['agent-007-2', 'agent-007'],
['zoe-muller-12', 'zoe-muller'],
])('strips the one collision suffix of "%s"', (slug, base) => {
expect(baseSlugOf(slug)).toBe(base)
})
it('leaves a slug without a numeric suffix alone', () => {
expect(baseSlugOf('marta-alves')).toBe('marta-alves')
})
})
+3 -1
View File
@@ -35,7 +35,9 @@ describe('hashFile', () => {
})
it('resolves null when the digest rejects', async () => {
vi.stubGlobal('crypto', { subtle: { digest: vi.fn().mockRejectedValue(new Error('no')) } })
vi.stubGlobal('crypto', {
subtle: { digest: vi.fn().mockRejectedValue(new Error('no')) },
})
expect(await hashFile(fileOf([1]))).toBeNull()
})
+16 -4
View File
@@ -32,11 +32,17 @@ describe('rectForRatio', () => {
describe('computeOutputSize', () => {
it('never upscales past the crop width', () => {
expect(computeOutputSize(640, 640, 1600)).toEqual({ width: 640, height: 640 })
expect(computeOutputSize(640, 640, 1600)).toEqual({
width: 640,
height: 640,
})
})
it('caps to the width preset when the crop is larger', () => {
expect(computeOutputSize(4032, 3024, 1600)).toEqual({ width: 1600, height: 1200 })
expect(computeOutputSize(4032, 3024, 1600)).toEqual({
width: 1600,
height: 1200,
})
})
})
@@ -177,10 +183,16 @@ describe('rotateRectBySteps', () => {
describe('rotatedDimensions', () => {
it('swaps dimensions for a 90deg rotation', () => {
expect(rotatedDimensions(4032, 3024, 90)).toEqual({ width: 3024, height: 4032 })
expect(rotatedDimensions(4032, 3024, 90)).toEqual({
width: 3024,
height: 4032,
})
})
it('keeps dimensions for a 180deg rotation', () => {
expect(rotatedDimensions(4032, 3024, 180)).toEqual({ width: 4032, height: 3024 })
expect(rotatedDimensions(4032, 3024, 180)).toEqual({
width: 4032,
height: 3024,
})
})
})
+4 -1
View File
@@ -21,7 +21,10 @@ describe('validateImageFile', () => {
})
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.gif', 'image/gif'))).toEqual({
ok: false,
kind: 'type',
})
expect(validateImageFile(fileOf('a.jpg', 'text/plain'))).toEqual({
ok: false,
kind: 'type',
+10 -2
View File
@@ -74,8 +74,16 @@ describe('groupDuplicates', () => {
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(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' }),
])
+12 -3
View File
@@ -75,9 +75,18 @@ describe('usedInLabel and accessibleName', () => {
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: '' })
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: '',
})
})
})
+20 -4
View File
@@ -32,7 +32,10 @@ describe('parseMediaControls', () => {
[{ view: 'grid' }, {}],
[{ sort: 'newest' }, {}],
])('maps %j', (query, expected) => {
expect(parseMediaControls(query)).toEqual({ ...DEFAULT_CONTROLS, ...expected })
expect(parseMediaControls(query)).toEqual({
...DEFAULT_CONTROLS,
...expected,
})
})
it.each([
@@ -51,7 +54,10 @@ describe('parseMediaControls', () => {
[{ size: '200' }, {}],
[{ size: '' }, {}],
])('maps the paging of %j', (query, expected) => {
expect(parseMediaControls(query)).toEqual({ ...DEFAULT_CONTROLS, ...expected })
expect(parseMediaControls(query)).toEqual({
...DEFAULT_CONTROLS,
...expected,
})
})
it('uses the stored size only when the URL has no size', () => {
@@ -82,7 +88,12 @@ describe('toRouteQuery', () => {
sort: 'largest',
view: 'list',
}),
).toEqual({ q: 'wall', filter: 'unused', sort: 'largest', view: 'list' })
).toEqual({
q: 'wall',
filter: 'unused',
sort: 'largest',
view: 'list',
})
})
it('keeps the page and the size when they are not the defaults', () => {
@@ -94,7 +105,12 @@ describe('toRouteQuery', () => {
it('drops the page and the size on the Duplicates tab', () => {
expect(
toRouteQuery({ ...DEFAULT_CONTROLS, filter: 'duplicates', page: 4, size: 5 }),
toRouteQuery({
...DEFAULT_CONTROLS,
filter: 'duplicates',
page: 4,
size: 5,
}),
).toEqual({ filter: 'duplicates' })
})
})
@@ -65,8 +65,12 @@ describe('describeUpload', () => {
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' })
expect(describeUpload(rows('failed'))).toMatchObject({
sub: '1 file needs attention',
})
expect(describeUpload(rows('rejected'))).toMatchObject({
sub: '1 file was rejected',
})
})
})
+19 -4
View File
@@ -70,7 +70,12 @@ export interface ActivitySummary {
active_people: { users: number; api_keys: number }
failed: number
security: number
series: { total: number[]; active: number[]; failed: number[]; security: number[] }
series: {
total: number[]
active: number[]
failed: number[]
security: number[]
}
}
export interface ActivityFilters {
@@ -240,7 +245,10 @@ export function outcomeFilterLabel(outcome: OutcomeFilter) {
}
export function outcomeOptions() {
return OUTCOME_FILTERS.map((value) => ({ value, label: outcomeFilterLabel(value) }))
return OUTCOME_FILTERS.map((value) => ({
value,
label: outcomeFilterLabel(value),
}))
}
export function outcomeBadge(outcome: ActivityOutcome) {
@@ -269,7 +277,11 @@ export function rangeLabel(range: RangeKey) {
export function activeChips(filters: ActivityFilters): ActivityChip[] {
const chips: ActivityChip[] = []
if (filters.q) chips.push({ key: 'q', label: t('Search: {0}', [filters.q]) })
if (filters.actor) chips.push({ key: 'actor', label: t('Person: {0}', [filters.actorLabel]) })
if (filters.actor)
chips.push({
key: 'actor',
label: t('Person: {0}', [filters.actorLabel]),
})
if (filters.category)
chips.push({
key: 'category',
@@ -281,7 +293,10 @@ export function activeChips(filters: ActivityFilters): ActivityChip[] {
label: t('Outcome: {0}', [outcomeFilterLabel(filters.outcome)]),
})
if (filters.security)
chips.push({ key: 'security', label: t('Type: {0}', [t('Security events')]) })
chips.push({
key: 'security',
label: t('Type: {0}', [t('Security events')]),
})
if (filters.range !== DEFAULT_RANGE) {
const value =
filters.range === 'custom'
+4 -1
View File
@@ -58,7 +58,10 @@ export function formatLogSize(bytes: number) {
if (gb < MIN_SHOWN_GB) {
return t('Less than 0.1 GB')
}
const figure = formatNumber(gb, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
const figure = formatNumber(gb, {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
})
return t('{0} GB', [figure])
}
+21 -5
View File
@@ -25,7 +25,10 @@ const CATALOGUE: Record<string, Template> = {
success: 'Created the article {0}',
failed: 'Failed to create the article {0}',
},
'article.edit': { success: 'Edited the article {0}', failed: 'Failed to edit the article {0}' },
'article.edit': {
success: 'Edited the article {0}',
failed: 'Failed to edit the article {0}',
},
'article.publish': {
success: 'Published the article {0}',
failed: 'Failed to publish the article {0}',
@@ -46,7 +49,10 @@ const CATALOGUE: Record<string, Template> = {
success: 'Renamed the image {0}',
failed: 'Failed to rename the image {0}',
},
'media.delete': { success: 'Deleted the image {0}', failed: 'Failed to delete the image {0}' },
'media.delete': {
success: 'Deleted the image {0}',
failed: 'Failed to delete the image {0}',
},
'media.merge': {
success: 'Merged duplicates into the image {0}',
failed: 'Failed to merge duplicates into the image {0}',
@@ -128,11 +134,16 @@ const CATALOGUE: Record<string, Template> = {
success: 'Downloaded the update {0}',
failed: 'Failed to download the update {0}',
},
'system.restart_requested': { success: 'Requested a restart', failed: 'Failed to restart' },
'system.restart_requested': {
success: 'Requested a restart',
failed: 'Failed to restart',
},
'system.activity_retention_changed': {
success: 'Changed the activity retention from {0} to {1}',
},
'system.activity_cleanup': { success: 'Removed {0} old events from the activity log' },
'system.activity_cleanup': {
success: 'Removed {0} old events from the activity log',
},
}
function count(value: unknown) {
@@ -229,7 +240,12 @@ export function composeSentence(item: ActivityItem): SentenceSegment[] {
if (template === null) return [{ text: item.action, strong: false }]
if (item.action === 'system.activity_cleanup' && count(item.params.removed) === 1) {
return [{ text: t('Removed 1 old event from the activity log'), strong: false }]
return [
{
text: t('Removed 1 old event from the activity log'),
strong: false,
},
]
}
const values = slotValues(item, template)
+4 -1
View File
@@ -51,7 +51,10 @@ function peopleSubline(summary: ActivitySummary) {
function failedSubline(summary: ActivitySummary) {
const ratio = (summary.failed / summary.total) * 100
return t('{0}% of all actions', [
formatNumber(ratio, { minimumFractionDigits: 1, maximumFractionDigits: 1 }),
formatNumber(ratio, {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
}),
])
}
+9 -2
View File
@@ -17,7 +17,10 @@ function pad(value: number) {
function monthShort(date: Date, timeZone?: 'UTC') {
if (locale.value === 'fr') {
return new Intl.DateTimeFormat('fr', { month: 'short', timeZone }).format(date)
return new Intl.DateTimeFormat('fr', {
month: 'short',
timeZone,
}).format(date)
}
return MONTHS[timeZone ? date.getUTCMonth() : date.getMonth()]
}
@@ -101,7 +104,11 @@ export function groupByDay<T extends { created_at: string }>(
last.items.push(item)
continue
}
groups.push({ key, label: dayLabel(item.created_at, now), items: [item] })
groups.push({
key,
label: dayLabel(item.created_at, now),
items: [item],
})
}
return groups
}
+7 -2
View File
@@ -162,7 +162,9 @@ export const ArticleLink = Link.extend<ArticleLinkOptions>({
return null
}
return DecorationSet.create(state.doc, [
Decoration.inline(range.from, range.to, { class: LINK_FOCUS_CLASS }),
Decoration.inline(range.from, range.to, {
class: LINK_FOCUS_CLASS,
}),
])
},
handleClick: (view, pos, event) => {
@@ -180,7 +182,10 @@ export const ArticleLink = Link.extend<ArticleLinkOptions>({
return false
}
event.preventDefault()
this.options.onRequestPopover({ element, pos: view.posAtDOM(element, 0) })
this.options.onRequestPopover({
element,
pos: view.posAtDOM(element, 0),
})
return true
},
},
+7
View File
@@ -9,6 +9,7 @@ export const DEFAULT_ARTICLE_SORT = 'updated_at:desc'
export const ARTICLE_SORT_FIELDS = ['title', 'status', 'created_at', 'updated_at'] as const
export type ArticleSortField = (typeof ARTICLE_SORT_FIELDS)[number]
const SITE_KEY_MAX = 32
const POSITIVE_INTEGER = /^[1-9]\d*$/
export interface ArticleListQuery {
@@ -17,6 +18,7 @@ export interface ArticleListQuery {
q: string
status: ArticleStatus | ''
sort: string
site: string
}
function first(value: LocationQuery[string] | undefined) {
@@ -49,6 +51,7 @@ export function parseArticleQuery(query: LocationQuery): ArticleListQuery {
q: first(query.q).slice(0, ARTICLE_SEARCH_MAX_LENGTH),
status: status === 'online' || status === 'draft' ? status : '',
sort: validSort(first(query.sort)),
site: first(query.site).slice(0, SITE_KEY_MAX),
}
}
@@ -69,6 +72,9 @@ export function toRouteQuery(query: ArticleListQuery) {
if (effectiveSort(query.sort) !== DEFAULT_ARTICLE_SORT) {
route.sort = query.sort
}
if (query.site) {
route.site = query.site
}
return route
}
@@ -78,6 +84,7 @@ export function sameArticleQuery(left: ArticleListQuery, right: ArticleListQuery
left.size === right.size &&
left.q === right.q &&
left.status === right.status &&
left.site === right.site &&
effectiveSort(left.sort) === effectiveSort(right.sort)
)
}
+37
View File
@@ -0,0 +1,37 @@
import { t } from '@/i18n'
import type { Byline } from '@/lib/articleTypes'
export function authorsSummary(authors: Byline[]) {
const [first] = authors
if (!first) {
return t('No authors')
}
return authors.length === 1 ? first.name : t('{0} +{1}', [first.name, authors.length - 1])
}
export function metaSummary(authors: Byline[], siteName: string | null) {
const summary = authorsSummary(authors)
return siteName ? t('{0} · {1}', [siteName, summary]) : summary
}
export function moveItem<T>(items: T[], from: number, to: number) {
if (from === to || from < 0 || to < 0 || from >= items.length || to >= items.length) {
return items
}
const next = [...items]
const [moved] = next.splice(from, 1)
next.splice(to, 0, moved!)
return next
}
export function insertionTarget(from: number, gap: number) {
return gap > from ? gap - 1 : gap
}
export function sameIds(left: Byline[], right: Byline[]) {
return left.length === right.length && left.every((item, index) => item.id === right[index]?.id)
}
export function toBylines(authors: Byline[]): Byline[] {
return authors.map(({ id, name, slug }) => ({ id, name, slug }))
}
+5
View File
@@ -0,0 +1,5 @@
export interface Byline {
id: number
name: string
slug: string
}
+5
View File
@@ -0,0 +1,5 @@
const COLLISION_SUFFIX = /-\d+$/
export function baseSlugOf(adjustedSlug: string) {
return adjustedSlug.replace(COLLISION_SUFFIX, '')
}
+6 -1
View File
@@ -125,7 +125,12 @@ export function resizeRectTo(
}
function rotateRectClockwiseOnce(rect: CropRect, width: number, height: number): CropRect {
return { x: height - rect.y - rect.height, y: rect.x, width: rect.height, height: rect.width }
return {
x: height - rect.y - rect.height,
y: rect.x,
width: rect.height,
height: rect.width,
}
}
export function rotateRectBySteps(
+39
View File
@@ -0,0 +1,39 @@
export const META_SECTION_OPEN_KEY = 'renewcms-meta-section-open'
const HINT_DISMISSED_PREFIX = 'renewcms-author-hint-dismissed'
export function readMetaSectionOpen() {
try {
const raw = localStorage.getItem(META_SECTION_OPEN_KEY)
return raw === 'true' ? true : raw === 'false' ? false : null
} catch {
return null
}
}
export function storeMetaSectionOpen(open: boolean) {
try {
localStorage.setItem(META_SECTION_OPEN_KEY, String(open))
} catch {
return
}
}
export function hintDismissedKey(userId: number | undefined) {
return `${HINT_DISMISSED_PREFIX}:${userId ?? 'anonymous'}`
}
export function readHintDismissed(userId: number | undefined) {
try {
return localStorage.getItem(hintDismissedKey(userId)) === 'true'
} catch {
return false
}
}
export function storeHintDismissed(userId: number | undefined) {
try {
localStorage.setItem(hintDismissedKey(userId), 'true')
} catch {
return
}
}
+4 -1
View File
@@ -16,7 +16,10 @@ export function readRestartMemory(): RestartMemory | null {
const parsed = JSON.parse(raw) as Partial<RestartMemory>
const validKind = parsed.kind === 'update' || parsed.kind === 'restart'
return validKind && typeof parsed.started_at === 'string'
? { kind: parsed.kind as RestartKind, started_at: parsed.started_at }
? {
kind: parsed.kind as RestartKind,
started_at: parsed.started_at,
}
: null
} catch {
return null
@@ -53,7 +53,11 @@ describe('Output size, never enlarged', () => {
})
it('crops a square box out of the sample', () => {
const size = outputSize(SAMPLE, { width: 1000, height: 1000, fit: 'cover' })
const size = outputSize(SAMPLE, {
width: 1000,
height: 1000,
fit: 'cover',
})
expect(size).toEqual({ width: 1000, height: 1000 })
})
@@ -19,7 +19,13 @@ const RANGE_ERROR = 'Enter a value from 1 to 8000.'
const QUALITY_ERROR = 'Enter a value from 1 to 100.'
function values(overrides: Partial<VariantFormValues>): VariantFormValues {
return { ...blankValues(), name: 'hero', width: '1200', height: '630', ...overrides }
return {
...blankValues(),
name: 'hero',
width: '1200',
height: '630',
...overrides,
}
}
describe('Field errors', () => {
@@ -65,7 +71,12 @@ describe('Field errors', () => {
describe('Request body', () => {
it('builds the create body from the form', () => {
const body = toNewVariant(
values({ format: 'webp', quality: '82', generateOnUpload: true, enabled: true }),
values({
format: 'webp',
quality: '82',
generateOnUpload: true,
enabled: true,
}),
)
expect(body).toEqual({
@@ -126,7 +126,11 @@ describe('Footer sums are computed from the list', () => {
})
it('uses the singular for one variant with one file', () => {
const thumb = variant({ name: 'thumb', generated_files: 1, cache_size: 18 * 1024 })
const thumb = variant({
name: 'thumb',
generated_files: 1,
cache_size: 18 * 1024,
})
expect(variantsSummary([thumb])).toBe('1 variant · 1 generated file · 18 KB')
})
@@ -64,7 +64,12 @@ describe('Option labels', () => {
it('offers only Original for a vector image', () => {
const options = buildOptions(
{ ...NORTH_WALL, width: null, height: null, content_type: 'image/svg+xml' },
{
...NORTH_WALL,
width: null,
height: null,
content_type: 'image/svg+xml',
},
[variant({})],
)
+5 -1
View File
@@ -10,7 +10,11 @@ function splitSrc(src: string) {
if (queryAt === -1) {
return { base: withoutHash, query: '', hash }
}
return { base: withoutHash.slice(0, queryAt), query: withoutHash.slice(queryAt + 1), hash }
return {
base: withoutHash.slice(0, queryAt),
query: withoutHash.slice(queryAt + 1),
hash,
}
}
export function variantOfSrc(src: string | undefined | null) {
+3 -1
View File
@@ -30,7 +30,9 @@ export interface VariantFormValues {
enabled: boolean
}
export type FieldErrors = Partial<Record<FieldName, string>> & { dimension?: string }
export type FieldErrors = Partial<Record<FieldName, string>> & {
dimension?: string
}
export const FIELD_ORDER: FieldName[] = ['name', 'width', 'height', 'quality']
+4 -1
View File
@@ -12,7 +12,10 @@ export interface OutputRequest {
}
function scaled(size: PixelSize, factor: number): PixelSize {
return { width: Math.round(size.width * factor), height: Math.round(size.height * factor) }
return {
width: Math.round(size.width * factor),
height: Math.round(size.height * factor),
}
}
function singleDimension(source: PixelSize, width: number | null, height: number | null) {