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:
Matthias G
2026-08-01 13:04:08 +02:00
parent a123e0bca2
commit c95db3a044
17 changed files with 264 additions and 84 deletions
+8 -2
View File
@@ -50,7 +50,11 @@
"back": "Alle Einträge von {project}",
"details": "Angaben",
"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": {
"before": "Vorher",
@@ -675,7 +679,9 @@
},
"publishTitle": "Veröffentlichen",
"publishHint": "Kein Zugang darf veröffentlichen. Das bleibt an Konten gebunden."
}
},
"recentEntries": "Zuletzt bearbeitet",
"allEntries": "Alle Beiträge"
},
"filter": {
"type": "Art",
+8 -2
View File
@@ -50,7 +50,11 @@
"back": "All entries of {project}",
"details": "Details",
"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": {
"before": "Before",
@@ -675,7 +679,9 @@
},
"publishTitle": "Publishing",
"publishHint": "No access may publish. That stays bound to accounts."
}
},
"recentEntries": "Recently edited",
"allEntries": "All entries"
},
"filter": {
"type": "Type",
+12 -1
View File
@@ -6,6 +6,7 @@ import { ProjectArchive } from '~/components/archive/ProjectArchive'
import {
findPostWithBlocks,
listArchiveMonths,
loadPostNeighbours,
listPostTypeCounts,
listPostsForArchive,
loadArchiveStats,
@@ -60,7 +61,17 @@ export default async function EntryPage({ params, searchParams }: Props) {
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
+2 -2
View File
@@ -24,7 +24,7 @@ import type { ViewerScope } from '~/domain/types'
const scope: ViewerScope = 'internal'
const sideCount = 3
const sideCount = 2
const sideThreshold = 4
@@ -101,7 +101,7 @@ export default async function OverviewPage({ searchParams }: { searchParams: Pro
{lead ? (
<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" />
<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>
) : null}
+1 -1
View File
@@ -29,7 +29,7 @@ export default async function AdminLayout({ children }: { children: ReactNode })
mark={<Wordmark number={number} />}
>
<main id="content">
<Wrap className="pb-16 pt-8">{children}</Wrap>
<Wrap wide className="pb-16 pt-8">{children}</Wrap>
</main>
</AppShell>
)
+55 -4
View File
@@ -2,11 +2,13 @@ import Link from 'next/link'
import { getTranslations } from 'next-intl/server'
import { TbArrowNarrowRight, TbPhoto, TbPlus } from 'react-icons/tb'
import { AdminHeading } from '~/components/admin/AdminHeading'
import { EntryStatusChip } from '~/components/admin/EntryStatusChip'
import { AdminNotice } from '~/components/admin/AdminNotice'
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 { listClients } from '~/data/repositories/clients'
import { listEntries } from '~/data/repositories/entries'
import { listAllProjects, listBrands } from '~/data/repositories/projects'
import { listUsers } from '~/data/repositories/users'
import { listProjectsForUser } from '~/data/repositories/user-roles'
@@ -15,6 +17,8 @@ import {
adminBrandsPath,
adminClientsPath,
adminNewEntryPath,
adminEntriesPath,
adminEntryPath,
adminNewProjectPath,
adminProjectEntriesPath,
adminProjectPath,
@@ -25,6 +29,8 @@ import { isAdmin } from '~/lib/auth-access'
import { requireAdminArea } from '~/lib/auth-guards'
import { adminMediaPath } from '~/lib/auth-routes'
const recentCount = 5
export default async function AdminPage() {
const viewer = await requireAdminArea()
const t = await getTranslations('admin')
@@ -35,9 +41,24 @@ export default async function AdminPage() {
const brands = manages ? await listBrands() : []
const users = manages ? await listUsers() : []
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
? [
...entryTiles,
{ href: adminProjectsPath, label: t('nav.projects'), value: projects.length },
{ href: adminBrandsPath, label: t('nav.brands'), value: brands.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,
},
]
: []
: entryTiles
return (
<div className="flex flex-col gap-10 pt-12">
@@ -72,9 +93,9 @@ export default async function AdminPage() {
/>
{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 => (
<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">
<span className="font-mono text-micro font-semibold uppercase tracking-label text-ink-3">
{tile.label}
@@ -86,6 +107,36 @@ export default async function AdminPage() {
</ul>
) : 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">
<SectionLabel meta={<span>{t('projectsMeta', { count: projects.length })}</span>}>
{t('yourProjects')}
+1
View File
@@ -17,6 +17,7 @@
--color-shot-1: var(--color-rule);
--color-shot-2: var(--color-ink-3);
--container-page: 63rem;
--container-wide: 80rem;
--container-read: 38rem;
--font-display: var(--font-archivo), system-ui, sans-serif;
--font-body: var(--font-source-serif), Georgia, serif;
+1 -1
View File
@@ -37,7 +37,7 @@ function group(months: ArchiveMonth[]): YearGroup[] {
export function ArchiveMonths({ months, hrefFor, activeYear, activeMonth }: ArchiveMonthsProps) {
const t = useTranslations('archive')
if (months.length <= 1) {
if (months.length === 0) {
return null
}
+15 -23
View File
@@ -1,11 +1,10 @@
import Link from 'next/link'
import type { CSSProperties } from 'react'
import { useTranslations } from 'next-intl'
import { AuthorMark } from '~/components/ui/AuthorMark'
import { Badge } from '~/components/ui/Badge'
import { EntryDate } from '~/components/ui/EntryDate'
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 type { PostListItem } from '~/data/repositories/posts'
@@ -17,49 +16,42 @@ export type EntryRowProps = {
className?: string
}
const stagger = 40
const maxStagger = 12
export function EntryRow({ item, index = 0, showCode = true, highlight, className }: EntryRowProps) {
export function EntryRow({ item, showCode = true, highlight, className }: EntryRowProps) {
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 }
return (
<Link
href={postPath(item.projectSlug, item.slug)}
style={style}
className={`group flex animate-rise items-stretch gap-4 border-b border-rule no-underline last:border-b-0 motion-reduce:animate-none ${className ?? ''}`}
className={`group flex items-stretch gap-4 border-b border-rule py-3 no-underline last:border-b-0 ${className ?? ''}`}
>
<span
aria-hidden="true"
className={`w-1 shrink-0 transition-all duration-120 group-hover:w-2 motion-reduce:transition-none ${projectSurface}`}
<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">
<span className="shrink-0 self-center lg:col-span-2">
<Plate
project={source}
color={item.projectColor}
number={item.number}
size="row"
showNumber={showCode}
/>
<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">
<span className="shrink-0 font-mono text-small font-medium uppercase tracking-mark text-ink-2 tabular-nums lg:col-span-2">
<span aria-hidden="true">
{showCode ? entryMark(source, item.number) : entryNumber(item.number)}
</span>
<span className="sr-only">{t('number', { number: item.number })}</span>
</span>
<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} />
</span>
{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}
</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.authorName ? <AuthorMark name={item.authorName} /> : null}
</span>
+13 -8
View File
@@ -4,7 +4,7 @@ import { AuthorMark } from '~/components/ui/AuthorMark'
import { Badge } from '~/components/ui/Badge'
import { Cover, type CoverMedia } from '~/components/ui/Cover'
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 { postPath, projectPath } from '~/lib/routes'
import type { PostListItem } from '~/data/repositories/posts'
@@ -34,8 +34,9 @@ export function FeatureEntry({
return (
<article
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 ?? ''}`}
>
<div className="relative">
<Cover
media={cover}
sizes={coverSizes}
@@ -43,16 +44,20 @@ export function FeatureEntry({
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-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} />
{item.publishAt ? <EntryDate date={item.publishAt} long /> : null}
{newest ? <Stamp className="ml-auto" /> : null}
+47 -3
View File
@@ -1,6 +1,6 @@
import Link from 'next/link'
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 { BlockRenderer } from '~/components/blocks/BlockRenderer'
import { Wrap } from '~/components/layout/Wrap'
@@ -17,18 +17,22 @@ import { toCover } from '~/lib/cover'
import { longDateTimeFormat } from '~/lib/dates'
import { contentBlocks, readingMinutes } from '~/lib/post-content'
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'
export type PostDetailProps = {
detail: ArchivePostDetail
neighbours?: PostNeighbours
covers?: Map<string, PostCover>
}
const column = 'mx-auto w-full max-w-read'
const coverSizes = '(min-width: 63rem) 63rem, 100vw'
export function PostDetail({ detail }: PostDetailProps) {
export function PostDetail({ detail, neighbours, covers }: PostDetailProps) {
const t = useTranslations('post')
const entry = useTranslations('entry')
const locale = useLocale() as Locale
@@ -146,6 +150,46 @@ export function PostDetail({ detail }: PostDetailProps) {
</Link>
</div>
</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>
</Wrap>
</main>
+1 -1
View File
@@ -105,7 +105,7 @@ export function ProjectArchive({
color={project.color}
number={highest ?? project.postCount}
showNumber={project.postCount > 0}
size="row"
size="mark"
/>
<span className="font-mono text-small text-ink-3 tabular-nums">
{archive('entries', { count: project.postCount })}
+19 -14
View File
@@ -3,7 +3,7 @@ import { useTranslations } from 'next-intl'
import { Badge } from '~/components/ui/Badge'
import { Cover, type CoverMedia } from '~/components/ui/Cover'
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 { postPath } from '~/lib/routes'
import type { PostListItem } from '~/data/repositories/posts'
@@ -11,12 +11,14 @@ import type { PostListItem } from '~/data/repositories/posts'
export type SideEntriesProps = {
items: PostListItem[]
covers?: Map<string, CoverMedia>
label?: string
layout?: 'column' | 'grid'
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')
if (items.length === 0) {
@@ -25,8 +27,8 @@ export function SideEntries({ items, covers, className }: SideEntriesProps) {
return (
<aside className={`flex flex-col gap-3 ${className ?? ''}`}>
<SectionLabel as="h3">{t('next')}</SectionLabel>
<ul className="m-0 flex list-none flex-col gap-3 p-0">
<SectionLabel as="h3">{label ?? t('next')}</SectionLabel>
<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 => {
const source = { code: item.projectCode, slug: item.projectSlug }
@@ -35,12 +37,12 @@ export function SideEntries({ items, covers, className }: SideEntriesProps) {
<Link
href={postPath(item.projectSlug, item.slug)}
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
media={covers?.get(item.id)}
sizes={coverSizes}
className="aspect-video w-24 shrink-0"
className="aspect-shot w-full"
fallback={
<Plate
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 flex-wrap items-center gap-x-2 gap-y-1 font-mono text-micro text-ink-3">
<span className="font-semibold uppercase tracking-mark text-ink-2 tabular-nums">
{entryMark(source, item.number)}
</span>
<span className="flex min-w-0 flex-col gap-1.5">
<span className="flex flex-wrap items-center gap-x-3 gap-y-1">
<Plate
project={source}
color={item.projectColor}
number={item.number}
size="row"
/>
<Badge type={item.type} />
</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}
</span>
{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}
{item.publishAt ? (
<EntryDate date={item.publishAt} className="font-mono text-micro text-ink-3" />
+7 -2
View File
@@ -8,7 +8,7 @@ import { Scroller } from '~/components/ui/Scroller'
import { ThemeToggle } from '~/components/ui/ThemeToggle'
import { SignOutButton } from '~/components/admin/SignOutButton'
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 { archivePath, overviewPath, projectPath, searchPath } from '~/lib/routes'
import { canOpenAdmin, type Viewer } from '~/lib/auth-access'
@@ -23,7 +23,10 @@ export type SiteNavProps = {
export async function SiteNav({ scope, number, viewer }: SiteNavProps) {
const t = await getTranslations('nav')
const admin = await getTranslations('admin')
const brands = await listBrandsWithProjects({ scope })
const [brands, months] = await Promise.all([
listBrandsWithProjects({ scope }),
listArchiveMonths({ scope }),
])
return (
<div className="flex min-h-0 flex-1 flex-col">
@@ -56,6 +59,7 @@ export async function SiteNav({ scope, number, viewer }: SiteNavProps) {
{t('overview')}
</NavLink>
</li>
{months.length > 0 ? (
<li>
<NavLink
href={archivePath()}
@@ -64,6 +68,7 @@ export async function SiteNav({ scope, number, viewer }: SiteNavProps) {
{t('archive')}
</NavLink>
</li>
) : null}
</ul>
<Scroller className="min-h-0 flex-1" options={{ overflow: { x: 'hidden' } }}>
+7 -2
View File
@@ -2,9 +2,14 @@ import type { ReactNode } from 'react'
export type WrapProps = {
children: ReactNode
wide?: boolean
className?: string
}
export function Wrap({ children, className }: WrapProps) {
return <div className={`mx-auto w-full max-w-page px-6 ${className ?? ''}`}>{children}</div>
export function Wrap({ children, wide = false, className }: WrapProps) {
return (
<div className={`mx-auto w-full px-6 ${wide ? 'max-w-wide' : 'max-w-page'} ${className ?? ''}`}>
{children}
</div>
)
}
+4 -1
View File
@@ -1,7 +1,7 @@
import type { CSSProperties } from 'react'
import { projectCode, type ProjectCodeSource } from '~/domain/project-code'
export type PlateSize = 'lead' | 'card' | 'row'
export type PlateSize = 'lead' | 'mark' | 'card' | 'row'
export type PlateProps = {
project: ProjectCodeSource
@@ -28,18 +28,21 @@ export function entryMark(project: ProjectCodeSource, number: number | null): st
const shells: Record<PlateSize, string> = {
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',
row: 'inline-flex items-baseline gap-2.5 px-2 py-1',
}
const codeSizes: Record<PlateSize, string> = {
lead: 'text-small tracking-plate',
mark: 'text-micro tracking-plate',
card: 'text-micro tracking-mark',
row: 'text-micro tracking-label',
}
const numberSizes: Record<PlateSize, string> = {
lead: 'text-plate leading-none',
mark: 'text-title leading-none',
card: 'text-small leading-none',
row: 'text-micro',
}
+47 -1
View File
@@ -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 { 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'
@@ -514,3 +514,49 @@ export async function listRecentPosts(args: { scope: ViewerScope, limit: number
.orderBy(...newestFirst)
.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 }
}