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}",
|
||||
"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
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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
@@ -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')}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 })}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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' } }}>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user