Give the archive its signature back
- the plate carries project colour on the lead image, in the cards and in every row, not only when an entry lacks an image - entries no longer fade in one after another, the page is readable at once - detail pages lead on: previous, next and more from the project - the month band shows from the first month, the nav item hides without one - side cards and row teasers get room instead of ellipsis - admin opens on drafts, scheduled and published plus what was edited last, and its tables use the full width
This commit is contained in:
+8
-2
@@ -50,7 +50,11 @@
|
|||||||
"back": "Alle Einträge von {project}",
|
"back": "Alle Einträge von {project}",
|
||||||
"details": "Angaben",
|
"details": "Angaben",
|
||||||
"readingTime": "{minutes} Min. Lesezeit",
|
"readingTime": "{minutes} Min. Lesezeit",
|
||||||
"emptyTitle": "Noch kein Inhalt"
|
"emptyTitle": "Noch kein Inhalt",
|
||||||
|
"previous": "Vorheriger Eintrag",
|
||||||
|
"next": "Nächster Eintrag",
|
||||||
|
"nearby": "Weiter im Projekt",
|
||||||
|
"more": "Mehr aus {project}"
|
||||||
},
|
},
|
||||||
"block": {
|
"block": {
|
||||||
"before": "Vorher",
|
"before": "Vorher",
|
||||||
@@ -675,7 +679,9 @@
|
|||||||
},
|
},
|
||||||
"publishTitle": "Veröffentlichen",
|
"publishTitle": "Veröffentlichen",
|
||||||
"publishHint": "Kein Zugang darf veröffentlichen. Das bleibt an Konten gebunden."
|
"publishHint": "Kein Zugang darf veröffentlichen. Das bleibt an Konten gebunden."
|
||||||
}
|
},
|
||||||
|
"recentEntries": "Zuletzt bearbeitet",
|
||||||
|
"allEntries": "Alle Beiträge"
|
||||||
},
|
},
|
||||||
"filter": {
|
"filter": {
|
||||||
"type": "Art",
|
"type": "Art",
|
||||||
|
|||||||
+8
-2
@@ -50,7 +50,11 @@
|
|||||||
"back": "All entries of {project}",
|
"back": "All entries of {project}",
|
||||||
"details": "Details",
|
"details": "Details",
|
||||||
"readingTime": "{minutes} min read",
|
"readingTime": "{minutes} min read",
|
||||||
"emptyTitle": "No content yet"
|
"emptyTitle": "No content yet",
|
||||||
|
"previous": "Previous entry",
|
||||||
|
"next": "Next entry",
|
||||||
|
"nearby": "More in this project",
|
||||||
|
"more": "More from {project}"
|
||||||
},
|
},
|
||||||
"block": {
|
"block": {
|
||||||
"before": "Before",
|
"before": "Before",
|
||||||
@@ -675,7 +679,9 @@
|
|||||||
},
|
},
|
||||||
"publishTitle": "Publishing",
|
"publishTitle": "Publishing",
|
||||||
"publishHint": "No access may publish. That stays bound to accounts."
|
"publishHint": "No access may publish. That stays bound to accounts."
|
||||||
}
|
},
|
||||||
|
"recentEntries": "Recently edited",
|
||||||
|
"allEntries": "All entries"
|
||||||
},
|
},
|
||||||
"filter": {
|
"filter": {
|
||||||
"type": "Type",
|
"type": "Type",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { ProjectArchive } from '~/components/archive/ProjectArchive'
|
|||||||
import {
|
import {
|
||||||
findPostWithBlocks,
|
findPostWithBlocks,
|
||||||
listArchiveMonths,
|
listArchiveMonths,
|
||||||
|
loadPostNeighbours,
|
||||||
listPostTypeCounts,
|
listPostTypeCounts,
|
||||||
listPostsForArchive,
|
listPostsForArchive,
|
||||||
loadArchiveStats,
|
loadArchiveStats,
|
||||||
@@ -60,7 +61,17 @@ export default async function EntryPage({ params, searchParams }: Props) {
|
|||||||
notFound()
|
notFound()
|
||||||
}
|
}
|
||||||
|
|
||||||
return <PostDetail detail={detail} />
|
const neighbours = await loadPostNeighbours({
|
||||||
|
scope,
|
||||||
|
projectSlug: detail.project.slug,
|
||||||
|
postId: detail.post.id,
|
||||||
|
publishAt: detail.post.publishAt,
|
||||||
|
limit: coverCount,
|
||||||
|
})
|
||||||
|
|
||||||
|
const nearbyCovers = await loadPostCovers(neighbours.more.map(item => item.id))
|
||||||
|
|
||||||
|
return <PostDetail detail={detail} neighbours={neighbours} covers={nearbyCovers} />
|
||||||
}
|
}
|
||||||
|
|
||||||
const query = await searchParams
|
const query = await searchParams
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import type { ViewerScope } from '~/domain/types'
|
|||||||
|
|
||||||
const scope: ViewerScope = 'internal'
|
const scope: ViewerScope = 'internal'
|
||||||
|
|
||||||
const sideCount = 3
|
const sideCount = 2
|
||||||
|
|
||||||
const sideThreshold = 4
|
const sideThreshold = 4
|
||||||
|
|
||||||
@@ -101,7 +101,7 @@ export default async function OverviewPage({ searchParams }: { searchParams: Pro
|
|||||||
{lead ? (
|
{lead ? (
|
||||||
<div className="flex flex-col gap-6 lg:flex-row lg:items-start">
|
<div className="flex flex-col gap-6 lg:flex-row lg:items-start">
|
||||||
<FeatureEntry item={lead} cover={covers.get(lead.id)} newest className="min-w-0 flex-1" />
|
<FeatureEntry item={lead} cover={covers.get(lead.id)} newest className="min-w-0 flex-1" />
|
||||||
<SideEntries items={side} covers={covers} className="lg:w-72 lg:shrink-0" />
|
<SideEntries items={side} covers={covers} className="lg:w-80 lg:shrink-0" />
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export default async function AdminLayout({ children }: { children: ReactNode })
|
|||||||
mark={<Wordmark number={number} />}
|
mark={<Wordmark number={number} />}
|
||||||
>
|
>
|
||||||
<main id="content">
|
<main id="content">
|
||||||
<Wrap className="pb-16 pt-8">{children}</Wrap>
|
<Wrap wide className="pb-16 pt-8">{children}</Wrap>
|
||||||
</main>
|
</main>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
)
|
)
|
||||||
|
|||||||
+55
-4
@@ -2,11 +2,13 @@ import Link from 'next/link'
|
|||||||
import { getTranslations } from 'next-intl/server'
|
import { getTranslations } from 'next-intl/server'
|
||||||
import { TbArrowNarrowRight, TbPhoto, TbPlus } from 'react-icons/tb'
|
import { TbArrowNarrowRight, TbPhoto, TbPlus } from 'react-icons/tb'
|
||||||
import { AdminHeading } from '~/components/admin/AdminHeading'
|
import { AdminHeading } from '~/components/admin/AdminHeading'
|
||||||
|
import { EntryStatusChip } from '~/components/admin/EntryStatusChip'
|
||||||
import { AdminNotice } from '~/components/admin/AdminNotice'
|
import { AdminNotice } from '~/components/admin/AdminNotice'
|
||||||
import { ghostButtonClass, quietLinkClass } from '~/components/admin/styles'
|
import { ghostButtonClass, quietLinkClass } from '~/components/admin/styles'
|
||||||
import { projectStyle, projectSurface } from '~/components/ui/Plate'
|
import { Plate, projectStyle, projectSurface } from '~/components/ui/Plate'
|
||||||
import { SectionLabel } from '~/components/ui/SectionLabel'
|
import { SectionLabel } from '~/components/ui/SectionLabel'
|
||||||
import { listClients } from '~/data/repositories/clients'
|
import { listClients } from '~/data/repositories/clients'
|
||||||
|
import { listEntries } from '~/data/repositories/entries'
|
||||||
import { listAllProjects, listBrands } from '~/data/repositories/projects'
|
import { listAllProjects, listBrands } from '~/data/repositories/projects'
|
||||||
import { listUsers } from '~/data/repositories/users'
|
import { listUsers } from '~/data/repositories/users'
|
||||||
import { listProjectsForUser } from '~/data/repositories/user-roles'
|
import { listProjectsForUser } from '~/data/repositories/user-roles'
|
||||||
@@ -15,6 +17,8 @@ import {
|
|||||||
adminBrandsPath,
|
adminBrandsPath,
|
||||||
adminClientsPath,
|
adminClientsPath,
|
||||||
adminNewEntryPath,
|
adminNewEntryPath,
|
||||||
|
adminEntriesPath,
|
||||||
|
adminEntryPath,
|
||||||
adminNewProjectPath,
|
adminNewProjectPath,
|
||||||
adminProjectEntriesPath,
|
adminProjectEntriesPath,
|
||||||
adminProjectPath,
|
adminProjectPath,
|
||||||
@@ -25,6 +29,8 @@ import { isAdmin } from '~/lib/auth-access'
|
|||||||
import { requireAdminArea } from '~/lib/auth-guards'
|
import { requireAdminArea } from '~/lib/auth-guards'
|
||||||
import { adminMediaPath } from '~/lib/auth-routes'
|
import { adminMediaPath } from '~/lib/auth-routes'
|
||||||
|
|
||||||
|
const recentCount = 5
|
||||||
|
|
||||||
export default async function AdminPage() {
|
export default async function AdminPage() {
|
||||||
const viewer = await requireAdminArea()
|
const viewer = await requireAdminArea()
|
||||||
const t = await getTranslations('admin')
|
const t = await getTranslations('admin')
|
||||||
@@ -35,9 +41,24 @@ export default async function AdminPage() {
|
|||||||
const brands = manages ? await listBrands() : []
|
const brands = manages ? await listBrands() : []
|
||||||
const users = manages ? await listUsers() : []
|
const users = manages ? await listUsers() : []
|
||||||
const clients = manages ? await listClients() : []
|
const clients = manages ? await listClients() : []
|
||||||
|
const projectIds = manages ? undefined : projects.map(project => project.id)
|
||||||
|
|
||||||
|
const [drafts, scheduled, published, recent] = await Promise.all([
|
||||||
|
listEntries({ projectIds, status: 'draft', page: 1, perPage: 1 }),
|
||||||
|
listEntries({ projectIds, status: 'scheduled', page: 1, perPage: 1 }),
|
||||||
|
listEntries({ projectIds, status: 'published', page: 1, perPage: 1 }),
|
||||||
|
listEntries({ projectIds, page: 1, perPage: recentCount }),
|
||||||
|
])
|
||||||
|
|
||||||
|
const entryTiles = [
|
||||||
|
{ href: adminEntriesPath, label: e('status.draft'), value: drafts.total },
|
||||||
|
{ href: adminEntriesPath, label: e('status.scheduled'), value: scheduled.total },
|
||||||
|
{ href: adminEntriesPath, label: e('status.published'), value: published.total },
|
||||||
|
]
|
||||||
|
|
||||||
const tiles = manages
|
const tiles = manages
|
||||||
? [
|
? [
|
||||||
|
...entryTiles,
|
||||||
{ href: adminProjectsPath, label: t('nav.projects'), value: projects.length },
|
{ href: adminProjectsPath, label: t('nav.projects'), value: projects.length },
|
||||||
{ href: adminBrandsPath, label: t('nav.brands'), value: brands.length },
|
{ href: adminBrandsPath, label: t('nav.brands'), value: brands.length },
|
||||||
{ href: adminUsersPath, label: t('nav.users'), value: users.length },
|
{ href: adminUsersPath, label: t('nav.users'), value: users.length },
|
||||||
@@ -47,7 +68,7 @@ export default async function AdminPage() {
|
|||||||
value: clients.filter(client => client.revokedAt === null).length,
|
value: clients.filter(client => client.revokedAt === null).length,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
: []
|
: entryTiles
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-10 pt-12">
|
<div className="flex flex-col gap-10 pt-12">
|
||||||
@@ -72,9 +93,9 @@ export default async function AdminPage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{tiles.length > 0 ? (
|
{tiles.length > 0 ? (
|
||||||
<ul className="m-0 grid list-none grid-cols-2 gap-px border border-rule bg-rule p-0 md:grid-cols-4">
|
<ul className="m-0 grid list-none grid-cols-2 gap-px border border-rule bg-rule p-0 md:grid-cols-4 xl:grid-cols-7">
|
||||||
{tiles.map(tile => (
|
{tiles.map(tile => (
|
||||||
<li key={tile.href} className="bg-surface">
|
<li key={tile.label} className="bg-surface">
|
||||||
<Link href={tile.href} className="flex flex-col gap-2 px-5 py-4 no-underline">
|
<Link href={tile.href} className="flex flex-col gap-2 px-5 py-4 no-underline">
|
||||||
<span className="font-mono text-micro font-semibold uppercase tracking-label text-ink-3">
|
<span className="font-mono text-micro font-semibold uppercase tracking-label text-ink-3">
|
||||||
{tile.label}
|
{tile.label}
|
||||||
@@ -86,6 +107,36 @@ export default async function AdminPage() {
|
|||||||
</ul>
|
</ul>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{recent.items.length > 0 ? (
|
||||||
|
<section className="flex flex-col gap-4">
|
||||||
|
<SectionLabel meta={<Link href={adminEntriesPath} className={quietLinkClass}>{t('allEntries')}</Link>}>
|
||||||
|
{t('recentEntries')}
|
||||||
|
</SectionLabel>
|
||||||
|
<ul className="m-0 flex list-none flex-col p-0">
|
||||||
|
{recent.items.map(item => (
|
||||||
|
<li
|
||||||
|
key={item.id}
|
||||||
|
className="flex flex-wrap items-center gap-x-4 gap-y-2 border-b border-rule py-3 last:border-b-0"
|
||||||
|
>
|
||||||
|
<Plate
|
||||||
|
project={{ code: item.projectCode, slug: item.projectSlug }}
|
||||||
|
color={item.projectColor}
|
||||||
|
number={item.number}
|
||||||
|
size="row"
|
||||||
|
/>
|
||||||
|
<Link
|
||||||
|
href={adminEntryPath(item.id)}
|
||||||
|
className="min-w-0 flex-1 font-display text-base font-semibold text-ink no-underline hover:text-signal"
|
||||||
|
>
|
||||||
|
{item.title}
|
||||||
|
</Link>
|
||||||
|
<EntryStatusChip status={item.status} label={e(`status.${item.status}`)} />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<section className="flex flex-col gap-4">
|
<section className="flex flex-col gap-4">
|
||||||
<SectionLabel meta={<span>{t('projectsMeta', { count: projects.length })}</span>}>
|
<SectionLabel meta={<span>{t('projectsMeta', { count: projects.length })}</span>}>
|
||||||
{t('yourProjects')}
|
{t('yourProjects')}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
--color-shot-1: var(--color-rule);
|
--color-shot-1: var(--color-rule);
|
||||||
--color-shot-2: var(--color-ink-3);
|
--color-shot-2: var(--color-ink-3);
|
||||||
--container-page: 63rem;
|
--container-page: 63rem;
|
||||||
|
--container-wide: 80rem;
|
||||||
--container-read: 38rem;
|
--container-read: 38rem;
|
||||||
--font-display: var(--font-archivo), system-ui, sans-serif;
|
--font-display: var(--font-archivo), system-ui, sans-serif;
|
||||||
--font-body: var(--font-source-serif), Georgia, serif;
|
--font-body: var(--font-source-serif), Georgia, serif;
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ function group(months: ArchiveMonth[]): YearGroup[] {
|
|||||||
export function ArchiveMonths({ months, hrefFor, activeYear, activeMonth }: ArchiveMonthsProps) {
|
export function ArchiveMonths({ months, hrefFor, activeYear, activeMonth }: ArchiveMonthsProps) {
|
||||||
const t = useTranslations('archive')
|
const t = useTranslations('archive')
|
||||||
|
|
||||||
if (months.length <= 1) {
|
if (months.length === 0) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import type { CSSProperties } from 'react'
|
|
||||||
import { useTranslations } from 'next-intl'
|
import { useTranslations } from 'next-intl'
|
||||||
import { AuthorMark } from '~/components/ui/AuthorMark'
|
import { AuthorMark } from '~/components/ui/AuthorMark'
|
||||||
import { Badge } from '~/components/ui/Badge'
|
import { Badge } from '~/components/ui/Badge'
|
||||||
import { EntryDate } from '~/components/ui/EntryDate'
|
import { EntryDate } from '~/components/ui/EntryDate'
|
||||||
import { Highlight } from '~/components/ui/Highlight'
|
import { Highlight } from '~/components/ui/Highlight'
|
||||||
import { entryMark, entryNumber, projectStyle, projectSurface } from '~/components/ui/Plate'
|
import { Plate } from '~/components/ui/Plate'
|
||||||
import { postPath } from '~/lib/routes'
|
import { postPath } from '~/lib/routes'
|
||||||
import type { PostListItem } from '~/data/repositories/posts'
|
import type { PostListItem } from '~/data/repositories/posts'
|
||||||
|
|
||||||
@@ -17,49 +16,42 @@ export type EntryRowProps = {
|
|||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const stagger = 40
|
export function EntryRow({ item, showCode = true, highlight, className }: EntryRowProps) {
|
||||||
|
|
||||||
const maxStagger = 12
|
|
||||||
|
|
||||||
export function EntryRow({ item, index = 0, showCode = true, highlight, className }: EntryRowProps) {
|
|
||||||
const t = useTranslations('entry')
|
const t = useTranslations('entry')
|
||||||
const delay = `${Math.min(index, maxStagger) * stagger}ms`
|
|
||||||
const style: CSSProperties = { ...projectStyle(item.projectColor), animationDelay: delay }
|
|
||||||
const source = { code: item.projectCode, slug: item.projectSlug }
|
const source = { code: item.projectCode, slug: item.projectSlug }
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={postPath(item.projectSlug, item.slug)}
|
href={postPath(item.projectSlug, item.slug)}
|
||||||
style={style}
|
className={`group flex items-stretch gap-4 border-b border-rule py-3 no-underline last:border-b-0 ${className ?? ''}`}
|
||||||
className={`group flex animate-rise items-stretch gap-4 border-b border-rule no-underline last:border-b-0 motion-reduce:animate-none ${className ?? ''}`}
|
|
||||||
>
|
>
|
||||||
<span
|
<span className="flex min-w-0 flex-1 flex-wrap items-baseline gap-x-4 gap-y-1 transition-transform duration-120 group-hover:translate-x-0.5 motion-reduce:transition-none motion-reduce:group-hover:translate-x-0 lg:grid lg:grid-cols-12 lg:gap-x-5">
|
||||||
aria-hidden="true"
|
<span className="shrink-0 self-center lg:col-span-2">
|
||||||
className={`w-1 shrink-0 transition-all duration-120 group-hover:w-2 motion-reduce:transition-none ${projectSurface}`}
|
<Plate
|
||||||
/>
|
project={source}
|
||||||
<span className="flex min-w-0 flex-1 flex-wrap items-baseline gap-x-4 gap-y-1 py-3 pr-1 transition-transform duration-120 group-hover:translate-x-0.5 motion-reduce:transition-none motion-reduce:group-hover:translate-x-0 lg:grid lg:grid-cols-12 lg:gap-x-5">
|
color={item.projectColor}
|
||||||
<span className="shrink-0 font-mono text-small font-medium uppercase tracking-mark text-ink-2 tabular-nums lg:col-span-2">
|
number={item.number}
|
||||||
<span aria-hidden="true">
|
size="row"
|
||||||
{showCode ? entryMark(source, item.number) : entryNumber(item.number)}
|
showNumber={showCode}
|
||||||
</span>
|
/>
|
||||||
<span className="sr-only">{t('number', { number: item.number })}</span>
|
<span className="sr-only">{t('number', { number: item.number })}</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<Badge type={item.type} className="shrink-0 lg:col-span-1" />
|
<Badge type={item.type} className="shrink-0 lg:col-span-1" />
|
||||||
|
|
||||||
<span className="min-w-0 flex-1 basis-full font-display text-lead font-semibold leading-snug text-balance text-ink lg:col-span-6 lg:basis-auto xl:col-span-4">
|
<span className="min-w-0 flex-1 basis-full font-display text-lead font-semibold leading-snug text-balance text-ink lg:col-span-4 lg:basis-auto">
|
||||||
<Highlight text={item.title} query={highlight} />
|
<Highlight text={item.title} query={highlight} />
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{item.teaser ? (
|
{item.teaser ? (
|
||||||
<span className="hidden min-w-0 truncate text-small text-ink-2 xl:col-span-3 xl:block">
|
<span className="hidden min-w-0 text-small leading-snug text-ink-2 lg:col-span-3 lg:line-clamp-2">
|
||||||
{item.teaser}
|
{item.teaser}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span aria-hidden="true" className="hidden xl:col-span-3 xl:block" />
|
<span aria-hidden="true" className="hidden lg:col-span-3 lg:block" />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<span className="ml-auto flex shrink-0 items-center gap-2 font-mono text-small text-ink-3 lg:col-span-3 lg:ml-0 lg:justify-end xl:col-span-2">
|
<span className="ml-auto flex shrink-0 items-center gap-2 self-center font-mono text-small text-ink-3 lg:col-span-2 lg:ml-0 lg:justify-end">
|
||||||
{item.publishAt ? <EntryDate date={item.publishAt} /> : null}
|
{item.publishAt ? <EntryDate date={item.publishAt} /> : null}
|
||||||
{item.authorName ? <AuthorMark name={item.authorName} /> : null}
|
{item.authorName ? <AuthorMark name={item.authorName} /> : null}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { AuthorMark } from '~/components/ui/AuthorMark'
|
|||||||
import { Badge } from '~/components/ui/Badge'
|
import { Badge } from '~/components/ui/Badge'
|
||||||
import { Cover, type CoverMedia } from '~/components/ui/Cover'
|
import { Cover, type CoverMedia } from '~/components/ui/Cover'
|
||||||
import { EntryDate } from '~/components/ui/EntryDate'
|
import { EntryDate } from '~/components/ui/EntryDate'
|
||||||
import { Plate, entryMark, projectStyle, projectSurface } from '~/components/ui/Plate'
|
import { Plate, projectStyle } from '~/components/ui/Plate'
|
||||||
import { Stamp } from '~/components/ui/Stamp'
|
import { Stamp } from '~/components/ui/Stamp'
|
||||||
import { postPath, projectPath } from '~/lib/routes'
|
import { postPath, projectPath } from '~/lib/routes'
|
||||||
import type { PostListItem } from '~/data/repositories/posts'
|
import type { PostListItem } from '~/data/repositories/posts'
|
||||||
@@ -34,25 +34,30 @@ export function FeatureEntry({
|
|||||||
return (
|
return (
|
||||||
<article
|
<article
|
||||||
style={projectStyle(item.projectColor)}
|
style={projectStyle(item.projectColor)}
|
||||||
className={`flex animate-rise flex-col overflow-hidden border border-rule bg-surface shadow-lift motion-reduce:animate-none ${className ?? ''}`}
|
className={`flex flex-col overflow-hidden border border-rule bg-surface shadow-lift ${className ?? ''}`}
|
||||||
>
|
>
|
||||||
<Cover
|
<div className="relative">
|
||||||
media={cover}
|
<Cover
|
||||||
sizes={coverSizes}
|
media={cover}
|
||||||
priority
|
sizes={coverSizes}
|
||||||
className="aspect-cover w-full border-b border-rule"
|
priority
|
||||||
fallback={<Plate project={source} color={item.projectColor} number={item.number} size="lead" className="w-full" />}
|
className="aspect-cover w-full border-b border-rule"
|
||||||
/>
|
fallback={<Plate project={source} color={item.projectColor} number={item.number} size="lead" className="w-full" />}
|
||||||
|
/>
|
||||||
|
{cover ? (
|
||||||
|
<Plate
|
||||||
|
project={source}
|
||||||
|
color={item.projectColor}
|
||||||
|
number={item.number}
|
||||||
|
size="mark"
|
||||||
|
className="absolute bottom-0 left-0"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="relative flex flex-1 flex-col gap-4 overflow-hidden p-6 md:p-8">
|
<div className="relative flex flex-1 flex-col gap-4 overflow-hidden p-6 md:p-8">
|
||||||
|
|
||||||
<div className="relative flex flex-wrap items-center gap-x-4 gap-y-2 font-mono text-micro text-ink-3">
|
<div className="relative flex flex-wrap items-center gap-x-4 gap-y-2 font-mono text-micro text-ink-3">
|
||||||
<span className="inline-flex items-center gap-2">
|
|
||||||
<span aria-hidden="true" className={`size-2.5 shrink-0 ${projectSurface}`} />
|
|
||||||
<span className="font-semibold uppercase tracking-mark text-ink-2 tabular-nums">
|
|
||||||
{entryMark(source, item.number)}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<Badge type={item.type} />
|
<Badge type={item.type} />
|
||||||
{item.publishAt ? <EntryDate date={item.publishAt} long /> : null}
|
{item.publishAt ? <EntryDate date={item.publishAt} long /> : null}
|
||||||
{newest ? <Stamp className="ml-auto" /> : null}
|
{newest ? <Stamp className="ml-auto" /> : null}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useFormatter, useLocale, useTranslations } from 'next-intl'
|
import { useFormatter, useLocale, useTranslations } from 'next-intl'
|
||||||
import { TbArrowNarrowLeft } from 'react-icons/tb'
|
import { TbArrowNarrowLeft, TbArrowNarrowRight } from 'react-icons/tb'
|
||||||
import { EmptyNotice } from './EmptyNotice'
|
import { EmptyNotice } from './EmptyNotice'
|
||||||
import { BlockRenderer } from '~/components/blocks/BlockRenderer'
|
import { BlockRenderer } from '~/components/blocks/BlockRenderer'
|
||||||
import { Wrap } from '~/components/layout/Wrap'
|
import { Wrap } from '~/components/layout/Wrap'
|
||||||
@@ -17,18 +17,22 @@ import { toCover } from '~/lib/cover'
|
|||||||
import { longDateTimeFormat } from '~/lib/dates'
|
import { longDateTimeFormat } from '~/lib/dates'
|
||||||
import { contentBlocks, readingMinutes } from '~/lib/post-content'
|
import { contentBlocks, readingMinutes } from '~/lib/post-content'
|
||||||
import { projectPath } from '~/lib/routes'
|
import { projectPath } from '~/lib/routes'
|
||||||
import type { ArchivePostDetail } from '~/data/repositories/archive'
|
import { SideEntries } from './SideEntries'
|
||||||
|
import { postPath } from '~/lib/routes'
|
||||||
|
import type { ArchivePostDetail, PostCover, PostNeighbours } from '~/data/repositories/archive'
|
||||||
import type { Locale } from '~/i18n/config'
|
import type { Locale } from '~/i18n/config'
|
||||||
|
|
||||||
export type PostDetailProps = {
|
export type PostDetailProps = {
|
||||||
detail: ArchivePostDetail
|
detail: ArchivePostDetail
|
||||||
|
neighbours?: PostNeighbours
|
||||||
|
covers?: Map<string, PostCover>
|
||||||
}
|
}
|
||||||
|
|
||||||
const column = 'mx-auto w-full max-w-read'
|
const column = 'mx-auto w-full max-w-read'
|
||||||
|
|
||||||
const coverSizes = '(min-width: 63rem) 63rem, 100vw'
|
const coverSizes = '(min-width: 63rem) 63rem, 100vw'
|
||||||
|
|
||||||
export function PostDetail({ detail }: PostDetailProps) {
|
export function PostDetail({ detail, neighbours, covers }: PostDetailProps) {
|
||||||
const t = useTranslations('post')
|
const t = useTranslations('post')
|
||||||
const entry = useTranslations('entry')
|
const entry = useTranslations('entry')
|
||||||
const locale = useLocale() as Locale
|
const locale = useLocale() as Locale
|
||||||
@@ -146,6 +150,46 @@ export function PostDetail({ detail }: PostDetailProps) {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
{neighbours && (neighbours.previous || neighbours.next) ? (
|
||||||
|
<nav aria-label={t('nearby')} className={`mt-12 grid gap-3 border-t border-rule pt-6 md:grid-cols-2 ${column}`}>
|
||||||
|
{neighbours.previous ? (
|
||||||
|
<Link
|
||||||
|
href={postPath(neighbours.previous.projectSlug, neighbours.previous.slug)}
|
||||||
|
className="flex flex-col gap-1 border border-rule bg-surface p-4 no-underline hover:border-ink-3"
|
||||||
|
>
|
||||||
|
<span className="inline-flex items-center gap-2 font-mono text-micro uppercase tracking-label text-ink-3">
|
||||||
|
<TbArrowNarrowLeft aria-hidden="true" className="size-4 shrink-0" />
|
||||||
|
{t('previous')}
|
||||||
|
</span>
|
||||||
|
<span className="font-display text-base font-semibold leading-snug text-balance text-ink">
|
||||||
|
{neighbours.previous.title}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
) : <span aria-hidden="true" />}
|
||||||
|
|
||||||
|
{neighbours.next ? (
|
||||||
|
<Link
|
||||||
|
href={postPath(neighbours.next.projectSlug, neighbours.next.slug)}
|
||||||
|
className="flex flex-col gap-1 border border-rule bg-surface p-4 text-right no-underline hover:border-ink-3 md:col-start-2"
|
||||||
|
>
|
||||||
|
<span className="inline-flex items-center justify-end gap-2 font-mono text-micro uppercase tracking-label text-ink-3">
|
||||||
|
{t('next')}
|
||||||
|
<TbArrowNarrowRight aria-hidden="true" className="size-4 shrink-0" />
|
||||||
|
</span>
|
||||||
|
<span className="font-display text-base font-semibold leading-snug text-balance text-ink">
|
||||||
|
{neighbours.next.title}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
</nav>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{neighbours && neighbours.more.length > 0 ? (
|
||||||
|
<div className={`mt-12 border-t border-rule pt-6 ${column}`}>
|
||||||
|
<SideEntries items={neighbours.more} covers={covers} layout="grid" label={t('more', { project: project.name })} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</article>
|
</article>
|
||||||
</Wrap>
|
</Wrap>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ export function ProjectArchive({
|
|||||||
color={project.color}
|
color={project.color}
|
||||||
number={highest ?? project.postCount}
|
number={highest ?? project.postCount}
|
||||||
showNumber={project.postCount > 0}
|
showNumber={project.postCount > 0}
|
||||||
size="row"
|
size="mark"
|
||||||
/>
|
/>
|
||||||
<span className="font-mono text-small text-ink-3 tabular-nums">
|
<span className="font-mono text-small text-ink-3 tabular-nums">
|
||||||
{archive('entries', { count: project.postCount })}
|
{archive('entries', { count: project.postCount })}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useTranslations } from 'next-intl'
|
|||||||
import { Badge } from '~/components/ui/Badge'
|
import { Badge } from '~/components/ui/Badge'
|
||||||
import { Cover, type CoverMedia } from '~/components/ui/Cover'
|
import { Cover, type CoverMedia } from '~/components/ui/Cover'
|
||||||
import { EntryDate } from '~/components/ui/EntryDate'
|
import { EntryDate } from '~/components/ui/EntryDate'
|
||||||
import { Plate, entryMark, projectStyle } from '~/components/ui/Plate'
|
import { Plate, projectStyle } from '~/components/ui/Plate'
|
||||||
import { SectionLabel } from '~/components/ui/SectionLabel'
|
import { SectionLabel } from '~/components/ui/SectionLabel'
|
||||||
import { postPath } from '~/lib/routes'
|
import { postPath } from '~/lib/routes'
|
||||||
import type { PostListItem } from '~/data/repositories/posts'
|
import type { PostListItem } from '~/data/repositories/posts'
|
||||||
@@ -11,12 +11,14 @@ import type { PostListItem } from '~/data/repositories/posts'
|
|||||||
export type SideEntriesProps = {
|
export type SideEntriesProps = {
|
||||||
items: PostListItem[]
|
items: PostListItem[]
|
||||||
covers?: Map<string, CoverMedia>
|
covers?: Map<string, CoverMedia>
|
||||||
|
label?: string
|
||||||
|
layout?: 'column' | 'grid'
|
||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const coverSizes = '(min-width: 64rem) 6rem, 20vw'
|
const coverSizes = '(min-width: 64rem) 22rem, 40vw'
|
||||||
|
|
||||||
export function SideEntries({ items, covers, className }: SideEntriesProps) {
|
export function SideEntries({ items, covers, label, layout = 'column', className }: SideEntriesProps) {
|
||||||
const t = useTranslations('entry')
|
const t = useTranslations('entry')
|
||||||
|
|
||||||
if (items.length === 0) {
|
if (items.length === 0) {
|
||||||
@@ -25,8 +27,8 @@ export function SideEntries({ items, covers, className }: SideEntriesProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className={`flex flex-col gap-3 ${className ?? ''}`}>
|
<aside className={`flex flex-col gap-3 ${className ?? ''}`}>
|
||||||
<SectionLabel as="h3">{t('next')}</SectionLabel>
|
<SectionLabel as="h3">{label ?? t('next')}</SectionLabel>
|
||||||
<ul className="m-0 flex list-none flex-col gap-3 p-0">
|
<ul className={`m-0 grid list-none gap-3 p-0 ${layout === 'grid' ? 'md:grid-cols-3' : 'md:grid-cols-2 lg:grid-cols-1'}`}>
|
||||||
{items.map(item => {
|
{items.map(item => {
|
||||||
const source = { code: item.projectCode, slug: item.projectSlug }
|
const source = { code: item.projectCode, slug: item.projectSlug }
|
||||||
|
|
||||||
@@ -35,12 +37,12 @@ export function SideEntries({ items, covers, className }: SideEntriesProps) {
|
|||||||
<Link
|
<Link
|
||||||
href={postPath(item.projectSlug, item.slug)}
|
href={postPath(item.projectSlug, item.slug)}
|
||||||
style={projectStyle(item.projectColor)}
|
style={projectStyle(item.projectColor)}
|
||||||
className="group flex animate-rise gap-3 border border-rule bg-surface p-2.5 no-underline transition-colors duration-120 hover:border-ink-3 motion-reduce:animate-none motion-reduce:transition-none"
|
className="group flex flex-col gap-2 border border-rule bg-surface p-3 no-underline transition-colors duration-120 hover:border-ink-3 motion-reduce:transition-none"
|
||||||
>
|
>
|
||||||
<Cover
|
<Cover
|
||||||
media={covers?.get(item.id)}
|
media={covers?.get(item.id)}
|
||||||
sizes={coverSizes}
|
sizes={coverSizes}
|
||||||
className="aspect-video w-24 shrink-0"
|
className="aspect-shot w-full"
|
||||||
fallback={
|
fallback={
|
||||||
<Plate
|
<Plate
|
||||||
project={source}
|
project={source}
|
||||||
@@ -51,18 +53,21 @@ export function SideEntries({ items, covers, className }: SideEntriesProps) {
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<span className="flex min-w-0 flex-1 flex-col gap-1">
|
<span className="flex min-w-0 flex-col gap-1.5">
|
||||||
<span className="flex flex-wrap items-center gap-x-2 gap-y-1 font-mono text-micro text-ink-3">
|
<span className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||||
<span className="font-semibold uppercase tracking-mark text-ink-2 tabular-nums">
|
<Plate
|
||||||
{entryMark(source, item.number)}
|
project={source}
|
||||||
</span>
|
color={item.projectColor}
|
||||||
|
number={item.number}
|
||||||
|
size="row"
|
||||||
|
/>
|
||||||
<Badge type={item.type} />
|
<Badge type={item.type} />
|
||||||
</span>
|
</span>
|
||||||
<span className="font-display text-small font-semibold leading-snug text-balance text-ink">
|
<span className="font-display text-base font-semibold leading-snug text-balance text-ink">
|
||||||
{item.title}
|
{item.title}
|
||||||
</span>
|
</span>
|
||||||
{item.teaser ? (
|
{item.teaser ? (
|
||||||
<span className="line-clamp-2 text-micro leading-snug text-ink-2">{item.teaser}</span>
|
<span className="line-clamp-2 text-small leading-snug text-ink-2">{item.teaser}</span>
|
||||||
) : null}
|
) : null}
|
||||||
{item.publishAt ? (
|
{item.publishAt ? (
|
||||||
<EntryDate date={item.publishAt} className="font-mono text-micro text-ink-3" />
|
<EntryDate date={item.publishAt} className="font-mono text-micro text-ink-3" />
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { Scroller } from '~/components/ui/Scroller'
|
|||||||
import { ThemeToggle } from '~/components/ui/ThemeToggle'
|
import { ThemeToggle } from '~/components/ui/ThemeToggle'
|
||||||
import { SignOutButton } from '~/components/admin/SignOutButton'
|
import { SignOutButton } from '~/components/admin/SignOutButton'
|
||||||
import { footerLinkClass } from '~/components/admin/styles'
|
import { footerLinkClass } from '~/components/admin/styles'
|
||||||
import { listBrandsWithProjects } from '~/data/repositories/archive'
|
import { listArchiveMonths, listBrandsWithProjects } from '~/data/repositories/archive'
|
||||||
import { adminPath } from '~/lib/auth-routes'
|
import { adminPath } from '~/lib/auth-routes'
|
||||||
import { archivePath, overviewPath, projectPath, searchPath } from '~/lib/routes'
|
import { archivePath, overviewPath, projectPath, searchPath } from '~/lib/routes'
|
||||||
import { canOpenAdmin, type Viewer } from '~/lib/auth-access'
|
import { canOpenAdmin, type Viewer } from '~/lib/auth-access'
|
||||||
@@ -23,7 +23,10 @@ export type SiteNavProps = {
|
|||||||
export async function SiteNav({ scope, number, viewer }: SiteNavProps) {
|
export async function SiteNav({ scope, number, viewer }: SiteNavProps) {
|
||||||
const t = await getTranslations('nav')
|
const t = await getTranslations('nav')
|
||||||
const admin = await getTranslations('admin')
|
const admin = await getTranslations('admin')
|
||||||
const brands = await listBrandsWithProjects({ scope })
|
const [brands, months] = await Promise.all([
|
||||||
|
listBrandsWithProjects({ scope }),
|
||||||
|
listArchiveMonths({ scope }),
|
||||||
|
])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-0 flex-1 flex-col">
|
<div className="flex min-h-0 flex-1 flex-col">
|
||||||
@@ -56,14 +59,16 @@ export async function SiteNav({ scope, number, viewer }: SiteNavProps) {
|
|||||||
{t('overview')}
|
{t('overview')}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
{months.length > 0 ? (
|
||||||
<NavLink
|
<li>
|
||||||
href={archivePath()}
|
<NavLink
|
||||||
icon={<TbArchive aria-hidden="true" className="size-4" />}
|
href={archivePath()}
|
||||||
>
|
icon={<TbArchive aria-hidden="true" className="size-4" />}
|
||||||
{t('archive')}
|
>
|
||||||
</NavLink>
|
{t('archive')}
|
||||||
</li>
|
</NavLink>
|
||||||
|
</li>
|
||||||
|
) : null}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<Scroller className="min-h-0 flex-1" options={{ overflow: { x: 'hidden' } }}>
|
<Scroller className="min-h-0 flex-1" options={{ overflow: { x: 'hidden' } }}>
|
||||||
|
|||||||
@@ -2,9 +2,14 @@ import type { ReactNode } from 'react'
|
|||||||
|
|
||||||
export type WrapProps = {
|
export type WrapProps = {
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
|
wide?: boolean
|
||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Wrap({ children, className }: WrapProps) {
|
export function Wrap({ children, wide = false, className }: WrapProps) {
|
||||||
return <div className={`mx-auto w-full max-w-page px-6 ${className ?? ''}`}>{children}</div>
|
return (
|
||||||
|
<div className={`mx-auto w-full px-6 ${wide ? 'max-w-wide' : 'max-w-page'} ${className ?? ''}`}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { CSSProperties } from 'react'
|
import type { CSSProperties } from 'react'
|
||||||
import { projectCode, type ProjectCodeSource } from '~/domain/project-code'
|
import { projectCode, type ProjectCodeSource } from '~/domain/project-code'
|
||||||
|
|
||||||
export type PlateSize = 'lead' | 'card' | 'row'
|
export type PlateSize = 'lead' | 'mark' | 'card' | 'row'
|
||||||
|
|
||||||
export type PlateProps = {
|
export type PlateProps = {
|
||||||
project: ProjectCodeSource
|
project: ProjectCodeSource
|
||||||
@@ -28,18 +28,21 @@ export function entryMark(project: ProjectCodeSource, number: number | null): st
|
|||||||
|
|
||||||
const shells: Record<PlateSize, string> = {
|
const shells: Record<PlateSize, string> = {
|
||||||
lead: 'flex min-h-44 flex-col justify-between gap-8 px-5 py-4',
|
lead: 'flex min-h-44 flex-col justify-between gap-8 px-5 py-4',
|
||||||
|
mark: 'flex flex-col justify-between gap-3 px-3 py-2.5',
|
||||||
card: 'flex flex-col justify-between gap-1 px-2 py-1.5',
|
card: 'flex flex-col justify-between gap-1 px-2 py-1.5',
|
||||||
row: 'inline-flex items-baseline gap-2.5 px-2 py-1',
|
row: 'inline-flex items-baseline gap-2.5 px-2 py-1',
|
||||||
}
|
}
|
||||||
|
|
||||||
const codeSizes: Record<PlateSize, string> = {
|
const codeSizes: Record<PlateSize, string> = {
|
||||||
lead: 'text-small tracking-plate',
|
lead: 'text-small tracking-plate',
|
||||||
|
mark: 'text-micro tracking-plate',
|
||||||
card: 'text-micro tracking-mark',
|
card: 'text-micro tracking-mark',
|
||||||
row: 'text-micro tracking-label',
|
row: 'text-micro tracking-label',
|
||||||
}
|
}
|
||||||
|
|
||||||
const numberSizes: Record<PlateSize, string> = {
|
const numberSizes: Record<PlateSize, string> = {
|
||||||
lead: 'text-plate leading-none',
|
lead: 'text-plate leading-none',
|
||||||
|
mark: 'text-title leading-none',
|
||||||
card: 'text-small leading-none',
|
card: 'text-small leading-none',
|
||||||
row: 'text-micro',
|
row: 'text-micro',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { and, asc, count, desc, eq, ilike, inArray, isNotNull, max, or, sql, type SQL } from 'drizzle-orm'
|
import { and, asc, count, desc, eq, gt, ilike, inArray, isNotNull, lt, max, ne, or, sql, type SQL } from 'drizzle-orm'
|
||||||
import { db } from '../db'
|
import { db } from '../db'
|
||||||
import { blocks, brands, media, postTags, postTypes, posts, projects, tags, type Block, type Brand, type Media, type MediaVariant, type Post, type Project } from '../schema'
|
import { blocks, brands, media, postTags, postTypes, posts, projects, tags, type Block, type Brand, type Media, type MediaVariant, type Post, type Project } from '../schema'
|
||||||
import { visibleAudiences } from '~/domain/audience'
|
import { visibleAudiences } from '~/domain/audience'
|
||||||
@@ -514,3 +514,49 @@ export async function listRecentPosts(args: { scope: ViewerScope, limit: number
|
|||||||
.orderBy(...newestFirst)
|
.orderBy(...newestFirst)
|
||||||
.limit(args.limit)
|
.limit(args.limit)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type PostNeighbours = {
|
||||||
|
previous?: PostListItem
|
||||||
|
next?: PostListItem
|
||||||
|
more: PostListItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadPostNeighbours(args: {
|
||||||
|
scope: ViewerScope
|
||||||
|
projectSlug: string
|
||||||
|
postId: string
|
||||||
|
publishAt: Date | null
|
||||||
|
limit: number
|
||||||
|
}): Promise<PostNeighbours> {
|
||||||
|
const base = [...visible(args.scope), eq(projects.slug, args.projectSlug), ne(posts.id, args.postId)]
|
||||||
|
const mark = args.publishAt ?? new Date()
|
||||||
|
|
||||||
|
const [older, newer, more] = await Promise.all([
|
||||||
|
db
|
||||||
|
.select(selection)
|
||||||
|
.from(posts)
|
||||||
|
.innerJoin(projects, eq(projects.id, posts.projectId))
|
||||||
|
.innerJoin(postTypes, typeJoin)
|
||||||
|
.where(and(...base, lt(posts.publishAt, mark)))
|
||||||
|
.orderBy(sql`${posts.publishAt} desc nulls last`, desc(posts.id))
|
||||||
|
.limit(1),
|
||||||
|
db
|
||||||
|
.select(selection)
|
||||||
|
.from(posts)
|
||||||
|
.innerJoin(projects, eq(projects.id, posts.projectId))
|
||||||
|
.innerJoin(postTypes, typeJoin)
|
||||||
|
.where(and(...base, gt(posts.publishAt, mark)))
|
||||||
|
.orderBy(asc(posts.publishAt), asc(posts.id))
|
||||||
|
.limit(1),
|
||||||
|
db
|
||||||
|
.select(selection)
|
||||||
|
.from(posts)
|
||||||
|
.innerJoin(projects, eq(projects.id, posts.projectId))
|
||||||
|
.innerJoin(postTypes, typeJoin)
|
||||||
|
.where(and(...base))
|
||||||
|
.orderBy(...newestFirst)
|
||||||
|
.limit(args.limit),
|
||||||
|
])
|
||||||
|
|
||||||
|
return { previous: older[0], next: newer[0], more }
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user