feat: editorial article list

This commit is contained in:
Florian Sylvain committed 2026-11-02 07:28:44 +01:00
1 parent a0b3c58c52
commit a671b81f80
103 files changed
+6493 -1805

No files matched your search

+492
View File
@@ -0,0 +1,492 @@
type ArticleCapability = 'read' | 'edit' | 'publish' | 'delete' | 'create_translation'
interface Byline {
id: number
name: string
slug: string
}
interface CoverageEntry {
id: number
lang: string
title: string
is_online: boolean
language_enabled: boolean
capabilities: ArticleCapability[]
}
interface StoryItem {
id: number
title: string
body: string
is_online: boolean
lang: string
site_id: number
story_id: number
created_by: number
authors: Byline[]
created_at: string
updated_at: string
story_updated_at: string
story_authors: Byline[]
coverage: CoverageEntry[]
missing_langs: string[]
capabilities: ArticleCapability[]
}
interface StoryCounts {
all: number
online: number
draft: number
suggestions: number
missing: number
}
interface StoryPage {
items: StoryItem[]
total: number
page: number
size: number
counts: StoryCounts
}
type VersionState = [status: 'pub' | 'draft', needsReview: 0 | 1]
interface FixtureStory {
n: number
title: string
versions: Record<string, VersionState>
titles: Record<string, string>
by: string
updatedAt: string
seo?: boolean
}
export interface StorySite {
key: string
id: number
langs: string[]
disabled: string[]
def: string
stories: FixtureStory[]
}
export interface StoryViewer {
read?: string[]
create?: boolean
scope?: (story: FixtureStory) => boolean
}
export interface StoryRequest {
site: string
lang: string
tab: string
status: string
author: string
seo: string
q: string
page: number
size: number
}
export const BYLINES: Record<string, { id: number; name: string; slug: string }> = {
'Marta Alves': { id: 1, name: 'Marta Alves', slug: 'marta-alves' },
'Camille Roche': { id: 2, name: 'Camille Roche', slug: 'camille-roche' },
'Luis Ferrer': { id: 3, name: 'Luis Ferrer', slug: 'luis-ferrer' },
}
function story(
n: number,
title: string,
versions: Record<string, VersionState>,
titles: Record<string, string>,
by: string,
updatedAt: string,
seo = false,
): FixtureStory {
return { n, title, versions, titles, by, updatedAt, seo }
}
const JOURNAL_STORIES = [
story(
1,
'How we price a bathroom renovation',
{ en: ['pub', 0], fr: ['draft', 1], de: ['pub', 0], ar: ['draft', 1] },
{
fr: 'Comment nous chiffrons une rénovation de salle de bain',
de: 'So kalkulieren wir eine Badsanierung',
ar: 'كيف نسعّر تجديد الحمّام',
},
'Marta Alves',
'2026-10-30T10:00:00Z',
),
story(
2,
'A guide to lime plaster',
{ en: ['pub', 0], fr: ['pub', 0], de: ['pub', 0] },
{ fr: "Guide de l'enduit à la chaux", de: 'Ein Leitfaden zu Kalkputz' },
'Camille Roche',
'2026-10-29T09:00:00Z',
),
story(
3,
'Winter checklist for timber windows',
{ en: ['pub', 0], fr: ['pub', 1] },
{ fr: "Liste d'hiver pour les fenêtres en bois" },
'Luis Ferrer',
'2026-10-28T09:00:00Z',
),
story(
4,
'Spring newsletter',
{ en: ['draft', 0] },
{},
'Marta Alves',
'2026-10-27T09:00:00Z',
true,
),
story(
5,
'Roof inspection checklist',
{ en: ['pub', 0], de: ['draft', 0] },
{ de: 'Checkliste für die Dachprüfung' },
'Luis Ferrer',
'2026-10-14T09:00:00Z',
true,
),
story(
6,
'Why we reuse original floorboards',
{ en: ['pub', 0], fr: ['draft', 0] },
{ fr: "Pourquoi nous réutilisons les lames de parquet d'origine" },
'Camille Roche',
'2026-10-13T09:00:00Z',
),
story(
7,
'Our first passive-house retrofit',
{ en: ['pub', 0], de: ['pub', 0] },
{ de: 'Unsere erste Passivhaus-Sanierung' },
'Marta Alves',
'2026-10-12T09:00:00Z',
),
story(
8,
'Cómo elegir un contratista',
{ es: ['pub', 0] },
{},
'Luis Ferrer',
'2026-10-10T09:00:00Z',
),
]
const PRESS_STORIES = [
story(
11,
'Opening hours over the holidays',
{ en: ['pub', 0], fr: ['pub', 0] },
{ fr: 'Horaires pendant les fêtes' },
'Marta Alves',
'2026-10-31T09:00:00Z',
),
story(
12,
'Meet the new site manager',
{ en: ['pub', 0], fr: ['draft', 1] },
{ fr: 'Rencontrez la nouvelle cheffe de chantier' },
'Luis Ferrer',
'2026-10-29T09:00:00Z',
),
story(
13,
'Annual report',
{ en: ['draft', 0], fr: ['pub', 0] },
{ fr: 'Rapport annuel' },
'Marta Alves',
'2026-10-14T09:00:00Z',
),
]
const ATELIER_STORIES = [
story(
21,
'A complete guide to renovating a ground-floor kitchen and bathroom in a listed building, with every cost itemised for owners and tenants',
{
en: ['pub', 0],
'en-GB': ['draft', 0],
fr: ['draft', 1],
de: ['pub', 0],
'pt-BR': ['draft', 0],
'zh-Hans': ['pub', 0],
ja: ['pub', 0],
ar: ['draft', 0],
},
{},
'Marta Alves',
'2026-10-30T10:00:00Z',
true,
),
story(
22,
'Heat pumps for stone farmhouses',
{ en: ['pub', 0], 'en-GB': ['pub', 0], fr: ['draft', 0], de: ['pub', 0], ja: ['draft', 0] },
{},
'Luis Ferrer',
'2026-10-29T09:00:00Z',
),
story(
23,
'Sourcing reclaimed oak beams',
{ en: ['draft', 0], 'en-GB': ['draft', 0] },
{},
'Camille Roche',
'2026-10-14T09:00:00Z',
true,
),
]
export const STORY_SITES: Record<string, StorySite> = {
journal: {
key: 'journal',
id: 1,
langs: ['en', 'fr', 'de', 'ar'],
disabled: ['es', 'it'],
def: 'en',
stories: JOURNAL_STORIES,
},
press: {
key: 'press',
id: 6,
langs: ['en', 'fr'],
disabled: [],
def: 'en',
stories: PRESS_STORIES,
},
atelier: {
key: 'atelier',
id: 2,
langs: ['en', 'en-GB', 'fr', 'de', 'pt-BR', 'zh-Hans', 'ja', 'ar'],
disabled: [],
def: 'en',
stories: ATELIER_STORIES,
},
}
export const LIMITED_VIEWER: StoryViewer = {
read: ['en'],
create: false,
scope: (entry) => ['Camille Roche', 'Luis Ferrer'].includes(entry.by),
}
const FULL: ArticleCapability[] = ['read', 'edit', 'publish', 'delete', 'create_translation']
const READ_ONLY: ArticleCapability[] = ['read']
export function requestOf(url: URL): StoryRequest {
const params = url.searchParams
return {
site: params.get('site') ?? 'journal',
lang: params.get('lang') ?? '',
tab: params.get('tab') ?? '',
status: params.get('status') ?? '',
author: params.get('author') ?? '',
seo: params.get('seo') ?? '',
q: (params.get('q') ?? '').toLowerCase(),
page: Number(params.get('page') ?? 1),
size: Number(params.get('size') ?? 25),
}
}
function titleIn(entry: FixtureStory, lang: string) {
return entry.titles[lang] ?? entry.title
}
function idOf(entry: FixtureStory, lang: string) {
return entry.n * 100 + Object.keys(entry.versions).indexOf(lang) + 1
}
function representative(entry: FixtureStory, def: string) {
return entry.versions[def] ? def : Object.keys(entry.versions).sort()[0]!
}
function authorId(name: string) {
return BYLINES[name]?.id ?? 0
}
function visibleVersions(entry: FixtureStory, viewer: StoryViewer) {
return Object.fromEntries(
Object.entries(entry.versions).filter(
([lang]) => !viewer.read || viewer.read.includes(lang),
),
)
}
function coverageOf(entry: FixtureStory, site: StorySite, viewer: StoryViewer): CoverageEntry[] {
const own = visibleVersions(entry, viewer)
const ordered = [site.def, ...site.langs.filter((lang) => lang !== site.def)]
const langs = [
...ordered.filter((lang) => own[lang]),
...Object.keys(own).filter((lang) => !site.langs.includes(lang)),
]
return langs.map((lang) => ({
id: idOf(entry, lang),
lang,
title: titleIn(entry, lang),
is_online: own[lang]![0] === 'pub',
language_enabled: site.langs.includes(lang),
capabilities: viewer.create === false ? READ_ONLY : FULL,
}))
}
function missingOf(entry: FixtureStory, site: StorySite, viewer: StoryViewer) {
if (viewer.create === false) {
return []
}
const own = visibleVersions(entry, viewer)
return site.langs.filter((lang) => !own[lang])
}
function itemOf(
entry: FixtureStory,
lang: string,
site: StorySite,
viewer: StoryViewer,
): StoryItem {
const own = visibleVersions(entry, viewer)
const byline = BYLINES[entry.by]!
return {
id: idOf(entry, lang),
title: titleIn(entry, lang),
body: '',
is_online: own[lang]![0] === 'pub',
lang,
site_id: site.id,
story_id: entry.n,
created_by: authorId(entry.by),
authors: [byline],
created_at: entry.updatedAt,
updated_at: entry.updatedAt,
story_updated_at: entry.updatedAt,
story_authors: [byline],
coverage: coverageOf(entry, site, viewer),
missing_langs: missingOf(entry, site, viewer),
capabilities: viewer.create === false ? READ_ONLY : FULL,
}
}
export function storyPage(request: StoryRequest, viewer: StoryViewer = {}): StoryPage {
const site = STORY_SITES[request.site] ?? STORY_SITES.journal!
const author = request.author ? Number(request.author) : 0
const status = request.status === 'online' ? 'pub' : request.status
const readable = site.stories
.map((entry) => ({ entry, own: visibleVersions(entry, viewer) }))
.filter(
({ entry, own }) =>
Object.keys(own).length > 0 && (!viewer.scope || viewer.scope(entry)),
)
const passes = (
entry: FixtureStory,
versions: VersionState[],
langs: string[],
useStatus: boolean,
) =>
(!useStatus || !status || versions.some((version) => version[0] === status)) &&
(!author || authorId(entry.by) === author) &&
(!request.seo || entry.seo) &&
(!request.q || langs.some((lang) => titleIn(entry, lang).toLowerCase().includes(request.q)))
const anyVersion = (
entry: FixtureStory,
own: Record<string, VersionState>,
useStatus: boolean,
) => passes(entry, Object.values(own), Object.keys(own), useStatus)
const create = viewer.create !== false
const rows = (useStatus: boolean) => {
if (request.lang) {
const lang = request.lang
const inLang = readable.filter(
({ entry, own }) => own[lang] && passes(entry, [own[lang]!], [lang], useStatus),
)
const missing = readable.filter(
({ entry, own }) => !own[lang] && anyVersion(entry, own, useStatus),
)
return { inLang, missing }
}
const base = readable.filter(({ entry, own }) => anyVersion(entry, own, useStatus))
const missing = base.filter(({ own }) => create && site.langs.some((lang) => !own[lang]))
return { inLang: base, missing }
}
const listed = rows(true)
const unfiltered = rows(false)
const chosen = request.tab === 'missing' ? listed.missing : listed.inLang
const start = (request.page - 1) * request.size
const items = chosen.slice(start, start + request.size).map(({ entry, own }) => {
const lang =
request.lang && request.tab !== 'missing'
? request.lang
: representative(entry, site.def)
return itemOf(entry, own[lang] ? lang : representative(entry, site.def), site, viewer)
})
const stateOf = (entry: FixtureStory, own: Record<string, VersionState>) =>
request.lang ? [own[request.lang]!] : Object.values(own)
const counts: StoryCounts = {
all: unfiltered.inLang.length,
online: unfiltered.inLang.filter(({ entry, own }) =>
stateOf(entry, own).some((version) => version[0] === 'pub'),
).length,
draft: unfiltered.inLang.filter(({ entry, own }) =>
stateOf(entry, own).some((version) => version[0] === 'draft'),
).length,
suggestions: site.stories.filter((entry) => entry.seo).length,
missing: unfiltered.missing.length,
}
return { items, total: chosen.length, page: request.page, size: request.size, counts }
}
export function storyPageFor(url: URL, viewer: StoryViewer = {}) {
return storyPage(requestOf(url), viewer)
}
export function siteLanguagesBody(siteKey: string) {
const site = STORY_SITES[siteKey] ?? STORY_SITES.journal!
const count = (lang: string) =>
site.stories.filter((entry) => entry.versions[lang] !== undefined).length
const published = (lang: string) =>
site.stories.filter((entry) => entry.versions[lang]?.[0] === 'pub').length
const row = (lang: string, enabled: boolean) => ({
lang,
enabled,
is_default: lang === site.def,
version_count: count(lang),
published_count: published(lang),
social_locale: null,
social_locale_state: 'not_set',
})
return {
revision: `${site.key}-1`,
default: site.def,
needs_setup: false,
languages: [
...site.langs.map((lang) => row(lang, true)),
...site.disabled.map((lang) => row(lang, false)),
],
available: [],
}
}
export function storyAuthorsBody() {
const counts = [3, 2, 3]
return {
items: Object.values(BYLINES).map((byline, index) => ({
...byline,
bio: '',
avatar_id: null,
website: null,
social_links: [],
user_id: null,
is_linked: false,
article_count: counts[index],
})),
total: 3,
page: 1,
size: 100,
}
}