feat: editorial article list
This commit is contained in:
1 parent
a0b3c58c52
commit
a671b81f80
103 files changed
+6493
-1805
No files matched your search
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user