Add Logbuch: project update blog with admin, media and API

Public archive with sidebar navigation, project pages, month archive,
search and entry pages with image blocks. Admin area for brands,
projects, post types, users, api clients, media and the entry editor
with drag and drop images, preview per audience, scheduling and
publish checks. Read and write API with bearer tokens, audience
scoping, idempotent creation, OpenAPI document and editorial guide.
Magic link login with configurable allowed domains, whole app behind
the session gate. 456 tests including design rule checks.
This commit is contained in:
Matthias Giesselmann
2026-07-31 21:33:42 +02:00
commit b90ff252d1
291 changed files with 43671 additions and 0 deletions
@@ -0,0 +1,88 @@
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
import { getFormatter, getTranslations } from 'next-intl/server'
import { ProjectArchive } from '~/components/archive/ProjectArchive'
import {
listArchiveMonths,
listPostTypeCounts,
listPostsForArchive,
loadArchiveStats,
loadHighestNumber,
loadPostCovers,
} from '~/data/repositories/archive'
import { monthYearFormat } from '~/lib/dates'
import { findProjectView } from '~/lib/project-view'
import { perPage, readMonth, readPage, readPostType, readYear, recentDays, type SearchParams } from '~/lib/query'
import type { ViewerScope } from '~/domain/types'
const scope: ViewerScope = 'internal'
const coverCount = 3
type Props = {
params: Promise<{ project: string, entry: string, month: string }>
searchParams: Promise<SearchParams>
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const resolved = await params
const app = await getTranslations('app')
const year = readYear(resolved.entry)
const month = readMonth(resolved.month)
if (year === undefined || month === undefined) {
const t = await getTranslations('notFound')
return { title: `${app('name')} - ${t('title')}` }
}
const format = await getFormatter()
const view = await findProjectView(resolved.project, scope)
const period = format.dateTime(new Date(Date.UTC(year, month - 1, 1)), monthYearFormat)
return { title: view ? `${period} - ${view.project.name} - ${app('name')}` : app('name') }
}
export default async function MonthPage({ params, searchParams }: Props) {
const resolved = await params
const year = readYear(resolved.entry)
const month = readMonth(resolved.month)
if (year === undefined || month === undefined) {
notFound()
}
const query = await searchParams
const page = readPage(query.page)
const type = readPostType(query.type)
const view = await findProjectView(resolved.project, scope)
if (!view) {
notFound()
}
const [stats, months, list, typeCounts, highest] = await Promise.all([
loadArchiveStats({ scope, projectSlug: resolved.project, recentDays }),
listArchiveMonths({ scope, projectSlug: resolved.project }),
listPostsForArchive({ scope, projectSlug: resolved.project, year, month, type, page, perPage }),
listPostTypeCounts({ scope, projectSlug: resolved.project, year, month }),
loadHighestNumber({ scope, projectSlug: resolved.project }),
])
const covers = await loadPostCovers(list.items.slice(0, coverCount).map(item => item.id))
return (
<ProjectArchive
view={view}
stats={stats}
months={months}
list={list}
typeCounts={typeCounts}
page={page}
covers={covers}
highest={highest}
year={year}
month={month}
type={type}
/>
)
}
+99
View File
@@ -0,0 +1,99 @@
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
import { getTranslations } from 'next-intl/server'
import { PostDetail } from '~/components/archive/PostDetail'
import { ProjectArchive } from '~/components/archive/ProjectArchive'
import {
findPostWithBlocks,
listArchiveMonths,
listPostTypeCounts,
listPostsForArchive,
loadArchiveStats,
loadHighestNumber,
loadPostCovers,
} from '~/data/repositories/archive'
import { findProjectView } from '~/lib/project-view'
import { perPage, readPage, readPostType, readYear, recentDays, type SearchParams } from '~/lib/query'
import type { ViewerScope } from '~/domain/types'
const scope: ViewerScope = 'internal'
const coverCount = 3
type Props = {
params: Promise<{ project: string, entry: string }>
searchParams: Promise<SearchParams>
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { project, entry } = await params
const app = await getTranslations('app')
const year = readYear(entry)
if (year !== undefined) {
const view = await findProjectView(project, scope)
return { title: view ? `${year} - ${view.project.name} - ${app('name')}` : app('name') }
}
const detail = await findPostWithBlocks({ slug: entry, scope, projectSlug: project })
if (!detail) {
const t = await getTranslations('notFound')
return { title: `${app('name')} - ${t('title')}` }
}
return {
title: `${detail.post.title} - ${detail.project.name}`,
description: detail.post.teaser ?? undefined,
}
}
export default async function EntryPage({ params, searchParams }: Props) {
const { project, entry } = await params
const year = readYear(entry)
if (year === undefined) {
const detail = await findPostWithBlocks({ slug: entry, scope, projectSlug: project })
if (!detail) {
notFound()
}
return <PostDetail detail={detail} />
}
const query = await searchParams
const page = readPage(query.page)
const type = readPostType(query.type)
const view = await findProjectView(project, scope)
if (!view) {
notFound()
}
const [stats, months, list, typeCounts, highest] = await Promise.all([
loadArchiveStats({ scope, projectSlug: project, recentDays }),
listArchiveMonths({ scope, projectSlug: project }),
listPostsForArchive({ scope, projectSlug: project, year, type, page, perPage }),
listPostTypeCounts({ scope, projectSlug: project, year }),
loadHighestNumber({ scope, projectSlug: project }),
])
const covers = await loadPostCovers(list.items.slice(0, coverCount).map(item => item.id))
return (
<ProjectArchive
view={view}
stats={stats}
months={months}
list={list}
typeCounts={typeCounts}
page={page}
covers={covers}
highest={highest}
year={year}
type={type}
/>
)
}
+76
View File
@@ -0,0 +1,76 @@
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
import { getTranslations } from 'next-intl/server'
import { ProjectArchive } from '~/components/archive/ProjectArchive'
import {
listArchiveMonths,
listPostTypeCounts,
listPostsForArchive,
loadArchiveStats,
loadHighestNumber,
loadPostCovers,
} from '~/data/repositories/archive'
import { findProjectView } from '~/lib/project-view'
import { perPage, readPage, readPostType, recentDays, type SearchParams } from '~/lib/query'
import type { ViewerScope } from '~/domain/types'
const scope: ViewerScope = 'internal'
const coverCount = 3
type Props = {
params: Promise<{ project: string }>
searchParams: Promise<SearchParams>
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { project } = await params
const app = await getTranslations('app')
const view = await findProjectView(project, scope)
if (!view) {
const t = await getTranslations('notFound')
return { title: `${app('name')} - ${t('title')}` }
}
return {
title: `${view.project.name} - ${app('name')}`,
description: view.project.description ?? undefined,
}
}
export default async function ProjectPage({ params, searchParams }: Props) {
const { project } = await params
const query = await searchParams
const page = readPage(query.page)
const type = readPostType(query.type)
const view = await findProjectView(project, scope)
if (!view) {
notFound()
}
const [stats, months, list, typeCounts, highest] = await Promise.all([
loadArchiveStats({ scope, projectSlug: project, recentDays }),
listArchiveMonths({ scope, projectSlug: project }),
listPostsForArchive({ scope, projectSlug: project, type, page, perPage }),
listPostTypeCounts({ scope, projectSlug: project }),
loadHighestNumber({ scope, projectSlug: project }),
])
const covers = await loadPostCovers(list.items.slice(0, coverCount).map(item => item.id))
return (
<ProjectArchive
view={view}
stats={stats}
months={months}
list={list}
typeCounts={typeCounts}
page={page}
covers={covers}
highest={highest}
type={type}
/>
)
}
+26
View File
@@ -0,0 +1,26 @@
import type { ReactNode } from 'react'
import { AppShell } from '~/components/layout/AppShell'
import { SiteFooter } from '~/components/layout/SiteFooter'
import { SiteNav } from '~/components/layout/SiteNav'
import { Wordmark } from '~/components/layout/Wordmark'
import { loadHighestNumber } from '~/data/repositories/archive'
import { requireSession } from '~/lib/auth-guards'
import { currentPath } from '~/lib/request-path'
import type { ViewerScope } from '~/domain/types'
const scope: ViewerScope = 'internal'
export default async function SiteLayout({ children }: { children: ReactNode }) {
const viewer = await requireSession(await currentPath())
const number = await loadHighestNumber({ scope })
return (
<AppShell
nav={<SiteNav scope={scope} number={number} viewer={viewer} />}
mark={<Wordmark number={number} />}
>
{children}
<SiteFooter />
</AppShell>
)
}
+130
View File
@@ -0,0 +1,130 @@
import type { Metadata } from 'next'
import Link from 'next/link'
import { getTranslations } from 'next-intl/server'
import { ArchiveMonths } from '~/components/archive/ArchiveMonths'
import { EmptyNotice } from '~/components/archive/EmptyNotice'
import { EntryList } from '~/components/archive/EntryList'
import { FeatureEntry } from '~/components/archive/FeatureEntry'
import { FilterBar } from '~/components/archive/FilterBar'
import { Pagination } from '~/components/archive/Pagination'
import { SideEntries } from '~/components/archive/SideEntries'
import { TypeFilter } from '~/components/archive/TypeFilter'
import { ViewHeader } from '~/components/archive/ViewHeader'
import { Wrap } from '~/components/layout/Wrap'
import {
listArchiveMonths,
listPostTypeCounts,
listPostsForArchive,
loadArchiveStats,
loadPostCovers,
} from '~/data/repositories/archive'
import { perPage, readMonth, readPage, readPostType, readYear, recentDays, type SearchParams } from '~/lib/query'
import { adminPath, overviewPath } from '~/lib/routes'
import type { ViewerScope } from '~/domain/types'
const scope: ViewerScope = 'internal'
const sideCount = 3
const sideThreshold = 4
export async function generateMetadata(): Promise<Metadata> {
const app = await getTranslations('app')
const t = await getTranslations('overview')
return {
title: `${app('name')} - ${t('title')}`,
description: t('intro'),
}
}
export default async function OverviewPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
const params = await searchParams
const page = readPage(params.page)
const year = readYear(params.year)
const month = readMonth(params.month)
const type = readPostType(params.type)
const t = await getTranslations('overview')
const archive = await getTranslations('archive')
const entry = await getTranslations('entry')
const [stats, months, list, typeCounts] = await Promise.all([
loadArchiveStats({ scope, recentDays }),
listArchiveMonths({ scope }),
listPostsForArchive({ scope, year, month, type, page, perPage }),
listPostTypeCounts({ scope, year, month }),
])
const period = year !== undefined || month !== undefined
const narrowed = period || type !== undefined
const beyond = list.total > 0 && list.items.length === 0
const emptyTitle = beyond ? t('emptyPageTitle') : type && list.total === 0 ? t('emptyTypeTitle') : period ? t('emptyFilteredTitle') : t('emptyTitle')
const emptyText = beyond ? t('emptyPage') : type && list.total === 0 ? t('emptyType') : period ? t('emptyFiltered') : t('empty')
const lead = page === 1 && !narrowed ? list.items[0] : undefined
const side = lead && list.items.length >= sideThreshold ? list.items.slice(1, 1 + sideCount) : []
const rows = lead ? list.items.slice(1 + side.length) : list.items
const updated = page === 1 ? list.items[0]?.publishAt ?? null : stats.to ?? null
const typeTotal = typeCounts.reduce((sum, count) => sum + count.postCount, 0)
const covers = await loadPostCovers(lead ? [lead, ...side].map(item => item.id) : [])
return (
<main id="content">
<Wrap>
<ViewHeader title={t('title')} intro={t('intro')} updated={updated} total={list.total}>
<TypeFilter
counts={typeCounts}
total={typeTotal}
active={type}
hrefFor={target => overviewPath({ year, month, type: target })}
/>
</ViewHeader>
<FilterBar year={year} month={month} resetHref={overviewPath({ type })} />
{list.items.length === 0 ? (
<EmptyNotice
title={emptyTitle}
action={
<Link
href={beyond ? overviewPath({ year, month, type }) : narrowed ? overviewPath() : adminPath()}
className="font-mono text-small"
>
{beyond ? archive('firstPage') : narrowed ? archive('all') : t('toAdmin')}
</Link>
}
>
{emptyText}
</EmptyNotice>
) : (
<div className="flex flex-col gap-10 pb-14 pt-6">
{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" />
</div>
) : null}
<EntryList items={rows} label={lead ? entry('older') : entry('latest')} />
<Pagination
page={page}
perPage={perPage}
total={list.total}
hrefFor={target => overviewPath({ page: target, year, month, type })}
/>
</div>
)}
<div id="archive" className="scroll-mt-6">
<ArchiveMonths
months={months}
activeYear={year}
activeMonth={month}
hrefFor={item => overviewPath({ year: item.year, month: item.month, type })}
/>
</div>
</Wrap>
</main>
)
}
+125
View File
@@ -0,0 +1,125 @@
import type { Metadata } from 'next'
import Form from 'next/form'
import Link from 'next/link'
import { getTranslations } from 'next-intl/server'
import { TbSearch } from 'react-icons/tb'
import { EmptyNotice } from '~/components/archive/EmptyNotice'
import { EntryList } from '~/components/archive/EntryList'
import { Pagination } from '~/components/archive/Pagination'
import { ViewHeader } from '~/components/archive/ViewHeader'
import { Wrap } from '~/components/layout/Wrap'
import { listPostsForArchive, listRecentPosts } from '~/data/repositories/archive'
import { perPage, readPage, readText, type SearchParams } from '~/lib/query'
import { archivePath, overviewPath, searchPath } from '~/lib/routes'
import type { ViewerScope } from '~/domain/types'
const scope: ViewerScope = 'internal'
const suggestionCount = 5
export async function generateMetadata({ searchParams }: { searchParams: Promise<SearchParams> }): Promise<Metadata> {
const app = await getTranslations('app')
const t = await getTranslations('search')
const query = readText((await searchParams).q)
return { title: query ? `${t('title')}: ${query} - ${app('name')}` : `${t('title')} - ${app('name')}` }
}
export default async function SearchPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
const params = await searchParams
const query = readText(params.q)
const page = readPage(params.page)
const t = await getTranslations('search')
const archive = await getTranslations('archive')
const nav = await getTranslations('nav')
const overview = await getTranslations('overview')
const list = query
? await listPostsForArchive({ scope, query, page, perPage })
: { items: [], total: 0 }
const suggestions = list.total === 0 ? await listRecentPosts({ scope, limit: suggestionCount }) : []
return (
<main id="content">
<Wrap>
<ViewHeader title={t('title')} intro={t('intro')} total={query ? list.total : undefined}>
<Form action={searchPath()} className="flex flex-wrap items-center gap-3">
<label htmlFor="q" className="sr-only">
{t('label')}
</label>
<span className="relative flex min-w-0 flex-1 items-center">
<TbSearch aria-hidden="true" className="pointer-events-none absolute left-3 size-4 text-ink-3" />
<input
id="q"
name="q"
type="search"
defaultValue={query ?? ''}
placeholder={t('placeholder')}
autoComplete="off"
className="w-full min-w-0 border border-rule bg-surface py-2 pl-9 pr-3 font-body text-base text-ink placeholder:text-ink-3 focus:border-signal"
/>
</span>
<button
type="submit"
className="inline-flex cursor-pointer items-center gap-2 border border-ink bg-ink px-4 py-2.5 font-mono text-micro font-semibold uppercase tracking-badge text-paper hover:border-signal hover:bg-signal"
>
{t('submit')}
</button>
</Form>
</ViewHeader>
<div className="flex flex-col gap-10 pb-14 pt-6">
{list.total > 0 && list.items.length === 0 ? (
<EmptyNotice
title={overview('emptyPageTitle')}
action={
<Link href={searchPath({ query })} className="font-mono text-small">
{archive('firstPage')}
</Link>
}
>
{overview('emptyPage')}
</EmptyNotice>
) : list.total > 0 ? (
<>
<EntryList
items={list.items}
label={t('found', { query: query ?? '' })}
meta={archive('entries', { count: list.total })}
highlight={query}
/>
<Pagination
page={page}
perPage={perPage}
total={list.total}
hrefFor={target => searchPath({ query, page: target })}
/>
</>
) : (
<>
<EmptyNotice
title={query === undefined ? t('promptTitle') : t('emptyTitle')}
action={
<span className="flex flex-wrap items-center gap-x-4 gap-y-2">
<Link href={overviewPath()} className="font-mono text-small">
{archive('all')}
</Link>
<Link href={archivePath()} className="font-mono text-small">
{nav('archive')}
</Link>
</span>
}
>
{query === undefined ? t('prompt') : t('empty', { query })}
</EmptyNotice>
<EntryList items={suggestions} label={t('suggestions')} />
</>
)}
</div>
</Wrap>
</main>
)
}
+327
View File
@@ -0,0 +1,327 @@
import type { Metadata } from 'next'
import Link from 'next/link'
import { TbBook, TbDownload } from 'react-icons/tb'
import { AdminHeading } from '~/components/admin/AdminHeading'
import { ApiEndpoint } from '~/components/admin/ApiEndpoint'
import { cellClass, headCellClass } from '~/components/admin/styles'
import { SectionLabel } from '~/components/ui/SectionLabel'
import { Scroller } from '~/components/ui/Scroller'
import { blockTypes } from '~/domain/blocks'
import { listActivePostTypes } from '~/data/repositories/post-types'
import { requireAdminArea } from '~/lib/auth-guards'
import { adminClientsPath } from '~/lib/admin-routes'
export const metadata: Metadata = { title: 'API - Logbuch' }
const blockFields: Record<string, string> = {
text: 'text',
image: 'mediaId',
gallery: 'mediaIds',
before_after: 'beforeMediaId, afterMediaId',
video: 'url, title',
quote: 'text, source',
code: 'code, language',
link: 'url, title, description',
callout: 'text, tone',
}
const audiences = [
{ key: 'internal', text: 'Nur intern. Umbauten, Zwischenstände, alles über Kunden.' },
{ key: 'customer', text: 'Für Kunden des Produkts. Sichtbar für interne und Kunden-Zugänge.' },
{ key: 'public', text: 'Öffentlich. Sichtbar für jeden Zugang.' },
]
export default async function AdminApiPage() {
await requireAdminArea()
const types = await listActivePostTypes()
return (
<div className="flex flex-col gap-12 pt-12">
<AdminHeading
eyebrow="Schnittstelle"
title="API"
intro="Über diese Schnittstelle holen andere Anwendungen ihre Einträge und verfassen neue. Alles hier ist der tatsächliche Stand, nicht die Absicht."
/>
<section className="flex flex-col gap-4">
<SectionLabel>Wofür Logbuch da ist</SectionLabel>
<div className="flex max-w-read flex-col gap-4 text-pretty text-ink-2">
<p className="m-0">
Logbuch hält fest, was in den Produkten passiert ist. Alle zwei Wochen entsteht pro Projekt ein
Eintrag über das, was fertig geworden ist.
</p>
<p className="m-0">
Es ist kein Änderungsprotokoll für Entwickler und keine Pressemitteilung. Es ist für Kollegen und
später Kunden, die wissen wollen, was sich geändert hat, ohne Tickets oder Code zu lesen. Wer über
die Schnittstelle schreibt, hält sich an den Leitfaden, abrufbar unter
{' '}
<code className="font-mono text-small">GET /api/v1/style-guide</code>.
</p>
</div>
</section>
<section className="flex flex-col gap-4">
<SectionLabel>Zum Mitnehmen</SectionLabel>
<div className="flex max-w-read flex-col gap-4 text-pretty text-ink-2">
<p className="m-0">
Die Beschreibung im OpenAPI-Format lässt sich in Postman, Bruno, Insomnia und ähnliche Werkzeuge
einlesen, und ein KI-Assistent kann sie direkt verwenden.
</p>
<div className="flex flex-wrap gap-3">
<a
href="/api/v1/openapi.json"
download
className="inline-flex items-center gap-2 border border-rule bg-surface px-3 py-2 font-mono text-micro font-semibold uppercase tracking-label text-ink no-underline hover:border-ink-3"
>
<TbDownload aria-hidden="true" className="size-4" />
openapi.json
</a>
<a
href="/api/v1/style-guide"
className="inline-flex items-center gap-2 border border-rule bg-surface px-3 py-2 font-mono text-micro font-semibold uppercase tracking-label text-ink no-underline hover:border-ink-3"
>
<TbBook aria-hidden="true" className="size-4" />
Redaktionsleitfaden
</a>
</div>
<p className="m-0 text-small">
Beide Adressen verlangen ein Token. In Postman oder Bruno trägst du es unter Authorization als
Bearer ein.
</p>
</div>
</section>
<section className="flex flex-col gap-4">
<SectionLabel>Anmeldung</SectionLabel>
<div className="flex max-w-read flex-col gap-4 text-pretty text-ink-2">
<p className="m-0">
Jede Anfrage trägt ein Token im Kopf:
{' '}
<code className="font-mono text-small text-ink">Authorization: Bearer &lt;token&gt;</code>
</p>
<p className="m-0">
Zugänge legst du unter{' '}
<Link href={adminClientsPath}>Zugänge</Link> an. Das Token wird dort einmal im Klartext gezeigt und
danach nie wieder, gespeichert ist nur ein Hash.
</p>
</div>
<Scroller options={{ overflow: { y: 'hidden' } }}>
<table className="w-full min-w-2xl border-collapse text-left">
<thead>
<tr>
<th className={headCellClass}>Eigenschaft</th>
<th className={headCellClass}>Bedeutung</th>
</tr>
</thead>
<tbody>
<tr>
<td className={cellClass}>Modus</td>
<td className={cellClass}>read liest, write schreibt zusätzlich</td>
</tr>
<tr>
<td className={cellClass}>Zielgruppe</td>
<td className={cellClass}>
Bestimmt, welche Einträge der Zugang sieht und wie offen er selbst schreiben darf
</td>
</tr>
<tr>
<td className={cellClass}>Projektbindung</td>
<td className={cellClass}>Ist ein Projekt gesetzt, gilt der Zugang ausschließlich dort</td>
</tr>
</tbody>
</table>
</Scroller>
<p className="m-0 max-w-read text-pretty text-ink-2">
Ein Zugang mit Schreibrecht kann niemals veröffentlichen. Alles landet als Entwurf, ein Mensch gibt
frei.
</p>
</section>
<section className="flex flex-col gap-5">
<SectionLabel>Lesen</SectionLabel>
<ApiEndpoint
method="GET"
path="/api/v1/projects"
auth="read"
summary="Die Projekte, die der Zugang sehen darf."
example={'curl -s -H "Authorization: Bearer $TOKEN" \\\n http://localhost:4700/api/v1/projects'}
/>
<ApiEndpoint
method="GET"
path="/api/v1/posts"
auth="read"
summary="Veröffentlichte Einträge, neueste zuerst. Parameter: project, since, type, tag, page, per_page."
example={'curl -s -H "Authorization: Bearer $TOKEN" \\\n "http://localhost:4700/api/v1/posts?project=trakk&per_page=5"'}
/>
<ApiEndpoint
method="GET"
path="/api/v1/posts/:slug"
auth="read"
summary="Ein einzelner Eintrag. Nicht sichtbar heißt 404, nicht 403, damit die Antwort seine Existenz nicht verrät."
example={'curl -s -H "Authorization: Bearer $TOKEN" \\\n http://localhost:4700/api/v1/posts/regel-engine'}
/>
<ApiEndpoint
method="GET"
path="/api/v1/post-types"
auth="read"
summary="Die verwaltbaren Beitragsarten mit Schlüssel, Bezeichnung und Farbe. Vor dem Schreiben abfragen statt raten."
example={'curl -s -H "Authorization: Bearer $TOKEN" \\\n http://localhost:4700/api/v1/post-types'}
/>
<ApiEndpoint
method="GET"
path="/api/v1/unread"
auth="read, projektgebunden"
summary="Was ein Nutzer der einbindenden Anwendung noch nicht gelesen hat. Die Kennung ist die des aufrufenden Systems, kein Logbuch-Konto."
example={'curl -s -H "Authorization: Bearer $TOKEN" \\\n "http://localhost:4700/api/v1/unread?external_user_id=u-42"'}
/>
<ApiEndpoint
method="POST"
path="/api/v1/read"
auth="read, projektgebunden"
summary="Markiert Einträge als gelesen. Unbekannte und fremde Kennungen werden still verworfen."
example={'curl -s -X POST -H "Authorization: Bearer $TOKEN" \\\n -H "content-type: application/json" \\\n -d \'{"external_user_id":"u-42","post_ids":["<id>"]}\' \\\n http://localhost:4700/api/v1/read'}
/>
</section>
<section className="flex flex-col gap-5">
<SectionLabel>Schreiben</SectionLabel>
<ApiEndpoint
method="POST"
path="/api/v1/posts"
auth="write"
summary="Legt einen Beitrag an, immer als Entwurf. Mit idempotency_key erzeugt ein wiederholter Aufruf keinen zweiten Beitrag."
example={'curl -s -X POST -H "Authorization: Bearer $TOKEN" \\\n -H "content-type: application/json" \\\n -d \'{\n "project": "trakk",\n "title": "SLA-Uhr zählt Feiertage nicht mehr mit",\n "teaser": "Reaktionszeiten liefen über Wochenenden weiter.",\n "type": "fix",\n "audience": "customer",\n "idempotency_key": "sla-2026-07-31"\n }\' \\\n http://localhost:4700/api/v1/posts'}
/>
<ApiEndpoint
method="PUT"
path="/api/v1/posts/:id/blocks"
auth="write"
summary="Setzt die Blöcke vollständig, in der übergebenen Reihenfolge. Höchstens 100 Blöcke."
example={'curl -s -X PUT -H "Authorization: Bearer $TOKEN" \\\n -H "content-type: application/json" \\\n -d \'{"blocks":[{"type":"text","data":{"text":"Erster Absatz."}}]}\' \\\n http://localhost:4700/api/v1/posts/<id>/blocks'}
/>
<ApiEndpoint
method="PATCH"
path="/api/v1/posts/:id"
auth="write"
summary="Ändert Titel, Anreißer, Art, Zielgruppe, Slug oder Aufmacherbild. Nur solange nichts veröffentlicht ist, sonst 409."
example={'curl -s -X PATCH -H "Authorization: Bearer $TOKEN" \\\n -H "content-type: application/json" \\\n -d \'{"teaser":"Neuer Anreißer."}\' \\\n http://localhost:4700/api/v1/posts/<id>'}
/>
<ApiEndpoint
method="GET"
path="/api/v1/posts/:id"
auth="write"
summary="Liest den Entwurf mit allen Blöcken zurück, zum Prüfen vor der Freigabe."
example={'curl -s -H "Authorization: Bearer $TOKEN" \\\n http://localhost:4700/api/v1/posts/<id>'}
/>
<ApiEndpoint
method="DELETE"
path="/api/v1/posts/:id"
auth="write"
summary="Löscht einen Entwurf. Veröffentlichtes lässt sich nicht löschen."
example={'curl -s -X DELETE -H "Authorization: Bearer $TOKEN" \\\n http://localhost:4700/api/v1/posts/<id>'}
/>
<ApiEndpoint
method="POST"
path="/api/v1/media"
auth="write"
summary="Lädt ein Bild hoch und erzeugt WebP-Varianten. Nur Bildformate, höchstens 15 MB."
example={'curl -s -X POST -H "Authorization: Bearer $TOKEN" \\\n -F "file=@screenshot.png" \\\n -F "projectId=<projekt-id>" \\\n -F "alt=Spaltenmenü mit den neuen Einträgen" \\\n http://localhost:4700/api/v1/media'}
/>
</section>
<section className="flex flex-col gap-4">
<SectionLabel>Beitragsarten</SectionLabel>
<Scroller options={{ overflow: { y: 'hidden' } }}>
<table className="w-full min-w-2xl border-collapse text-left">
<thead>
<tr>
<th className={headCellClass}>Schlüssel</th>
<th className={headCellClass}>Bezeichnung</th>
</tr>
</thead>
<tbody>
{types.map(type => (
<tr key={type.id}>
<td className={cellClass}><code className="font-mono text-small">{type.key}</code></td>
<td className={cellClass}>{type.labelDe}</td>
</tr>
))}
</tbody>
</table>
</Scroller>
</section>
<section className="flex flex-col gap-4">
<SectionLabel>Zielgruppen</SectionLabel>
<Scroller options={{ overflow: { y: 'hidden' } }}>
<table className="w-full min-w-2xl border-collapse text-left">
<thead>
<tr>
<th className={headCellClass}>Schlüssel</th>
<th className={headCellClass}>Bedeutung</th>
</tr>
</thead>
<tbody>
{audiences.map(audience => (
<tr key={audience.key}>
<td className={cellClass}><code className="font-mono text-small">{audience.key}</code></td>
<td className={cellClass}>{audience.text}</td>
</tr>
))}
</tbody>
</table>
</Scroller>
<p className="m-0 max-w-read text-pretty text-ink-2">
Ein Zugang darf keine Zielgruppe setzen, die offener ist als seine eigene. Ein Zugang mit customer
kann internal und customer schreiben, public nicht.
</p>
</section>
<section className="flex flex-col gap-4">
<SectionLabel>Blocktypen</SectionLabel>
<Scroller options={{ overflow: { y: 'hidden' } }}>
<table className="w-full min-w-2xl border-collapse text-left">
<thead>
<tr>
<th className={headCellClass}>Typ</th>
<th className={headCellClass}>Felder in data</th>
</tr>
</thead>
<tbody>
{blockTypes.map(type => (
<tr key={type}>
<td className={cellClass}><code className="font-mono text-small">{type}</code></td>
<td className={cellClass}>{blockFields[type] ?? '-'}</td>
</tr>
))}
</tbody>
</table>
</Scroller>
</section>
<section className="flex flex-col gap-4">
<SectionLabel>Fehler</SectionLabel>
<p className="m-0 max-w-read text-pretty text-ink-2">
Fehler kommen einheitlich als Problem-JSON mit type, title, status und detail. 401 fehlendes oder
widerrufenes Token, 403 fehlendes Recht, 404 nicht vorhanden oder nicht sichtbar, 409 bereits
veröffentlicht, 422 unbekannte Beitragsart, 400 fehlerhafte Eingabe.
</p>
</section>
</div>
)
}
+45
View File
@@ -0,0 +1,45 @@
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
import { getTranslations } from 'next-intl/server'
import { AdminHeading } from '~/components/admin/AdminHeading'
import { BrandForm } from '~/components/admin/BrandForm'
import { findBrandById } from '~/data/repositories/projects'
import { isUuid } from '~/lib/admin-forms'
import { requireAdmin } from '~/lib/auth-guards'
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
const { id } = await params
const app = await getTranslations('app')
const t = await getTranslations('admin.brands')
const brand = isUuid(id) ? await findBrandById(id) : undefined
return { title: `${brand?.name ?? t('title')} - ${app('name')}` }
}
export default async function EditBrandPage({ params }: { params: Promise<{ id: string }> }) {
await requireAdmin()
const { id } = await params
const brand = isUuid(id) ? await findBrandById(id) : undefined
if (!brand) {
notFound()
}
const t = await getTranslations('admin.brands')
return (
<div className="flex flex-col gap-8 pt-12">
<AdminHeading eyebrow={t('eyebrow')} title={brand.name} intro={t('editIntro')} />
<BrandForm
brand={{
id: brand.id,
name: brand.name,
slug: brand.slug,
color: brand.color,
sort: brand.sort,
}}
/>
</div>
)
}
+25
View File
@@ -0,0 +1,25 @@
import type { Metadata } from 'next'
import { getTranslations } from 'next-intl/server'
import { AdminHeading } from '~/components/admin/AdminHeading'
import { BrandForm } from '~/components/admin/BrandForm'
import { requireAdmin } from '~/lib/auth-guards'
export async function generateMetadata(): Promise<Metadata> {
const app = await getTranslations('app')
const t = await getTranslations('admin.brands')
return { title: `${t('createTitle')} - ${app('name')}` }
}
export default async function NewBrandPage() {
await requireAdmin()
const t = await getTranslations('admin.brands')
return (
<div className="flex flex-col gap-8 pt-12">
<AdminHeading eyebrow={t('eyebrow')} title={t('createTitle')} intro={t('createIntro')} />
<BrandForm />
</div>
)
}
+104
View File
@@ -0,0 +1,104 @@
import type { Metadata } from 'next'
import Link from 'next/link'
import { getTranslations } from 'next-intl/server'
import { TbPlus } from 'react-icons/tb'
import { AdminHeading } from '~/components/admin/AdminHeading'
import { AdminNotice } from '~/components/admin/AdminNotice'
import { cellClass, ghostButtonClass, headCellClass, quietLinkClass } from '~/components/admin/styles'
import { projectStyle, projectSurface } from '~/components/ui/Plate'
import { Scroller } from '~/components/ui/Scroller'
import { countProjectsPerBrand, listBrands } from '~/data/repositories/projects'
import { adminBrandPath, adminNewBrandPath } from '~/lib/admin-routes'
import { requireAdmin } from '~/lib/auth-guards'
export async function generateMetadata(): Promise<Metadata> {
const app = await getTranslations('app')
const t = await getTranslations('admin.brands')
return { title: `${t('title')} - ${app('name')}` }
}
export default async function AdminBrandsPage() {
await requireAdmin()
const t = await getTranslations('admin.brands')
const [brands, counts] = await Promise.all([listBrands(), countProjectsPerBrand()])
return (
<div className="flex flex-col gap-8 pt-12">
<AdminHeading
eyebrow={t('eyebrow')}
title={t('title')}
intro={t('intro')}
actions={
<Link href={adminNewBrandPath} className={ghostButtonClass}>
<TbPlus aria-hidden="true" className="size-4 shrink-0" />
{t('create')}
</Link>
}
/>
{brands.length === 0 ? (
<AdminNotice
title={t('emptyTitle')}
action={
<Link href={adminNewBrandPath} className={quietLinkClass}>
{t('create')}
</Link>
}
>
{t('empty')}
</AdminNotice>
) : (
<Scroller options={{ overflow: { y: 'hidden' } }}>
<table className="w-full min-w-xl border-collapse text-left">
<thead>
<tr>
<th scope="col" className={headCellClass}>{t('columns.name')}</th>
<th scope="col" className={headCellClass}>{t('columns.slug')}</th>
<th scope="col" className={headCellClass}>{t('columns.color')}</th>
<th scope="col" className={headCellClass}>{t('columns.projects')}</th>
<th scope="col" className={headCellClass}>{t('columns.sort')}</th>
<th scope="col" className={headCellClass}>
<span className="sr-only">{t('columns.edit')}</span>
</th>
</tr>
</thead>
<tbody>
{brands.map(brand => (
<tr key={brand.id}>
<td className={cellClass}>
<Link
href={adminBrandPath(brand.id)}
className="font-display text-base font-semibold text-ink no-underline hover:text-signal"
>
{brand.name}
</Link>
</td>
<td className={`${cellClass} font-mono text-micro`}>{brand.slug}</td>
<td className={cellClass}>
<span className="flex items-center gap-2">
<span
aria-hidden="true"
style={projectStyle(brand.color)}
className={`size-3 shrink-0 ${projectSurface}`}
/>
<span className="font-mono text-micro uppercase tracking-mark">{brand.color}</span>
</span>
</td>
<td className={`${cellClass} font-mono tabular-nums`}>{counts.get(brand.id) ?? 0}</td>
<td className={`${cellClass} font-mono tabular-nums`}>{brand.sort}</td>
<td className={cellClass}>
<Link href={adminBrandPath(brand.id)} className={quietLinkClass}>
{t('columns.edit')}
</Link>
</td>
</tr>
))}
</tbody>
</table>
</Scroller>
)}
</div>
)
}
+95
View File
@@ -0,0 +1,95 @@
import type { Metadata } from 'next'
import { getTranslations } from 'next-intl/server'
import { AdminHeading } from '~/components/admin/AdminHeading'
import { AdminNotice } from '~/components/admin/AdminNotice'
import { ClientForm } from '~/components/admin/ClientForm'
import { ClientRevokeButton } from '~/components/admin/ClientRevokeButton'
import { cellClass, headCellClass } from '~/components/admin/styles'
import { EntryDate } from '~/components/ui/EntryDate'
import { SectionLabel } from '~/components/ui/SectionLabel'
import { Scroller } from '~/components/ui/Scroller'
import { listClients } from '~/data/repositories/clients'
import { listAllProjects } from '~/data/repositories/projects'
import { requireAdmin } from '~/lib/auth-guards'
export async function generateMetadata(): Promise<Metadata> {
const app = await getTranslations('app')
const t = await getTranslations('admin.clients')
return { title: `${t('title')} - ${app('name')}` }
}
export default async function AdminClientsPage() {
await requireAdmin()
const t = await getTranslations('admin.clients')
const [clients, projects] = await Promise.all([listClients(), listAllProjects()])
const projectNames = new Map(projects.map(project => [project.id, project.name]))
return (
<div className="flex flex-col gap-10 pt-12">
<AdminHeading eyebrow={t('eyebrow')} title={t('title')} intro={t('intro')} />
<section className="flex flex-col gap-4">
<SectionLabel meta={<span>{t('meta', { count: clients.length })}</span>}>{t('listTitle')}</SectionLabel>
{clients.length === 0 ? (
<AdminNotice title={t('emptyTitle')}>{t('empty')}</AdminNotice>
) : (
<Scroller options={{ overflow: { y: 'hidden' } }}>
<table className="w-full min-w-3xl border-collapse text-left">
<thead>
<tr>
<th scope="col" className={headCellClass}>{t('columns.name')}</th>
<th scope="col" className={headCellClass}>{t('columns.mode')}</th>
<th scope="col" className={headCellClass}>{t('columns.scope')}</th>
<th scope="col" className={headCellClass}>{t('columns.project')}</th>
<th scope="col" className={headCellClass}>{t('columns.lastUsed')}</th>
<th scope="col" className={headCellClass}>{t('columns.state')}</th>
<th scope="col" className={headCellClass}>
<span className="sr-only">{t('columns.action')}</span>
</th>
</tr>
</thead>
<tbody>
{clients.map(client => (
<tr key={client.id}>
<td className={`${cellClass} font-display text-base font-semibold text-ink`}>
{client.name}
</td>
<td className={cellClass}>{t(`modes.${client.mode}`)}</td>
<td className={cellClass}>{t(`scopes.${client.scope}`)}</td>
<td className={cellClass}>
{client.projectId === null
? t('allProjects')
: projectNames.get(client.projectId) ?? t('unknownProject')}
</td>
<td className={`${cellClass} font-mono text-micro`}>
{client.lastUsedAt ? <EntryDate date={client.lastUsedAt} /> : t('never')}
</td>
<td className={cellClass}>
{client.revokedAt ? (
<span className="text-signal">{t('revoked')}</span>
) : (
<span className="text-ink-2">{t('activeState')}</span>
)}
</td>
<td className={cellClass}>
{client.revokedAt ? null : <ClientRevokeButton id={client.id} />}
</td>
</tr>
))}
</tbody>
</table>
</Scroller>
)}
</section>
<section className="flex flex-col gap-6 border-t border-rule pt-8">
<SectionLabel>{t('createTitle')}</SectionLabel>
<p className="m-0 max-w-read text-base text-pretty text-ink-2">{t('createIntro')}</p>
<ClientForm projects={projects.map(project => ({ id: project.id, name: project.name }))} />
</section>
</div>
)
}
+80
View File
@@ -0,0 +1,80 @@
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
import { getFormatter, getLocale, getTranslations } from 'next-intl/server'
import { EntryEditor } from '~/components/admin/EntryEditor'
import { findEntry, listEntryBlocks } from '~/data/repositories/entries'
import { listMediaForProject } from '~/data/repositories/media'
import { listActivePostTypes } from '~/data/repositories/post-types'
import { parseBlocks } from '~/domain/blocks'
import { projectCode } from '~/domain/project-code'
import { adminEntryPath } from '~/lib/admin-routes'
import { requireProjectAccess } from '~/lib/auth-guards'
import { toEntryMediaList } from '~/lib/entry-media'
import { dateTimeFormat } from '~/lib/dates'
import { supportedMimes } from '~/lib/media-images'
import { toTypeOption, toTypeOptions, withCurrentType } from '~/lib/post-types'
import type { Locale } from '~/i18n/config'
type Params = Promise<{ id: string }>
export async function generateMetadata({ params }: { params: Params }): Promise<Metadata> {
const app = await getTranslations('app')
const t = await getTranslations('admin.entries')
const { id } = await params
const entry = await findEntry(id)
return { title: `${entry?.title ?? t('editTitle')} - ${app('name')}` }
}
export default async function EditEntryPage({ params }: { params: Params }) {
const { id } = await params
const entry = await findEntry(id)
if (!entry) {
notFound()
}
await requireProjectAccess(entry.projectId, adminEntryPath(entry.id))
const format = await getFormatter()
const locale = await getLocale() as Locale
const [rows, media, types] = await Promise.all([
listEntryBlocks(entry.id),
listMediaForProject(entry.projectId),
listActivePostTypes(),
])
const parsed = parseBlocks(rows.map(row => ({ type: row.type, data: row.data })))
return (
<div className="flex flex-col gap-8 pt-12">
<EntryEditor
projects={[{
id: entry.project.id,
slug: entry.project.slug,
name: entry.project.name,
code: projectCode(entry.project),
color: entry.project.color,
}]}
types={withCurrentType(toTypeOptions(types, locale), toTypeOption(entry.type, locale))}
media={toEntryMediaList(media)}
accept={supportedMimes().join(',')}
defaultProjectId={entry.projectId}
entry={{
id: entry.id,
number: entry.number,
slug: entry.slug,
status: entry.status,
publishAt: entry.publishAt ? entry.publishAt.toISOString() : null,
updatedLabel: format.dateTime(entry.updatedAt, dateTimeFormat),
projectId: entry.projectId,
title: entry.title,
teaser: entry.teaser ?? '',
typeId: entry.typeId,
audience: entry.audience,
coverMediaId: entry.coverMediaId,
blocks: parsed.ok ? parsed.blocks : [],
}}
/>
</div>
)
}
+85
View File
@@ -0,0 +1,85 @@
import type { Metadata } from 'next'
import Link from 'next/link'
import { cookies } from 'next/headers'
import { getLocale, getTranslations } from 'next-intl/server'
import { AdminHeading } from '~/components/admin/AdminHeading'
import { AdminNotice } from '~/components/admin/AdminNotice'
import { EntryEditor } from '~/components/admin/EntryEditor'
import { quietLinkClass } from '~/components/admin/styles'
import { listMediaForProject } from '~/data/repositories/media'
import { listActivePostTypes } from '~/data/repositories/post-types'
import { listAllProjects } from '~/data/repositories/projects'
import { listProjectsForUser } from '~/data/repositories/user-roles'
import { projectCode } from '~/domain/project-code'
import { adminNewProjectPath, lastProjectCookie } from '~/lib/admin-routes'
import { isAdmin } from '~/lib/auth-access'
import { requireAdminArea } from '~/lib/auth-guards'
import { toEntryMediaList } from '~/lib/entry-media'
import { supportedMimes } from '~/lib/media-images'
import { toTypeOptions } from '~/lib/post-types'
import type { Locale } from '~/i18n/config'
type SearchParams = Promise<Record<string, string | string[] | undefined>>
export async function generateMetadata(): Promise<Metadata> {
const app = await getTranslations('app')
const t = await getTranslations('admin.entries')
return { title: `${t('createTitle')} - ${app('name')}` }
}
export default async function NewEntryPage({ searchParams }: { searchParams: SearchParams }) {
const viewer = await requireAdminArea()
const t = await getTranslations('admin.entries')
const params = await searchParams
const manages = isAdmin(viewer)
const projects = manages ? await listAllProjects() : await listProjectsForUser(viewer.id)
const wanted = typeof params.project === 'string' ? params.project : ''
const store = await cookies()
const remembered = store.get(lastProjectCookie)?.value ?? ''
const chosen = projects.find(project => project.slug === wanted)
?? projects.find(project => project.id === remembered)
?? projects[0]
if (!chosen) {
return (
<div className="flex flex-col gap-8 pt-12">
<AdminHeading eyebrow={t('eyebrow')} title={t('createTitle')} />
<AdminNotice
title={t('noProjectsTitle')}
action={
manages ? (
<Link href={adminNewProjectPath} className={quietLinkClass}>
{t('toProjects')}
</Link>
) : null
}
>
{manages ? t('noProjectsForAdmin') : t('noProjects')}
</AdminNotice>
</div>
)
}
const locale = await getLocale() as Locale
const [media, types] = await Promise.all([listMediaForProject(chosen.id), listActivePostTypes()])
return (
<div className="flex flex-col gap-8 pt-12">
<EntryEditor
projects={projects.map(project => ({
id: project.id,
slug: project.slug,
name: project.name,
code: projectCode(project),
color: project.color,
}))}
types={toTypeOptions(types, locale)}
media={toEntryMediaList(media)}
accept={supportedMimes().join(',')}
defaultProjectId={chosen.id}
/>
</div>
)
}
+228
View File
@@ -0,0 +1,228 @@
import type { Metadata, Route } from 'next'
import Link from 'next/link'
import { getLocale, getTranslations } from 'next-intl/server'
import { TbPlus } from 'react-icons/tb'
import { AdminHeading } from '~/components/admin/AdminHeading'
import { AdminNotice } from '~/components/admin/AdminNotice'
import { EntryFilters } from '~/components/admin/EntryFilters'
import { EntryStatusChip } from '~/components/admin/EntryStatusChip'
import { cellClass, ghostButtonClass, headCellClass, quietLinkClass } from '~/components/admin/styles'
import { EntryDate } from '~/components/ui/EntryDate'
import { entryMark, projectStyle, projectSurface } from '~/components/ui/Plate'
import { Scroller } from '~/components/ui/Scroller'
import { SectionLabel } from '~/components/ui/SectionLabel'
import { listEntries } from '~/data/repositories/entries'
import { listAllProjects } from '~/data/repositories/projects'
import { listPostTypes } from '~/data/repositories/post-types'
import { listProjectsForUser } from '~/data/repositories/user-roles'
import { isPostStatus } from '~/domain/post-status'
import { isPostTypeKey, postTypeLabel } from '~/domain/post-type'
import { adminEntriesPath, adminNewEntryPath, adminEntryPath } from '~/lib/admin-routes'
import { isAdmin } from '~/lib/auth-access'
import { requireAdminArea } from '~/lib/auth-guards'
import { toTypeOptions } from '~/lib/post-types'
import type { Locale } from '~/i18n/config'
const perPage = 25
type SearchParams = Promise<Record<string, string | string[] | undefined>>
function single(value: string | string[] | undefined): string {
return typeof value === 'string' ? value.trim() : ''
}
export async function generateMetadata(): Promise<Metadata> {
const app = await getTranslations('app')
const t = await getTranslations('admin.entries')
return { title: `${t('title')} - ${app('name')}` }
}
export default async function AdminEntriesPage({ searchParams }: { searchParams: SearchParams }) {
const viewer = await requireAdminArea()
const t = await getTranslations('admin.entries')
const params = await searchParams
const manages = isAdmin(viewer)
const projects = manages ? await listAllProjects() : await listProjectsForUser(viewer.id)
const locale = await getLocale() as Locale
const types = await listPostTypes()
const projectSlug = single(params.project)
const statusValue = single(params.status)
const typeValue = single(params.type)
const search = single(params.q)
const page = Math.max(1, Number(single(params.page)) || 1)
const selected = projects.find(project => project.slug === projectSlug)
const filtered = projectSlug !== '' || statusValue !== '' || typeValue !== '' || search !== ''
const result = projects.length === 0
? { items: [], total: 0 }
: await listEntries({
projectIds: manages ? undefined : projects.map(project => project.id),
projectId: selected?.id,
status: isPostStatus(statusValue) ? statusValue : undefined,
type: isPostTypeKey(typeValue) ? typeValue : undefined,
search,
page,
perPage,
})
const pages = Math.max(1, Math.ceil(result.total / perPage))
const query = new URLSearchParams()
if (projectSlug !== '') {
query.set('project', projectSlug)
}
if (statusValue !== '') {
query.set('status', statusValue)
}
if (typeValue !== '') {
query.set('type', typeValue)
}
if (search !== '') {
query.set('q', search)
}
function pagePath(target: number): Route {
const next = new URLSearchParams(query)
if (target > 1) {
next.set('page', String(target))
}
const suffix = next.toString()
return (suffix === '' ? adminEntriesPath : `${adminEntriesPath}?${suffix}`) as Route
}
return (
<div className="flex flex-col gap-8 pt-12">
<AdminHeading
eyebrow={t('eyebrow')}
title={t('title')}
intro={t('intro')}
actions={
projects.length > 0 ? (
<Link href={adminNewEntryPath} className={ghostButtonClass}>
<TbPlus aria-hidden="true" className="size-4 shrink-0" />
{t('create')}
</Link>
) : null
}
/>
{projects.length === 0 ? (
<AdminNotice title={t('noProjectsTitle')}>{manages ? t('noProjectsForAdmin') : t('noProjects')}</AdminNotice>
) : (
<>
<EntryFilters
projects={projects.map(project => ({ slug: project.slug, name: project.name }))}
types={toTypeOptions(types, locale)}
project={projectSlug}
status={statusValue}
type={typeValue}
search={search}
filtered={filtered}
/>
<section className="flex flex-col gap-4">
<SectionLabel meta={<span>{t('meta', { count: result.total })}</span>}>{t('listTitle')}</SectionLabel>
{result.items.length === 0 ? (
<AdminNotice
title={filtered ? t('emptyFilteredTitle') : t('emptyTitle')}
action={
filtered ? (
<Link href={adminEntriesPath} className={quietLinkClass}>
{t('filters.reset')}
</Link>
) : (
<Link href={adminNewEntryPath} className={quietLinkClass}>
{t('create')}
</Link>
)
}
>
{filtered ? t('emptyFiltered') : t('empty')}
</AdminNotice>
) : (
<Scroller options={{ overflow: { y: 'hidden' } }}>
<table className="w-full min-w-3xl border-collapse text-left">
<thead>
<tr>
<th scope="col" className={headCellClass}>{t('columns.number')}</th>
<th scope="col" className={headCellClass}>{t('columns.title')}</th>
<th scope="col" className={headCellClass}>{t('columns.project')}</th>
<th scope="col" className={headCellClass}>{t('columns.type')}</th>
<th scope="col" className={headCellClass}>{t('columns.audience')}</th>
<th scope="col" className={headCellClass}>{t('columns.status')}</th>
<th scope="col" className={headCellClass}>{t('columns.date')}</th>
<th scope="col" className={headCellClass}>{t('columns.author')}</th>
</tr>
</thead>
<tbody>
{result.items.map(item => (
<tr key={item.id}>
<td className={`${cellClass} font-mono text-micro uppercase tracking-mark`}>
{entryMark({ code: item.projectCode, slug: item.projectSlug }, item.number)}
</td>
<td className={cellClass}>
<Link
href={adminEntryPath(item.id)}
className="font-display text-base font-semibold text-ink no-underline hover:text-signal"
>
{item.title}
</Link>
</td>
<td className={cellClass}>
<span className="flex items-center gap-2">
<span
aria-hidden="true"
style={projectStyle(item.projectColor)}
className={`size-2.5 shrink-0 ${projectSurface}`}
/>
{item.projectName}
</span>
</td>
<td className={cellClass}>{postTypeLabel(item.type, locale)}</td>
<td className={cellClass}>{t(`audience.${item.audience}`)}</td>
<td className={cellClass}>
<EntryStatusChip status={item.status} label={t(`status.${item.status}`)} />
</td>
<td className={`${cellClass} font-mono text-micro`}>
<EntryDate date={item.publishAt ?? item.updatedAt} />
</td>
<td className={cellClass}>{item.authorName ?? t('noAuthor')}</td>
</tr>
))}
</tbody>
</table>
</Scroller>
)}
{pages > 1 ? (
<div className="flex flex-wrap items-center gap-6 border-t border-rule pt-4">
{page > 1 ? (
<Link href={pagePath(page - 1)} className={quietLinkClass}>
{t('pagination.previous')}
</Link>
) : null}
<span className="font-mono text-micro uppercase tracking-mark text-ink-3 tabular-nums">
{t('pagination.status', { page, pages })}
</span>
{page < pages ? (
<Link href={pagePath(page + 1)} className={quietLinkClass}>
{t('pagination.next')}
</Link>
) : null}
</div>
) : null}
</section>
</>
)}
</div>
)
}
+36
View File
@@ -0,0 +1,36 @@
import type { Metadata } from 'next'
import type { ReactNode } from 'react'
import { getTranslations } from 'next-intl/server'
import { AppShell } from '~/components/layout/AppShell'
import { AdminNav } from '~/components/layout/AdminNav'
import { Wrap } from '~/components/layout/Wrap'
import { Wordmark } from '~/components/layout/Wordmark'
import { loadHighestNumber } from '~/data/repositories/archive'
import { requireSession } from '~/lib/auth-guards'
import { currentPath } from '~/lib/request-path'
import type { ViewerScope } from '~/domain/types'
const scope: ViewerScope = 'internal'
export async function generateMetadata(): Promise<Metadata> {
const app = await getTranslations('app')
const t = await getTranslations('admin')
return { title: `${t('title')} - ${app('name')}` }
}
export default async function AdminLayout({ children }: { children: ReactNode }) {
const viewer = await requireSession(await currentPath())
const number = await loadHighestNumber({ scope })
return (
<AppShell
nav={<AdminNav viewer={viewer} number={number} />}
mark={<Wordmark number={number} />}
>
<main id="content">
<Wrap className="pb-16 pt-8">{children}</Wrap>
</main>
</AppShell>
)
}
+61
View File
@@ -0,0 +1,61 @@
import Link from 'next/link'
import { notFound } from 'next/navigation'
import { getTranslations } from 'next-intl/server'
import { TbArrowNarrowLeft } from 'react-icons/tb'
import { AdminHeading } from '~/components/admin/AdminHeading'
import { AdminNotice } from '~/components/admin/AdminNotice'
import { MediaCard } from '~/components/admin/MediaCard'
import { MediaUploadForm } from '~/components/admin/MediaUploadForm'
import { ghostButtonClass } from '~/components/admin/styles'
import { SectionLabel } from '~/components/ui/SectionLabel'
import { listMediaForProject } from '~/data/repositories/media'
import { findProjectBySlug } from '~/data/repositories/projects'
import { requireProjectAccess } from '~/lib/auth-guards'
import { adminMediaPath, adminMediaProjectPath } from '~/lib/auth-routes'
import { supportedMimes } from '~/lib/media-images'
export default async function AdminProjectMediaPage({ params }: { params: Promise<{ project: string }> }) {
const { project: slug } = await params
const project = await findProjectBySlug(slug)
if (!project) {
notFound()
}
await requireProjectAccess(project.id, adminMediaProjectPath(project.slug))
const t = await getTranslations('media')
const items = await listMediaForProject(project.id)
return (
<div className="flex flex-col gap-10 pt-12">
<AdminHeading
eyebrow={t('eyebrow')}
title={project.name}
intro={t('projectIntro')}
actions={
<Link href={adminMediaPath} className={ghostButtonClass}>
<TbArrowNarrowLeft aria-hidden="true" className="size-4 shrink-0" />
{t('back')}
</Link>
}
/>
<MediaUploadForm project={project.slug} accept={supportedMimes().join(',')} />
<section className="flex flex-col gap-4">
<SectionLabel meta={<span>{t('projectMeta', { count: items.length })}</span>}>{t('library')}</SectionLabel>
{items.length === 0 ? (
<AdminNotice title={t('emptyLibraryTitle')}>{t('emptyLibrary')}</AdminNotice>
) : (
<ul className="m-0 flex list-none flex-col gap-4 p-0">
{items.map(item => (
<MediaCard key={item.id} item={item} project={project.slug} />
))}
</ul>
)}
</section>
</div>
)
}
+51
View File
@@ -0,0 +1,51 @@
import Link from 'next/link'
import { getTranslations } from 'next-intl/server'
import { TbArrowNarrowRight } from 'react-icons/tb'
import { AdminHeading } from '~/components/admin/AdminHeading'
import { AdminNotice } from '~/components/admin/AdminNotice'
import { metaClass } from '~/components/admin/styles'
import { projectStyle, projectSurface } from '~/components/ui/Plate'
import { countMediaForProject } from '~/data/repositories/media'
import { listAllProjects } from '~/data/repositories/projects'
import { listProjectsForUser } from '~/data/repositories/user-roles'
import { isAdmin } from '~/lib/auth-access'
import { requireAdminArea } from '~/lib/auth-guards'
import { adminMediaPath, adminMediaProjectPath } from '~/lib/auth-routes'
export default async function AdminMediaPage() {
const viewer = await requireAdminArea(adminMediaPath)
const t = await getTranslations('media')
const projects = isAdmin(viewer) ? await listAllProjects() : await listProjectsForUser(viewer.id)
const counts = await Promise.all(projects.map(project => countMediaForProject(project.id)))
return (
<div className="flex flex-col gap-10 pt-12">
<AdminHeading eyebrow={t('eyebrow')} title={t('title')} intro={t('intro')} />
{projects.length === 0 ? (
<AdminNotice title={t('emptyProjectsTitle')}>{t('emptyProjects')}</AdminNotice>
) : (
<ul className="m-0 flex list-none flex-col p-0">
{projects.map((project, index) => (
<li key={project.id} className="border-b border-rule last:border-b-0">
<Link
href={adminMediaProjectPath(project.slug)}
className="flex flex-wrap items-center gap-x-4 gap-y-2 py-3 text-ink no-underline hover:text-signal"
>
<span
aria-hidden="true"
style={projectStyle(project.color)}
className={`size-2.5 shrink-0 ${projectSurface}`}
/>
<span className="font-display text-base font-semibold">{project.name}</span>
<span className={metaClass}>{t('projectMeta', { count: counts[index] ?? 0 })}</span>
<span aria-hidden="true" className="h-px min-w-6 flex-1 bg-rule" />
<TbArrowNarrowRight aria-hidden="true" className="size-4 shrink-0" />
</Link>
</li>
))}
</ul>
)}
</div>
)
}
+35
View File
@@ -0,0 +1,35 @@
import Link from 'next/link'
import { getTranslations } from 'next-intl/server'
import { AdminNotice } from '~/components/admin/AdminNotice'
import { canOpenAdmin } from '~/lib/auth-access'
import { requireSession } from '~/lib/auth-guards'
import { adminPath } from '~/lib/auth-routes'
export default async function AdminNoAccessPage() {
const viewer = await requireSession()
const t = await getTranslations('admin.noAccess')
const hasSomeAccess = canOpenAdmin(viewer)
return (
<div className="flex flex-col gap-6 pt-12">
<div className="flex flex-col gap-4">
<h1 className="text-title font-bold text-balance">{t('title')}</h1>
</div>
<AdminNotice
tone="alert"
action={
hasSomeAccess ? (
<Link href={adminPath} className="font-mono text-small">
{t('toOverview')}
</Link>
) : null
}
>
{hasSomeAccess ? t('forModerator') : t('forNobody')}
</AdminNotice>
<p className="m-0 max-w-read text-small text-pretty text-ink-3">{t('wrongAccount')}</p>
</div>
)
}
+146
View File
@@ -0,0 +1,146 @@
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 { AdminNotice } from '~/components/admin/AdminNotice'
import { ghostButtonClass, quietLinkClass } from '~/components/admin/styles'
import { projectStyle, projectSurface } from '~/components/ui/Plate'
import { SectionLabel } from '~/components/ui/SectionLabel'
import { listClients } from '~/data/repositories/clients'
import { listAllProjects, listBrands } from '~/data/repositories/projects'
import { listUsers } from '~/data/repositories/users'
import { listProjectsForUser } from '~/data/repositories/user-roles'
import { projectCode } from '~/domain/project-code'
import {
adminBrandsPath,
adminClientsPath,
adminNewEntryPath,
adminNewProjectPath,
adminProjectPath,
adminProjectsPath,
adminUsersPath,
} from '~/lib/admin-routes'
import { isAdmin } from '~/lib/auth-access'
import { requireAdminArea } from '~/lib/auth-guards'
import { adminMediaPath, adminMediaProjectPath } from '~/lib/auth-routes'
export default async function AdminPage() {
const viewer = await requireAdminArea()
const t = await getTranslations('admin')
const e = await getTranslations('admin.entries')
const m = await getTranslations('media')
const manages = isAdmin(viewer)
const projects = manages ? await listAllProjects() : await listProjectsForUser(viewer.id)
const brands = manages ? await listBrands() : []
const users = manages ? await listUsers() : []
const clients = manages ? await listClients() : []
const tiles = manages
? [
{ 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 },
{
href: adminClientsPath,
label: t('nav.clients'),
value: clients.filter(client => client.revokedAt === null).length,
},
]
: []
return (
<div className="flex flex-col gap-10 pt-12">
<AdminHeading
eyebrow={manages ? t('role.admin') : t('role.moderator')}
title={t('title')}
intro={t('intro')}
actions={
<>
{projects.length > 0 ? (
<Link href={adminNewEntryPath} className={ghostButtonClass}>
<TbPlus aria-hidden="true" className="size-4 shrink-0" />
{e('create')}
</Link>
) : null}
<Link href={adminMediaPath} className={ghostButtonClass}>
<TbPhoto aria-hidden="true" className="size-4 shrink-0" />
{m('title')}
</Link>
</>
}
/>
{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">
{tiles.map(tile => (
<li key={tile.href} 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}
</span>
<span className="font-mono text-title font-semibold text-ink tabular-nums">{tile.value}</span>
</Link>
</li>
))}
</ul>
) : null}
<section className="flex flex-col gap-4">
<SectionLabel meta={<span>{t('projectsMeta', { count: projects.length })}</span>}>
{t('yourProjects')}
</SectionLabel>
{projects.length === 0 ? (
<AdminNotice
title={t('emptyTitle')}
action={
manages ? (
<Link href={adminNewProjectPath} className={quietLinkClass}>
{t('emptyAction')}
</Link>
) : null
}
>
{manages ? t('emptyForAdmin') : t('empty')}
</AdminNotice>
) : (
<ul className="m-0 flex list-none flex-col p-0">
{projects.map(project => (
<li
key={project.id}
className="flex flex-wrap items-center gap-x-4 gap-y-2 border-b border-rule py-3 last:border-b-0"
>
<span
aria-hidden="true"
style={projectStyle(project.color)}
className={`size-2.5 shrink-0 ${projectSurface}`}
/>
<Link
href={adminMediaProjectPath(project.slug)}
className="flex items-center gap-2 font-display text-base font-semibold text-ink no-underline hover:text-signal"
>
{project.name}
<TbArrowNarrowRight aria-hidden="true" className="size-4 shrink-0" />
</Link>
<span className="font-mono text-micro font-semibold uppercase tracking-mark text-ink-3">
{projectCode(project)}
</span>
{project.isActive ? null : (
<span className="font-mono text-micro uppercase tracking-mark text-signal">
{t('inactive')}
</span>
)}
<span aria-hidden="true" className="h-px min-w-6 flex-1 bg-rule" />
{manages ? (
<Link href={adminProjectPath(project.id)} className={quietLinkClass}>
{t('edit')}
</Link>
) : null}
</li>
))}
</ul>
)}
</section>
</div>
)
}
+63
View File
@@ -0,0 +1,63 @@
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
import { getLocale, getTranslations } from 'next-intl/server'
import { AdminHeading } from '~/components/admin/AdminHeading'
import { PostTypeDeleteButton } from '~/components/admin/PostTypeDeleteButton'
import { PostTypeForm } from '~/components/admin/PostTypeForm'
import { SectionLabel } from '~/components/ui/SectionLabel'
import { countPostsOfType, findPostTypeById } from '~/data/repositories/post-types'
import { postTypeLabel } from '~/domain/post-type'
import { isUuid } from '~/lib/admin-forms'
import { requireAdmin } from '~/lib/auth-guards'
import type { Locale } from '~/i18n/config'
type Params = Promise<{ id: string }>
export async function generateMetadata({ params }: { params: Params }): Promise<Metadata> {
const { id } = await params
const app = await getTranslations('app')
const t = await getTranslations('admin.postTypes')
const locale = await getLocale() as Locale
const type = isUuid(id) ? await findPostTypeById(id) : undefined
return { title: `${type ? postTypeLabel(type, locale) : t('title')} - ${app('name')}` }
}
export default async function EditPostTypePage({ params }: { params: Params }) {
await requireAdmin()
const { id } = await params
const type = isUuid(id) ? await findPostTypeById(id) : undefined
if (!type) {
notFound()
}
const t = await getTranslations('admin.postTypes')
const locale = await getLocale() as Locale
const postCount = await countPostsOfType(type.id)
return (
<div className="flex flex-col gap-8 pt-12">
<AdminHeading eyebrow={t('eyebrow')} title={postTypeLabel(type, locale)} intro={t('editIntro')} />
<PostTypeForm
postType={{
id: type.id,
key: type.key,
labelDe: type.labelDe,
labelEn: type.labelEn,
color: type.color,
sort: type.sort,
isActive: type.isActive,
}}
locked={postCount > 0}
/>
<section className="flex flex-col gap-4 border-t border-rule pt-8">
<SectionLabel meta={<span>{t('usage', { count: postCount })}</span>}>{t('deleteTitle')}</SectionLabel>
<PostTypeDeleteButton id={type.id} postCount={postCount} />
</section>
</div>
)
}
+25
View File
@@ -0,0 +1,25 @@
import type { Metadata } from 'next'
import { getTranslations } from 'next-intl/server'
import { AdminHeading } from '~/components/admin/AdminHeading'
import { PostTypeForm } from '~/components/admin/PostTypeForm'
import { requireAdmin } from '~/lib/auth-guards'
export async function generateMetadata(): Promise<Metadata> {
const app = await getTranslations('app')
const t = await getTranslations('admin.postTypes')
return { title: `${t('createTitle')} - ${app('name')}` }
}
export default async function NewPostTypePage() {
await requireAdmin()
const t = await getTranslations('admin.postTypes')
return (
<div className="flex flex-col gap-8 pt-12">
<AdminHeading eyebrow={t('eyebrow')} title={t('createTitle')} intro={t('createIntro')} />
<PostTypeForm />
</div>
)
}
+111
View File
@@ -0,0 +1,111 @@
import type { Metadata } from 'next'
import Link from 'next/link'
import { getLocale, getTranslations } from 'next-intl/server'
import { TbPlus } from 'react-icons/tb'
import { AdminHeading } from '~/components/admin/AdminHeading'
import { AdminNotice } from '~/components/admin/AdminNotice'
import { cellClass, ghostButtonClass, headCellClass, quietLinkClass } from '~/components/admin/styles'
import { projectStyle, projectSurface } from '~/components/ui/Plate'
import { Scroller } from '~/components/ui/Scroller'
import { countPostsPerPostType, listPostTypes } from '~/data/repositories/post-types'
import { postTypeLabel } from '~/domain/post-type'
import { adminNewPostTypePath, adminPostTypePath } from '~/lib/admin-routes'
import { requireAdmin } from '~/lib/auth-guards'
import type { Locale } from '~/i18n/config'
export async function generateMetadata(): Promise<Metadata> {
const app = await getTranslations('app')
const t = await getTranslations('admin.postTypes')
return { title: `${t('title')} - ${app('name')}` }
}
export default async function AdminPostTypesPage() {
await requireAdmin()
const t = await getTranslations('admin.postTypes')
const locale = await getLocale() as Locale
const [types, counts] = await Promise.all([listPostTypes(), countPostsPerPostType()])
return (
<div className="flex flex-col gap-8 pt-12">
<AdminHeading
eyebrow={t('eyebrow')}
title={t('title')}
intro={t('intro')}
actions={
<Link href={adminNewPostTypePath} className={ghostButtonClass}>
<TbPlus aria-hidden="true" className="size-4 shrink-0" />
{t('create')}
</Link>
}
/>
{types.length === 0 ? (
<AdminNotice
title={t('emptyTitle')}
action={
<Link href={adminNewPostTypePath} className={quietLinkClass}>
{t('create')}
</Link>
}
>
{t('empty')}
</AdminNotice>
) : (
<Scroller options={{ overflow: { y: 'hidden' } }}>
<table className="w-full min-w-xl border-collapse text-left">
<thead>
<tr>
<th scope="col" className={headCellClass}>{t('columns.label')}</th>
<th scope="col" className={headCellClass}>{t('columns.key')}</th>
<th scope="col" className={headCellClass}>{t('columns.color')}</th>
<th scope="col" className={headCellClass}>{t('columns.posts')}</th>
<th scope="col" className={headCellClass}>{t('columns.sort')}</th>
<th scope="col" className={headCellClass}>{t('columns.active')}</th>
<th scope="col" className={headCellClass}>
<span className="sr-only">{t('columns.edit')}</span>
</th>
</tr>
</thead>
<tbody>
{types.map(type => (
<tr key={type.id}>
<td className={cellClass}>
<Link
href={adminPostTypePath(type.id)}
className="font-display text-base font-semibold text-ink no-underline hover:text-signal"
>
{postTypeLabel(type, locale)}
</Link>
</td>
<td className={`${cellClass} font-mono text-micro`}>{type.key}</td>
<td className={cellClass}>
<span className="flex items-center gap-2">
<span
aria-hidden="true"
style={projectStyle(type.color)}
className={`size-3 shrink-0 ${projectSurface}`}
/>
<span className="font-mono text-micro uppercase tracking-mark">{type.color}</span>
</span>
</td>
<td className={`${cellClass} font-mono tabular-nums`}>{counts.get(type.id) ?? 0}</td>
<td className={`${cellClass} font-mono tabular-nums`}>{type.sort}</td>
<td className={`${cellClass} font-mono text-micro uppercase tracking-mark`}>
{type.isActive ? t('active') : t('inactive')}
</td>
<td className={cellClass}>
<Link href={adminPostTypePath(type.id)} className={quietLinkClass}>
{t('columns.edit')}
</Link>
</td>
</tr>
))}
</tbody>
</table>
</Scroller>
)}
</div>
)
}
+64
View File
@@ -0,0 +1,64 @@
import type { Metadata } from 'next'
import Link from 'next/link'
import { notFound } from 'next/navigation'
import { getTranslations } from 'next-intl/server'
import { AdminHeading } from '~/components/admin/AdminHeading'
import { ProjectForm } from '~/components/admin/ProjectForm'
import { quietLinkClass } from '~/components/admin/styles'
import { findProjectById, listBrands } from '~/data/repositories/projects'
import { isUuid } from '~/lib/admin-forms'
import { requireAdmin } from '~/lib/auth-guards'
import { projectPath } from '~/lib/routes'
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
const { id } = await params
const app = await getTranslations('app')
const t = await getTranslations('admin.projects')
const project = isUuid(id) ? await findProjectById(id) : undefined
return { title: `${project?.name ?? t('title')} - ${app('name')}` }
}
export default async function EditProjectPage({ params }: { params: Promise<{ id: string }> }) {
await requireAdmin()
const { id } = await params
const project = isUuid(id) ? await findProjectById(id) : undefined
if (!project) {
notFound()
}
const t = await getTranslations('admin.projects')
const brands = await listBrands()
return (
<div className="flex flex-col gap-8 pt-12">
<AdminHeading
eyebrow={t('eyebrow')}
title={project.name}
intro={t('editIntro')}
actions={
<Link href={projectPath(project.slug)} className={quietLinkClass}>
{t('toArchive')}
</Link>
}
/>
<ProjectForm
brands={brands.map(brand => ({ id: brand.id, name: brand.name }))}
project={{
id: project.id,
name: project.name,
slug: project.slug,
code: project.code ?? '',
description: project.description ?? '',
color: project.color,
brandId: project.brandId,
sort: project.sort,
isActive: project.isActive,
}}
/>
</div>
)
}
+45
View File
@@ -0,0 +1,45 @@
import type { Metadata } from 'next'
import Link from 'next/link'
import { getTranslations } from 'next-intl/server'
import { AdminHeading } from '~/components/admin/AdminHeading'
import { AdminNotice } from '~/components/admin/AdminNotice'
import { ProjectForm } from '~/components/admin/ProjectForm'
import { quietLinkClass } from '~/components/admin/styles'
import { listBrands } from '~/data/repositories/projects'
import { adminNewBrandPath } from '~/lib/admin-routes'
import { requireAdmin } from '~/lib/auth-guards'
export async function generateMetadata(): Promise<Metadata> {
const app = await getTranslations('app')
const t = await getTranslations('admin.projects')
return { title: `${t('createTitle')} - ${app('name')}` }
}
export default async function NewProjectPage() {
await requireAdmin()
const t = await getTranslations('admin.projects')
const brands = await listBrands()
return (
<div className="flex flex-col gap-8 pt-12">
<AdminHeading eyebrow={t('eyebrow')} title={t('createTitle')} intro={t('createIntro')} />
{brands.length === 0 ? (
<AdminNotice
title={t('noBrandsTitle')}
action={
<Link href={adminNewBrandPath} className={quietLinkClass}>
{t('toBrands')}
</Link>
}
>
{t('noBrands')}
</AdminNotice>
) : (
<ProjectForm brands={brands.map(brand => ({ id: brand.id, name: brand.name }))} />
)}
</div>
)
}
+131
View File
@@ -0,0 +1,131 @@
import type { Metadata } from 'next'
import Link from 'next/link'
import { getTranslations } from 'next-intl/server'
import { TbPlus } from 'react-icons/tb'
import { AdminHeading } from '~/components/admin/AdminHeading'
import { AdminNotice } from '~/components/admin/AdminNotice'
import { cellClass, ghostButtonClass, headCellClass, quietLinkClass } from '~/components/admin/styles'
import { projectStyle, projectSurface } from '~/components/ui/Plate'
import { Scroller } from '~/components/ui/Scroller'
import { countPostsPerProject, listAllProjects, listBrands } from '~/data/repositories/projects'
import { projectCode } from '~/domain/project-code'
import { adminNewBrandPath, adminNewProjectPath, adminProjectPath } from '~/lib/admin-routes'
import { requireAdmin } from '~/lib/auth-guards'
export async function generateMetadata(): Promise<Metadata> {
const app = await getTranslations('app')
const t = await getTranslations('admin.projects')
return { title: `${t('title')} - ${app('name')}` }
}
export default async function AdminProjectsPage() {
await requireAdmin()
const t = await getTranslations('admin.projects')
const [projects, brands, counts] = await Promise.all([
listAllProjects(),
listBrands(),
countPostsPerProject(),
])
const brandNames = new Map(brands.map(brand => [brand.id, brand.name]))
return (
<div className="flex flex-col gap-8 pt-12">
<AdminHeading
eyebrow={t('eyebrow')}
title={t('title')}
intro={t('intro')}
actions={
brands.length > 0 ? (
<Link href={adminNewProjectPath} className={ghostButtonClass}>
<TbPlus aria-hidden="true" className="size-4 shrink-0" />
{t('create')}
</Link>
) : null
}
/>
{brands.length === 0 ? (
<AdminNotice
title={t('noBrandsTitle')}
action={
<Link href={adminNewBrandPath} className={quietLinkClass}>
{t('toBrands')}
</Link>
}
>
{t('noBrands')}
</AdminNotice>
) : projects.length === 0 ? (
<AdminNotice
title={t('emptyTitle')}
action={
<Link href={adminNewProjectPath} className={quietLinkClass}>
{t('create')}
</Link>
}
>
{t('empty')}
</AdminNotice>
) : (
<Scroller options={{ overflow: { y: 'hidden' } }}>
<table className="w-full min-w-3xl border-collapse text-left">
<thead>
<tr>
<th scope="col" className={headCellClass}>{t('columns.name')}</th>
<th scope="col" className={headCellClass}>{t('columns.code')}</th>
<th scope="col" className={headCellClass}>{t('columns.brand')}</th>
<th scope="col" className={headCellClass}>{t('columns.color')}</th>
<th scope="col" className={headCellClass}>{t('columns.posts')}</th>
<th scope="col" className={headCellClass}>{t('columns.state')}</th>
<th scope="col" className={headCellClass}>
<span className="sr-only">{t('columns.edit')}</span>
</th>
</tr>
</thead>
<tbody>
{projects.map(project => (
<tr key={project.id}>
<td className={cellClass}>
<Link
href={adminProjectPath(project.id)}
className="font-display text-base font-semibold text-ink no-underline hover:text-signal"
>
{project.name}
</Link>
</td>
<td className={`${cellClass} font-mono text-micro uppercase tracking-mark`}>
{projectCode(project)}
</td>
<td className={cellClass}>{brandNames.get(project.brandId) ?? t('columns.brandMissing')}</td>
<td className={cellClass}>
<span className="flex items-center gap-2">
<span
aria-hidden="true"
style={projectStyle(project.color)}
className={`size-3 shrink-0 ${projectSurface}`}
/>
<span className="font-mono text-micro uppercase tracking-mark">{project.color}</span>
</span>
</td>
<td className={`${cellClass} font-mono tabular-nums`}>{counts.get(project.id) ?? 0}</td>
<td className={cellClass}>
<span className={project.isActive ? 'text-ink-2' : 'text-signal'}>
{project.isActive ? t('active') : t('inactive')}
</span>
</td>
<td className={cellClass}>
<Link href={adminProjectPath(project.id)} className={quietLinkClass}>
{t('columns.edit')}
</Link>
</td>
</tr>
))}
</tbody>
</table>
</Scroller>
)}
</div>
)
}
+107
View File
@@ -0,0 +1,107 @@
import type { Metadata } from 'next'
import Link from 'next/link'
import { notFound } from 'next/navigation'
import { getTranslations } from 'next-intl/server'
import { AdminHeading } from '~/components/admin/AdminHeading'
import { AdminNotice } from '~/components/admin/AdminNotice'
import { UserAdminToggle } from '~/components/admin/UserAdminToggle'
import { UserRoleRow } from '~/components/admin/UserRoleRow'
import { hintClass, labelClass, quietLinkClass } from '~/components/admin/styles'
import { SectionLabel } from '~/components/ui/SectionLabel'
import { listAllProjects } from '~/data/repositories/projects'
import { findUserById } from '~/data/repositories/users'
import { listAssignmentsForUser } from '~/data/repositories/user-roles'
import { projectCode } from '~/domain/project-code'
import { adminNewProjectPath, adminUsersPath } from '~/lib/admin-routes'
import { requireAdmin } from '~/lib/auth-guards'
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
const { id } = await params
const app = await getTranslations('app')
const t = await getTranslations('admin.users')
const user = await findUserById(id)
return { title: `${user?.name ?? t('title')} - ${app('name')}` }
}
export default async function AdminUserPage({ params }: { params: Promise<{ id: string }> }) {
const viewer = await requireAdmin()
const { id } = await params
const user = await findUserById(id)
if (!user) {
notFound()
}
const t = await getTranslations('admin.users')
const [projects, assignments] = await Promise.all([listAllProjects(), listAssignmentsForUser(user.id)])
const assigned = new Set(assignments.map(assignment => assignment.projectId))
return (
<div className="flex flex-col gap-10 pt-12">
<AdminHeading
eyebrow={t('eyebrow')}
title={user.name.trim() === '' ? user.email : user.name}
intro={user.email}
actions={
<Link href={adminUsersPath} className={quietLinkClass}>
{t('back')}
</Link>
}
/>
<section className="flex flex-col gap-4">
<SectionLabel>{t('adminSection')}</SectionLabel>
<p className="m-0 max-w-read text-base text-pretty text-ink-2">
{user.isAdmin ? t('isAdmin') : t('isNotAdmin')}
</p>
<UserAdminToggle userId={user.id} isAdmin={user.isAdmin} isSelf={user.id === viewer.id} />
</section>
<section className="flex flex-col gap-4">
<SectionLabel meta={<span>{t('assignedMeta', { count: assigned.size })}</span>}>
{t('rolesSection')}
</SectionLabel>
{user.isAdmin ? (
<AdminNotice>{t('adminHasAll')}</AdminNotice>
) : null}
{projects.length === 0 ? (
<AdminNotice
title={t('noProjectsTitle')}
action={
<Link href={adminNewProjectPath} className={quietLinkClass}>
{t('toProjects')}
</Link>
}
>
{t('noProjects')}
</AdminNotice>
) : (
<ul className="m-0 flex list-none flex-col p-0">
{projects.map(project => (
<UserRoleRow
key={project.id}
userId={user.id}
projectId={project.id}
projectName={project.name}
projectCode={projectCode(project)}
color={project.color}
assigned={assigned.has(project.id)}
implicit={user.isAdmin}
/>
))}
</ul>
)}
<span className={hintClass}>{t('rolesHint')}</span>
</section>
<section className="flex flex-col gap-2 border-t border-rule pt-6">
<span className={labelClass}>{t('accountSection')}</span>
<span className={hintClass}>{t('accountHint')}</span>
</section>
</div>
)
}
+86
View File
@@ -0,0 +1,86 @@
import type { Metadata } from 'next'
import Link from 'next/link'
import { getTranslations } from 'next-intl/server'
import { AdminHeading } from '~/components/admin/AdminHeading'
import { AdminNotice } from '~/components/admin/AdminNotice'
import { cellClass, headCellClass, quietLinkClass } from '~/components/admin/styles'
import { Scroller } from '~/components/ui/Scroller'
import { listUsers } from '~/data/repositories/users'
import { listAllAssignments } from '~/data/repositories/user-roles'
import { adminUserPath } from '~/lib/admin-routes'
import { requireAdmin } from '~/lib/auth-guards'
export async function generateMetadata(): Promise<Metadata> {
const app = await getTranslations('app')
const t = await getTranslations('admin.users')
return { title: `${t('title')} - ${app('name')}` }
}
export default async function AdminUsersPage() {
const viewer = await requireAdmin()
const t = await getTranslations('admin.users')
const [users, assignments] = await Promise.all([listUsers(), listAllAssignments()])
const counts = new Map<string, number>()
for (const assignment of assignments) {
counts.set(assignment.userId, (counts.get(assignment.userId) ?? 0) + 1)
}
return (
<div className="flex flex-col gap-8 pt-12">
<AdminHeading eyebrow={t('eyebrow')} title={t('title')} intro={t('intro')} />
{users.length === 0 ? (
<AdminNotice title={t('emptyTitle')}>{t('empty')}</AdminNotice>
) : (
<Scroller options={{ overflow: { y: 'hidden' } }}>
<table className="w-full min-w-2xl border-collapse text-left">
<thead>
<tr>
<th scope="col" className={headCellClass}>{t('columns.name')}</th>
<th scope="col" className={headCellClass}>{t('columns.email')}</th>
<th scope="col" className={headCellClass}>{t('columns.role')}</th>
<th scope="col" className={headCellClass}>{t('columns.projects')}</th>
<th scope="col" className={headCellClass}>
<span className="sr-only">{t('columns.edit')}</span>
</th>
</tr>
</thead>
<tbody>
{users.map(user => (
<tr key={user.id}>
<td className={cellClass}>
<Link
href={adminUserPath(user.id)}
className="font-display text-base font-semibold text-ink no-underline hover:text-signal"
>
{user.name.trim() === '' ? user.email : user.name}
</Link>
{user.id === viewer.id ? (
<span className="pl-3 font-mono text-micro uppercase tracking-mark text-ink-3">
{t('you')}
</span>
) : null}
</td>
<td className={`${cellClass} font-mono text-micro`}>{user.email}</td>
<td className={cellClass}>{user.isAdmin ? t('roles.admin') : t('roles.moderator')}</td>
<td className={`${cellClass} font-mono tabular-nums`}>
{user.isAdmin ? t('all') : counts.get(user.id) ?? 0}
</td>
<td className={cellClass}>
<Link href={adminUserPath(user.id)} className={quietLinkClass}>
{t('columns.edit')}
</Link>
</td>
</tr>
))}
</tbody>
</table>
</Scroller>
)}
<AdminNotice title={t('newAccountTitle')}>{t('newAccount')}</AdminNotice>
</div>
)
}
+4
View File
@@ -0,0 +1,4 @@
import { toNextJsHandler } from 'better-auth/next-js'
import { auth } from '~/lib/auth'
export const { GET, POST } = toNextJsHandler(auth)
@@ -0,0 +1,29 @@
import { problem } from '~/lib/problem'
import { getStorage, isSafeObjectPath } from '~/lib/storage'
export const dynamic = 'force-dynamic'
const cacheControl = 'public, max-age=31536000, immutable'
export async function GET(_request: Request, context: { params: Promise<{ path: string[] }> }) {
const { path } = await context.params
const key = (path ?? []).join('/')
if (!isSafeObjectPath(key)) {
return problem(400, 'invalid_path', 'Der Pfad zeigt aus dem Speicher heraus.')
}
const object = await getStorage().read(key)
if (!object) {
return problem(404, 'not_found')
}
return new Response(new Uint8Array(object.body), {
headers: {
'content-type': object.contentType,
'content-length': String(object.byteSize),
'cache-control': cacheControl,
},
})
}
+105
View File
@@ -0,0 +1,105 @@
import { findProjectBySlug } from '~/data/repositories/projects'
import { resolveClient } from '~/lib/api-auth'
import { maxUploadBytes, readUploadBody, uploadMedia, type UploadFailure } from '~/lib/media-upload'
import { mediaUrl } from '~/lib/media-url'
import { problem } from '~/lib/problem'
import type { Media } from '~/data/schema'
const failures: Record<UploadFailure, { status: number, type: string, detail: string }> = {
file_missing: { status: 400, type: 'file_missing', detail: 'Es wurde keine Datei im Feld file mitgeschickt.' },
file_too_large: { status: 413, type: 'file_too_large', detail: `Die Datei ist groesser als ${maxUploadBytes} Bytes.` },
unsupported_type: { status: 415, type: 'unsupported_type', detail: 'Dieses Dateiformat wird nicht angenommen.' },
unreadable_image: { status: 422, type: 'unreadable_image', detail: 'Die Datei laesst sich nicht als Bild lesen.' },
}
function body(item: Media) {
return {
id: item.id,
projectId: item.projectId,
kind: item.kind,
originalFilename: item.originalFilename,
mime: item.mime,
width: item.width,
height: item.height,
byteSize: item.byteSize,
alt: item.alt,
caption: item.caption,
url: mediaUrl(item.path),
variants: item.variants.map(variant => ({
width: variant.width,
format: variant.format,
url: mediaUrl(variant.path),
})),
variantError: item.variantError,
createdAt: item.createdAt,
}
}
export async function POST(request: Request) {
const client = await resolveClient(request)
if (!client) {
return problem(401, 'unauthorized')
}
if (client.mode !== 'write') {
return problem(403, 'forbidden', 'Dieses Token darf nur lesen.')
}
let form: FormData
try {
form = await request.formData()
} catch {
return problem(400, 'invalid_body', 'Der Aufruf muss multipart/form-data sein.')
}
const file = form.get('file')
if (!(file instanceof File) || file.size === 0) {
return problem(400, 'file_missing', 'Es wurde keine Datei im Feld file mitgeschickt.')
}
if (file.size > maxUploadBytes) {
return problem(413, 'file_too_large', `Die Datei ist groesser als ${maxUploadBytes} Bytes.`)
}
const requested = String(form.get('project') ?? '').trim()
let projectId = client.projectId ?? undefined
if (projectId === undefined) {
if (requested === '') {
return problem(400, 'project_missing', 'Ohne projektgebundenes Token muss project mitgeschickt werden.')
}
const project = await findProjectBySlug(requested)
if (!project) {
return problem(404, 'project_unknown')
}
projectId = project.id
} else if (requested !== '') {
const project = await findProjectBySlug(requested)
if (!project || project.id !== projectId) {
return problem(403, 'forbidden', 'Dieses Token gehoert zu einem anderen Projekt.')
}
}
const result = await uploadMedia({
projectId,
filename: file.name,
body: await readUploadBody(file),
alt: String(form.get('alt') ?? '').trim() || null,
caption: String(form.get('caption') ?? '').trim() || null,
})
if (!result.ok) {
const failure = failures[result.reason]
return problem(failure.status, failure.type, failure.detail)
}
return Response.json(body(result.media), { status: 201 })
}
+21
View File
@@ -0,0 +1,21 @@
import { resolveClient } from '~/lib/api-auth'
import { openApiDocument } from '~/lib/openapi'
import { problem } from '~/lib/problem'
export async function GET(request: Request) {
const client = await resolveClient(request)
if (!client) {
return problem(401, 'unauthorized')
}
const url = new URL(request.url)
const document = openApiDocument(process.env.BETTER_AUTH_URL ?? url.origin)
return new Response(JSON.stringify(document, null, '\t'), {
headers: {
'content-type': 'application/json; charset=utf-8',
'content-disposition': 'attachment; filename="logbuch-openapi.json"',
},
})
}
+23
View File
@@ -0,0 +1,23 @@
import { listActivePostTypes } from '~/data/repositories/post-types'
import { resolveClient } from '~/lib/api-auth'
import { problem } from '~/lib/problem'
export async function GET(request: Request) {
const client = await resolveClient(request)
if (!client) {
return problem(401, 'unauthorized')
}
const items = await listActivePostTypes()
return Response.json({
items: items.map(item => ({
key: item.key,
labelDe: item.labelDe,
labelEn: item.labelEn,
color: item.color,
sort: item.sort,
})),
})
}
@@ -0,0 +1,33 @@
import { z } from 'zod'
import { resolveClient } from '~/lib/api-auth'
import { apiSetBlocks } from '~/lib/api-entries'
import { entryProblem } from '~/lib/api-problem'
import { problem } from '~/lib/problem'
const idPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu
const bodySchema = z.object({ blocks: z.unknown() })
export async function PUT(request: Request, context: { params: Promise<{ slug: string }> }) {
const client = await resolveClient(request)
if (!client) {
return problem(401, 'unauthorized')
}
const { slug } = await context.params
if (!idPattern.test(slug)) {
return problem(400, 'id_required', 'Zum Setzen der Bloecke wird die Kennung des Beitrags gebraucht.')
}
const parsed = bodySchema.safeParse(await request.json().catch(() => null))
if (!parsed.success) {
return problem(400, 'invalid_body', 'blocks')
}
const result = await apiSetBlocks(client, slug, parsed.data.blocks)
return result.ok ? Response.json(result.value) : entryProblem(result.error)
}
+91
View File
@@ -0,0 +1,91 @@
import { z } from 'zod'
import { findPost } from '~/data/repositories/posts'
import { resolveClient } from '~/lib/api-auth'
import { apiDeleteEntry, apiReadEntry, apiUpdateEntry } from '~/lib/api-entries'
import { entryProblem } from '~/lib/api-problem'
import { problem } from '~/lib/problem'
const idPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu
const updateSchema = z.object({
title: z.string().min(1).optional(),
teaser: z.string().nullish(),
type: z.string().min(1).optional(),
audience: z.enum(['internal', 'customer', 'public']).optional(),
slug: z.string().nullish(),
cover_media_id: z.string().nullish(),
})
export async function GET(request: Request, context: { params: Promise<{ slug: string }> }) {
const client = await resolveClient(request)
if (!client) {
return problem(401, 'unauthorized')
}
const { slug } = await context.params
if (idPattern.test(slug)) {
const result = await apiReadEntry(client, slug)
return result.ok ? Response.json(result.value) : entryProblem(result.error)
}
const post = await findPost(slug, client.scope, client.projectId ?? undefined)
if (!post) {
return problem(404, 'post_not_found')
}
return Response.json(post)
}
export async function PATCH(request: Request, context: { params: Promise<{ slug: string }> }) {
const client = await resolveClient(request)
if (!client) {
return problem(401, 'unauthorized')
}
const { slug } = await context.params
if (!idPattern.test(slug)) {
return problem(400, 'id_required', 'Zum Aendern wird die Kennung des Beitrags gebraucht, nicht der Slug.')
}
const parsed = updateSchema.safeParse(await request.json().catch(() => null))
if (!parsed.success) {
return problem(400, 'invalid_body', parsed.error.issues.map(issue => issue.path.join('.')).join(', '))
}
const body = parsed.data
const result = await apiUpdateEntry(client, slug, {
title: body.title,
teaser: body.teaser === undefined ? undefined : body.teaser,
type: body.type,
audience: body.audience,
slug: body.slug === undefined ? undefined : body.slug,
coverMediaId: body.cover_media_id === undefined ? undefined : body.cover_media_id,
})
return result.ok ? Response.json(result.value) : entryProblem(result.error)
}
export async function DELETE(request: Request, context: { params: Promise<{ slug: string }> }) {
const client = await resolveClient(request)
if (!client) {
return problem(401, 'unauthorized')
}
const { slug } = await context.params
if (!idPattern.test(slug)) {
return problem(400, 'id_required', 'Zum Loeschen wird die Kennung des Beitrags gebraucht, nicht der Slug.')
}
const result = await apiDeleteEntry(client, slug)
return result.ok ? Response.json(result.value) : entryProblem(result.error)
}
+93
View File
@@ -0,0 +1,93 @@
import { z } from 'zod'
import { listPosts } from '~/data/repositories/posts'
import { isPostTypeKey } from '~/domain/post-type'
import { apiCreateEntry } from '~/lib/api-entries'
import { entryProblem } from '~/lib/api-problem'
import { resolveClient } from '~/lib/api-auth'
import { problem } from '~/lib/problem'
const querySchema = z.object({
project: z.string().min(1).optional(),
since: z.coerce.date().optional(),
type: z.string().trim().toLowerCase().refine(isPostTypeKey).optional(),
tag: z.string().min(1).optional(),
page: z.coerce.number().int().min(1).default(1),
per_page: z.coerce.number().int().min(1).max(100).default(25),
})
export async function GET(request: Request) {
const client = await resolveClient(request)
if (!client) {
return problem(401, 'unauthorized')
}
const url = new URL(request.url)
const parsed = querySchema.safeParse(Object.fromEntries(url.searchParams))
if (!parsed.success) {
return problem(400, 'invalid_query', parsed.error.issues.map(issue => issue.path.join('.')).join(', '))
}
const query = parsed.data
const result = await listPosts({
scope: client.scope,
projectSlug: query.project,
projectId: client.projectId ?? undefined,
since: query.since,
type: query.type,
tag: query.tag,
page: query.page,
perPage: query.per_page,
})
return Response.json({
items: result.items,
meta: { total: result.total, page: query.page, per_page: query.per_page },
})
}
const createSchema = z.object({
project: z.string().min(1),
title: z.string().min(1),
teaser: z.string().nullish(),
type: z.string().min(1),
audience: z.enum(['internal', 'customer', 'public']).optional(),
author_name: z.string().nullish(),
slug: z.string().nullish(),
blocks: z.unknown().optional(),
idempotency_key: z.string().min(1).max(200).nullish(),
})
export async function POST(request: Request) {
const client = await resolveClient(request)
if (!client) {
return problem(401, 'unauthorized')
}
const parsed = createSchema.safeParse(await request.json().catch(() => null))
if (!parsed.success) {
return problem(400, 'invalid_body', parsed.error.issues.map(issue => issue.path.join('.')).join(', '))
}
const body = parsed.data
const result = await apiCreateEntry(client, {
project: body.project,
title: body.title,
teaser: body.teaser ?? null,
type: body.type,
audience: body.audience,
authorName: body.author_name ?? null,
slug: body.slug ?? null,
blocks: body.blocks,
idempotencyKey: body.idempotency_key ?? null,
})
if (!result.ok) {
return entryProblem(result.error)
}
return Response.json(result.value, { status: 201 })
}
+16
View File
@@ -0,0 +1,16 @@
import { listProjects } from '~/data/repositories/projects'
import { resolveClient } from '~/lib/api-auth'
import { problem } from '~/lib/problem'
export async function GET(request: Request) {
const client = await resolveClient(request)
if (!client) {
return problem(401, 'unauthorized')
}
const all = await listProjects()
const items = client.projectId ? all.filter(project => project.id === client.projectId) : all
return Response.json({ items })
}
+32
View File
@@ -0,0 +1,32 @@
import { publishDueEntries } from '~/lib/publish-due'
import { problem } from '~/lib/problem'
function allowed(request: Request): boolean {
const secret = process.env.CRON_SECRET?.trim()
if (!secret) {
return false
}
const header = request.headers.get('authorization')
return header === `Bearer ${secret}`
}
export async function POST(request: Request) {
if (!allowed(request)) {
return problem(401, 'unauthorized')
}
const { published } = await publishDueEntries()
return Response.json({
published: published.map(entry => ({
id: entry.id,
number: entry.number,
slug: entry.slug,
title: entry.title,
})),
meta: { total: published.length },
})
}
+36
View File
@@ -0,0 +1,36 @@
import { z } from 'zod'
import { markRead } from '~/data/repositories/reads'
import { resolveClient } from '~/lib/api-auth'
import { problem } from '~/lib/problem'
const bodySchema = z.object({
external_user_id: z.string().min(1),
post_ids: z.array(z.uuid()).min(1).max(200),
})
export async function POST(request: Request) {
const client = await resolveClient(request)
if (!client) {
return problem(401, 'unauthorized')
}
if (!client.projectId) {
return problem(422, 'client_without_project')
}
const parsed = bodySchema.safeParse(await request.json().catch(() => null))
if (!parsed.success) {
return problem(400, 'invalid_body', parsed.error.issues.map(issue => issue.path.join('.')).join(', '))
}
const marked = await markRead({
projectId: client.projectId,
externalUserId: parsed.data.external_user_id,
scope: client.scope,
postIds: parsed.data.post_ids,
})
return Response.json({ marked })
}
+22
View File
@@ -0,0 +1,22 @@
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { resolveClient } from '~/lib/api-auth'
import { problem } from '~/lib/problem'
export async function GET(request: Request) {
const client = await resolveClient(request)
if (!client) {
return problem(401, 'unauthorized')
}
try {
const text = await readFile(join(process.cwd(), 'docs', 'style-guide.md'), 'utf8')
return new Response(text, {
headers: { 'content-type': 'text/markdown; charset=utf-8' },
})
} catch {
return problem(500, 'style_guide_missing', 'docs/style-guide.md fehlt.')
}
}
+33
View File
@@ -0,0 +1,33 @@
import { z } from 'zod'
import { listUnread } from '~/data/repositories/reads'
import { resolveClient } from '~/lib/api-auth'
import { problem } from '~/lib/problem'
const querySchema = z.object({ external_user_id: z.string().min(1) })
export async function GET(request: Request) {
const client = await resolveClient(request)
if (!client) {
return problem(401, 'unauthorized')
}
if (!client.projectId) {
return problem(422, 'client_without_project')
}
const url = new URL(request.url)
const parsed = querySchema.safeParse(Object.fromEntries(url.searchParams))
if (!parsed.success) {
return problem(400, 'invalid_query', 'external_user_id')
}
const items = await listUnread({
projectId: client.projectId,
externalUserId: parsed.data.external_user_id,
scope: client.scope,
})
return Response.json({ count: items.length, items })
}
+138
View File
@@ -0,0 +1,138 @@
@import "tailwindcss";
@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));
@theme {
--color-paper: #f2f1ec;
--color-surface: #fbfaf6;
--color-ink: #14161a;
--color-ink-2: #4c5157;
--color-ink-3: #8a8f95;
--color-rule: #dedcd3;
--color-signal: #c43a22;
--color-link: #2b4a9b;
--shadow-lift: 0 1.5rem 3rem -1.5rem rgb(20 22 26 / 0.18);
--color-positive: #2e7d5b;
--color-caution: #b4761a;
--color-shot-1: var(--color-rule);
--color-shot-2: var(--color-ink-3);
--container-page: 63rem;
--container-read: 38rem;
--font-display: var(--font-archivo), system-ui, sans-serif;
--font-body: var(--font-source-serif), Georgia, serif;
--font-mono: var(--font-jetbrains-mono), ui-monospace, monospace;
--text-micro: 0.6875rem;
--text-small: 0.8125rem;
--text-base: 1.0625rem;
--text-lead: 1.25rem;
--text-title: 1.75rem;
--text-hero: clamp(2.25rem, 4vw, 3.25rem);
--text-plate: clamp(2rem, 5vw, 3.5rem);
--text-mark: clamp(5rem, 13vw, 10rem);
--tracking-label: 0.28em;
--tracking-mark: 0.18em;
--tracking-plate: 0.42em;
--tracking-badge: 0.24em;
--tracking-chip: 0.14em;
--aspect-cover: 21 / 9;
--aspect-shot: 16 / 10;
--breakpoint-md: 46rem;
--animate-rise: rise 0.5s both cubic-bezier(0.22, 0.61, 0.36, 1);
}
@keyframes rise {
from { opacity: 0; transform: translateY(0.375rem); }
}
:root[data-theme="dark"] {
--shadow-lift: 0 1.5rem 3rem -1.5rem rgb(0 0 0 / 0.65);
--color-paper: #121417;
--color-surface: #191c20;
--color-ink: #edebe4;
--color-ink-2: #a9aeb4;
--color-ink-3: #757a80;
--color-rule: #2a2e34;
--color-signal: #e4603f;
--color-link: #8aa6ee;
--color-positive: #6dbd97;
--color-caution: #d9a34e;
color-scheme: dark;
}
:root[data-theme="light"] {
--color-paper: #f2f1ec;
--color-surface: #fbfaf6;
--color-ink: #14161a;
--color-ink-2: #4c5157;
--color-ink-3: #8a8f95;
--color-rule: #dedcd3;
--color-signal: #c43a22;
--color-link: #2b4a9b;
--color-positive: #2e7d5b;
--color-caution: #b4761a;
color-scheme: light;
}
@layer base {
html {
background: var(--color-paper);
}
body {
font-family: var(--font-body);
line-height: 1.6;
}
h1,
h2,
h3 {
font-family: var(--font-display);
font-weight: 600;
letter-spacing: -0.02em;
line-height: 1.2;
}
a {
color: var(--color-link);
text-decoration-thickness: 0.0625rem;
text-underline-offset: 0.15em;
}
:focus-visible {
outline: 0.125rem solid var(--color-signal);
outline-offset: 0.125rem;
}
::selection {
background: var(--color-signal);
color: var(--color-paper);
}
}
.plate-fill {
background: var(--plate);
}
:root[data-theme="dark"] .plate-fill {
background: color-mix(in srgb, var(--plate) 88%, var(--color-paper));
}
.os-theme-logbuch {
--os-size: 0.625rem;
--os-padding-perpendicular: 0.125rem;
--os-padding-axis: 0.125rem;
--os-track-border-radius: 1rem;
--os-track-bg: transparent;
--os-track-bg-hover: transparent;
--os-track-bg-active: transparent;
--os-handle-border-radius: 1rem;
--os-handle-min-size: 2rem;
--os-handle-interactive-area-offset: 0.25rem;
--os-handle-bg: color-mix(in srgb, var(--color-ink-3) 50%, transparent);
--os-handle-bg-hover: color-mix(in srgb, var(--color-ink-3) 75%, transparent);
--os-handle-bg-active: var(--color-ink-2);
}
.shadow-lift {
box-shadow: var(--shadow-lift);
}
+31
View File
@@ -0,0 +1,31 @@
import './globals.css'
import type { Metadata } from 'next'
import type { ReactNode } from 'react'
import { NextIntlClientProvider } from 'next-intl'
import { getLocale, getTranslations } from 'next-intl/server'
import { fontVariables } from '~/lib/fonts'
import { themeInitScript } from '~/components/ui/theme'
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations('app')
return {
title: t('name'),
description: t('tagline'),
}
}
export default async function RootLayout({ children }: { children: ReactNode }) {
const locale = await getLocale()
return (
<html lang={locale} className={fontVariables} suppressHydrationWarning>
<head>
<script dangerouslySetInnerHTML={{ __html: themeInitScript }} />
</head>
<body className="h-dvh overflow-hidden bg-paper font-body text-base text-ink antialiased">
<NextIntlClientProvider>{children}</NextIntlClientProvider>
</body>
</html>
)
}
+76
View File
@@ -0,0 +1,76 @@
import type { Metadata, Route } from 'next'
import Link from 'next/link'
import { redirect } from 'next/navigation'
import { getTranslations } from 'next-intl/server'
import { AdminNotice } from '~/components/admin/AdminNotice'
import { LoginForm } from '~/components/admin/LoginForm'
import { MagicLinkForm } from '~/components/admin/MagicLinkForm'
import { Scroller } from '~/components/ui/Scroller'
import { magicLinkMinutes } from '~/domain/magic-link'
import { readViewer } from '~/lib/auth-guards'
import { internalTarget, overviewTarget } from '~/lib/auth-routes'
import { readText, type SearchParams } from '~/lib/query'
import { overviewPath } from '~/lib/routes'
export async function generateMetadata(): Promise<Metadata> {
const app = await getTranslations('app')
const t = await getTranslations('login')
return { title: `${t('title')} - ${app('name')}` }
}
export default async function LoginPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
const params = await searchParams
const target = internalTarget(readText(params.next), overviewTarget)
const viewer = await readViewer()
if (viewer) {
redirect(target as Route)
}
const t = await getTranslations('login')
const next = target === overviewTarget ? undefined : target
const failed = readText(params.error) !== undefined
return (
<main id="content" className="h-dvh w-full">
<Scroller className="h-full" options={{ overflow: { x: 'hidden' } }}>
<div className="flex min-h-dvh w-full items-center justify-center px-6 py-12">
<div className="flex w-full max-w-sm flex-col gap-8">
<div className="flex flex-col gap-4">
<span className="font-mono text-micro font-semibold uppercase tracking-label text-ink-3">
{t('eyebrow')}
</span>
<h1 className="text-title font-bold text-balance">{t('title')}</h1>
<p className="m-0 text-base text-pretty text-ink-2">{t('intro')}</p>
</div>
{failed ? (
<AdminNotice tone="alert">
{t('magic.linkFailed', { minutes: magicLinkMinutes })}
</AdminNotice>
) : null}
<MagicLinkForm next={next} />
<details className="border-t border-rule pt-6">
<summary className="cursor-pointer font-mono text-micro font-semibold uppercase tracking-label text-ink-2 hover:text-signal">
{t('magic.passwordTitle')}
</summary>
<div className="pt-6">
<LoginForm next={next} />
</div>
</details>
<div className="flex flex-col gap-3 border-t border-rule pt-6">
<p className="m-0 text-small text-pretty text-ink-3">{t('noAccount')}</p>
<Link href={overviewPath()} className="font-mono text-small">
{t('back')}
</Link>
</div>
</div>
</div>
</Scroller>
</main>
)
}
+53
View File
@@ -0,0 +1,53 @@
import Link from 'next/link'
import { useTranslations } from 'next-intl'
import { EmptyNotice } from '~/components/archive/EmptyNotice'
import { ViewHeader } from '~/components/archive/ViewHeader'
import { Wrap } from '~/components/layout/Wrap'
import { Watermark } from '~/components/ui/Watermark'
import { overviewPath, searchPath } from '~/lib/routes'
const code = 404
export default function NotFoundPage() {
const t = useTranslations('notFound')
const nav = useTranslations('nav')
return (
<main id="content">
<Wrap>
<div className="relative overflow-hidden">
<Watermark number={code} className="-right-2 -top-4" />
<div className="relative">
<ViewHeader
title={t('title')}
intro={t('hint')}
eyebrow={
<span className="font-semibold tracking-plate text-signal tabular-nums">
{t('code')}
</span>
}
/>
</div>
</div>
<div className="pb-14">
<EmptyNotice
title={t('nextTitle')}
action={
<span className="flex flex-wrap items-center gap-x-4 gap-y-2">
<Link href={overviewPath()} className="font-mono text-small">
{t('home')}
</Link>
<Link href={searchPath()} className="font-mono text-small">
{nav('search')}
</Link>
</span>
}
>
{t('next')}
</EmptyNotice>
</div>
</Wrap>
</main>
)
}
+43
View File
@@ -0,0 +1,43 @@
'use client'
import type { ReactNode } from 'react'
import { AdminField } from './AdminField'
import { inputClass } from './styles'
import { isColor } from '~/domain/color'
export type AdminColorFieldProps = {
id: string
name: string
label: ReactNode
pickerLabel: string
value: string
onChange: (value: string) => void
hint?: ReactNode
error?: ReactNode
}
export function AdminColorField({ id, name, label, pickerLabel, value, onChange, hint, error }: AdminColorFieldProps) {
return (
<AdminField id={id} label={label} hint={hint} error={error}>
<div className="flex items-center gap-3">
<input
id={`${id}-picker`}
type="color"
aria-label={pickerLabel}
value={isColor(value) ? value : '#000000'}
onChange={event => onChange(event.target.value)}
className="h-10 w-12 shrink-0 cursor-pointer border border-rule bg-surface p-1"
/>
<input
id={id}
name={name}
value={value}
spellCheck={false}
autoComplete="off"
onChange={event => onChange(event.target.value)}
className={`${inputClass} font-mono`}
/>
</div>
</AdminField>
)
}
+28
View File
@@ -0,0 +1,28 @@
import type { ReactNode } from 'react'
import { errorClass, hintClass, labelClass } from './styles'
export type AdminFieldProps = {
id: string
label: ReactNode
children: ReactNode
hint?: ReactNode
error?: ReactNode
className?: string
}
export function AdminField({ id, label, children, hint, error, className }: AdminFieldProps) {
return (
<div className={`flex min-w-0 flex-col gap-2 ${className ?? ''}`}>
<label htmlFor={id} className={labelClass}>
{label}
</label>
{children}
{hint ? <span className={hintClass}>{hint}</span> : null}
{error ? (
<span role="alert" className={errorClass}>
{error}
</span>
) : null}
</div>
)
}
+26
View File
@@ -0,0 +1,26 @@
'use client'
import { useTranslations } from 'next-intl'
import { AdminNotice } from './AdminNotice'
import type { AdminFormState } from '~/lib/admin-forms'
export type AdminFormMessageProps = {
state: AdminFormState
silentOnDone?: boolean
}
export function AdminFormMessage({ state, silentOnDone = false }: AdminFormMessageProps) {
const t = useTranslations('admin.messages')
if (state.status === 'idle' || !state.message) {
return null
}
if (state.status === 'ok' && silentOnDone) {
return null
}
return (
<AdminNotice tone={state.status === 'error' ? 'alert' : 'done'}>{t(state.message)}</AdminNotice>
)
}
+25
View File
@@ -0,0 +1,25 @@
import type { ReactNode } from 'react'
export type AdminHeadingProps = {
eyebrow?: ReactNode
title: ReactNode
intro?: ReactNode
actions?: ReactNode
}
export function AdminHeading({ eyebrow, title, intro, actions }: AdminHeadingProps) {
return (
<div className="flex flex-wrap items-end justify-between gap-x-8 gap-y-4">
<div className="flex min-w-0 flex-col gap-3">
{eyebrow ? (
<span className="font-mono text-micro font-semibold uppercase tracking-label text-ink-3">
{eyebrow}
</span>
) : null}
<h1 className="text-title font-bold text-balance">{title}</h1>
{intro ? <p className="m-0 max-w-read text-base text-pretty text-ink-2">{intro}</p> : null}
</div>
{actions ? <div className="flex flex-wrap items-center gap-3">{actions}</div> : null}
</div>
)
}
+51
View File
@@ -0,0 +1,51 @@
import type { ReactNode } from 'react'
import { TbAlertTriangle, TbCircleCheck, TbInfoCircle } from 'react-icons/tb'
export type AdminNoticeTone = 'info' | 'alert' | 'done'
export type AdminNoticeProps = {
children: ReactNode
title?: ReactNode
action?: ReactNode
tone?: AdminNoticeTone
}
const tones: Record<AdminNoticeTone, string> = {
info: 'border-rule',
alert: 'border-signal',
done: 'border-positive',
}
const icons: Record<AdminNoticeTone, string> = {
info: 'text-ink-3',
alert: 'text-signal',
done: 'text-positive',
}
const glyphs = {
info: TbInfoCircle,
alert: TbAlertTriangle,
done: TbCircleCheck,
}
export function AdminNotice({ children, title, action, tone = 'info' }: AdminNoticeProps) {
const Icon = glyphs[tone]
return (
<div
role={tone === 'alert' ? 'alert' : undefined}
className={`flex gap-3 border-l-2 bg-surface px-5 py-4 ${tones[tone]}`}
>
<Icon aria-hidden="true" className={`mt-1 size-4 shrink-0 ${icons[tone]}`} />
<div className="flex flex-col gap-2">
{title ? (
<span className="font-display text-micro font-semibold uppercase tracking-label text-ink-3">
{title}
</span>
) : null}
<p className="m-0 max-w-read text-base text-pretty text-ink-2">{children}</p>
{action ? <span className="font-mono text-small text-ink-2">{action}</span> : null}
</div>
</div>
)
}
+32
View File
@@ -0,0 +1,32 @@
'use client'
import type { ReactNode } from 'react'
import { useFormStatus } from 'react-dom'
import { ghostButtonClass, primaryButtonClass } from './styles'
export type AdminSubmitProps = {
children: ReactNode
pendingLabel: ReactNode
icon?: ReactNode
quiet?: boolean
disabled?: boolean
name?: string
value?: string
}
export function AdminSubmit({ children, pendingLabel, icon, quiet = false, disabled = false, name, value }: AdminSubmitProps) {
const { pending } = useFormStatus()
return (
<button
type="submit"
name={name}
value={value}
disabled={pending || disabled}
className={quiet ? ghostButtonClass : primaryButtonClass}
>
{icon ? <span aria-hidden="true" className="flex shrink-0">{icon}</span> : null}
{pending ? pendingLabel : children}
</button>
)
}
+33
View File
@@ -0,0 +1,33 @@
import type { ReactNode } from 'react'
import { Scroller } from '~/components/ui/Scroller'
export type ApiEndpointProps = {
method: string
path: string
summary: string
auth: string
example: string
children?: ReactNode
}
export function ApiEndpoint({ method, path, summary, auth, example, children }: ApiEndpointProps) {
return (
<article className="flex flex-col gap-3 border-t border-rule pt-5">
<div className="flex flex-wrap items-baseline gap-3">
<span className="border border-ink px-2 py-0.5 font-mono text-micro font-semibold uppercase tracking-label text-ink">
{method}
</span>
<code className="font-mono text-small text-ink">{path}</code>
<span className="ml-auto font-mono text-micro uppercase tracking-mark text-ink-3">{auth}</span>
</div>
<p className="m-0 max-w-read text-pretty text-ink-2">{summary}</p>
{children}
<Scroller options={{ overflow: { y: 'hidden' } }}>
<pre className="m-0 w-max min-w-full bg-surface p-4 font-mono text-small text-ink-2">{example}</pre>
</Scroller>
</article>
)
}
+121
View File
@@ -0,0 +1,121 @@
'use client'
import { useActionState, useState } from 'react'
import Link from 'next/link'
import { useTranslations } from 'next-intl'
import { TbDeviceFloppy } from 'react-icons/tb'
import { AdminColorField } from './AdminColorField'
import { AdminField } from './AdminField'
import { AdminFormMessage } from './AdminFormMessage'
import { AdminSubmit } from './AdminSubmit'
import { inputClass, quietLinkClass } from './styles'
import { saveBrand } from '~/lib/admin-actions'
import { emptyAdminFormState } from '~/lib/admin-forms'
import { adminBrandsPath } from '~/lib/admin-routes'
import { defaultColor } from '~/domain/color'
import { slugify } from '~/domain/slug'
export type BrandFormValues = {
id: string
name: string
slug: string
color: string
sort: number
}
export type BrandFormProps = {
brand?: BrandFormValues
}
export function BrandForm({ brand }: BrandFormProps) {
const t = useTranslations('admin.brands')
const errors = useTranslations('admin.errors')
const [state, formAction] = useActionState(saveBrand, emptyAdminFormState)
const [name, setName] = useState(brand?.name ?? '')
const [slug, setSlug] = useState(brand?.slug ?? '')
const [slugTouched, setSlugTouched] = useState((brand?.slug ?? '') !== '')
const [color, setColor] = useState(brand?.color ?? defaultColor)
const [sort, setSort] = useState(String(brand?.sort ?? 0))
function fieldError(field: string): string | undefined {
const key = state.fields?.[field]
return key ? errors(key) : undefined
}
return (
<form action={formAction} className="flex flex-col gap-8">
{brand ? <input type="hidden" name="id" value={brand.id} /> : null}
<AdminFormMessage state={state} />
<div className="grid gap-6 md:grid-cols-2">
<AdminField id="name" label={t('fields.name')} error={fieldError('name')}>
<input
id="name"
name="name"
value={name}
required
autoComplete="off"
onChange={event => {
setName(event.target.value)
if (!slugTouched) {
setSlug(slugify(event.target.value))
}
}}
className={inputClass}
/>
</AdminField>
<AdminField id="slug" label={t('fields.slug')} hint={t('hints.slug')} error={fieldError('slug')}>
<input
id="slug"
name="slug"
value={slug}
spellCheck={false}
autoComplete="off"
onChange={event => {
setSlugTouched(true)
setSlug(event.target.value)
}}
className={`${inputClass} font-mono`}
/>
</AdminField>
<AdminColorField
id="color"
name="color"
label={t('fields.color')}
pickerLabel={t('fields.colorPicker')}
value={color}
onChange={setColor}
hint={t('hints.color')}
error={fieldError('color')}
/>
<AdminField id="sort" label={t('fields.sort')} hint={t('hints.sort')} error={fieldError('sort')}>
<input
id="sort"
name="sort"
type="number"
min={0}
max={9999}
step={1}
value={sort}
onChange={event => setSort(event.target.value)}
className={`${inputClass} font-mono tabular-nums`}
/>
</AdminField>
</div>
<div className="flex flex-wrap items-center gap-6">
<AdminSubmit pendingLabel={t('saving')} icon={<TbDeviceFloppy className="size-4" />}>
{brand ? t('save') : t('create')}
</AdminSubmit>
<Link href={adminBrandsPath} className={quietLinkClass}>
{t('back')}
</Link>
</div>
</form>
)
}
+149
View File
@@ -0,0 +1,149 @@
'use client'
import { useActionState, useEffect, useState } from 'react'
import { useTranslations } from 'next-intl'
import { TbCopy, TbKey } from 'react-icons/tb'
import { AdminField } from './AdminField'
import { AdminFormMessage } from './AdminFormMessage'
import { AdminSubmit } from './AdminSubmit'
import { ghostButtonClass, hintClass, inputClass, labelClass, selectClass } from './styles'
import { createApiClient } from '~/lib/admin-actions'
import { emptyAdminFormState } from '~/lib/admin-forms'
export type ClientFormProps = {
projects: { id: string, name: string }[]
}
const modes = ['read', 'write'] as const
const scopes = ['internal', 'customer', 'public'] as const
export function ClientForm({ projects }: ClientFormProps) {
const t = useTranslations('admin.clients')
const errors = useTranslations('admin.errors')
const [state, formAction] = useActionState(createApiClient, emptyAdminFormState)
const [name, setName] = useState('')
const [mode, setMode] = useState<string>('read')
const [scope, setScope] = useState<string>('customer')
const [projectId, setProjectId] = useState('')
const [copied, setCopied] = useState(false)
useEffect(() => {
if (state.status === 'ok' && state.token) {
setName('')
setCopied(false)
}
}, [state.status, state.token])
function fieldError(field: string): string | undefined {
const key = state.fields?.[field]
return key ? errors(key) : undefined
}
async function copyToken(token: string) {
try {
await navigator.clipboard.writeText(token)
setCopied(true)
} catch {
setCopied(false)
}
}
return (
<div className="flex flex-col gap-8">
{state.token ? (
<div className="flex flex-col gap-3 border-l-2 border-signal bg-surface px-5 py-4">
<span className="font-display text-micro font-semibold uppercase tracking-label text-ink-3">
{t('tokenTitle')}
</span>
<code className="block break-all font-mono text-base text-ink">{state.token}</code>
<p className="m-0 max-w-read text-small text-pretty text-ink-2">{t('tokenHint')}</p>
<span className="flex flex-wrap items-center gap-3">
<button type="button" onClick={() => copyToken(state.token ?? '')} className={ghostButtonClass}>
<TbCopy aria-hidden="true" className="size-4 shrink-0" />
{copied ? t('copied') : t('copy')}
</button>
</span>
</div>
) : null}
<form action={formAction} className="flex flex-col gap-8">
<AdminFormMessage state={state} silentOnDone />
<div className="grid gap-6 md:grid-cols-2">
<AdminField id="name" label={t('fields.name')} hint={t('hints.name')} error={fieldError('name')}>
<input
id="name"
name="name"
value={name}
required
autoComplete="off"
onChange={event => setName(event.target.value)}
className={inputClass}
/>
</AdminField>
<AdminField id="projectId" label={t('fields.project')} hint={t('hints.project')} error={fieldError('projectId')}>
<select
id="projectId"
name="projectId"
value={projectId}
onChange={event => setProjectId(event.target.value)}
className={selectClass}
>
<option value="">{t('allProjects')}</option>
{projects.map(project => (
<option key={project.id} value={project.id}>
{project.name}
</option>
))}
</select>
</AdminField>
<AdminField id="mode" label={t('fields.mode')} hint={t('hints.mode')} error={fieldError('mode')}>
<select
id="mode"
name="mode"
value={mode}
onChange={event => setMode(event.target.value)}
className={selectClass}
>
{modes.map(value => (
<option key={value} value={value}>
{t(`modes.${value}`)}
</option>
))}
</select>
</AdminField>
<AdminField id="scope" label={t('fields.scope')} hint={t('hints.scope')} error={fieldError('scope')}>
<select
id="scope"
name="scope"
value={scope}
onChange={event => setScope(event.target.value)}
className={selectClass}
>
{scopes.map(value => (
<option key={value} value={value}>
{t(`scopes.${value}`)}
</option>
))}
</select>
</AdminField>
</div>
<div className="flex flex-col gap-3 border-t border-rule pt-6">
<span className={labelClass}>{t('publishTitle')}</span>
<span className={hintClass}>{t('publishHint')}</span>
</div>
<div className="flex flex-wrap items-center gap-6">
<AdminSubmit pendingLabel={t('creating')} icon={<TbKey className="size-4" />}>
{t('create')}
</AdminSubmit>
</div>
</form>
</div>
)
}
@@ -0,0 +1,33 @@
'use client'
import { useActionState } from 'react'
import { useTranslations } from 'next-intl'
import { TbBan } from 'react-icons/tb'
import { AdminSubmit } from './AdminSubmit'
import { errorClass } from './styles'
import { revokeApiClient } from '~/lib/admin-actions'
import { emptyAdminFormState } from '~/lib/admin-forms'
export type ClientRevokeButtonProps = {
id: string
}
export function ClientRevokeButton({ id }: ClientRevokeButtonProps) {
const t = useTranslations('admin.clients')
const messages = useTranslations('admin.messages')
const [state, formAction] = useActionState(revokeApiClient, emptyAdminFormState)
return (
<form action={formAction} className="flex flex-col gap-2">
<input type="hidden" name="id" value={id} />
<AdminSubmit quiet pendingLabel={t('revoking')} icon={<TbBan className="size-4" />}>
{t('revoke')}
</AdminSubmit>
{state.status === 'error' && state.message ? (
<span role="alert" className={errorClass}>
{messages(state.message)}
</span>
) : null}
</form>
)
}
+350
View File
@@ -0,0 +1,350 @@
'use client'
import { useId } from 'react'
import { useTranslations } from 'next-intl'
import { TbArrowDown, TbArrowUp, TbGripVertical, TbTrash } from 'react-icons/tb'
import { AdminField } from './AdminField'
import { EntryMediaPicker } from './EntryMediaPicker'
import { inputClass, selectClass, textareaClass } from './styles'
import type { BlockType, BlockValues } from '~/domain/blocks'
import type { EntryMedia } from '~/lib/entry-types'
export type EntryBlockCardProps = {
type: BlockType
data: BlockValues
index: number
total: number
media: EntryMedia[]
accept: string
dragging: boolean
onChange: (data: BlockValues) => void
onRemove: () => void
onMove: (to: number) => void
onDragStart: () => void
onDragEnd: () => void
onDropOn: () => void
upload: (files: File[]) => Promise<string[]>
}
function text(data: BlockValues, key: string): string {
const value = data[key]
return typeof value === 'string' ? value : ''
}
function list(data: BlockValues, key: string): string[] {
const value = data[key]
return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : []
}
export function EntryBlockCard({
type,
data,
index,
total,
media,
accept,
dragging,
onChange,
onRemove,
onMove,
onDragStart,
onDragEnd,
onDropOn,
upload,
}: EntryBlockCardProps) {
const t = useTranslations('admin.entries')
const id = useId()
function set(key: string, value: unknown) {
onChange({ ...data, [key]: value })
}
function pick(key: string, ids: string[]) {
set(key, ids[0] ?? '')
}
async function uploadInto(key: string, files: File[]) {
const ids = await upload(files)
if (ids.length > 0) {
set(key, ids[0])
}
}
async function uploadIntoList(key: string, files: File[]) {
const ids = await upload(files)
if (ids.length > 0) {
set(key, [...list(data, key), ...ids])
}
}
return (
<li
onDragOver={event => event.preventDefault()}
onDrop={event => {
if (event.dataTransfer.files.length > 0) {
return
}
event.preventDefault()
event.stopPropagation()
onDropOn()
}}
className={`flex flex-col gap-4 border bg-surface px-5 py-4 ${dragging ? 'border-signal' : 'border-rule'}`}
>
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
<span
draggable
onDragStart={onDragStart}
onDragEnd={onDragEnd}
aria-label={t('actions.drag')}
className="flex cursor-grab items-center text-ink-3 hover:text-ink"
>
<TbGripVertical aria-hidden="true" className="size-4" />
</span>
<span className="font-mono text-micro font-semibold uppercase tracking-label text-ink-2">
{t(`blocks.${type}`)}
</span>
<span aria-hidden="true" className="h-px min-w-6 flex-1 bg-rule" />
<button
type="button"
disabled={index === 0}
onClick={() => onMove(index - 1)}
aria-label={t('actions.up')}
className="cursor-pointer border-0 bg-transparent p-0 text-ink-3 hover:text-ink disabled:cursor-not-allowed disabled:opacity-40"
>
<TbArrowUp aria-hidden="true" className="size-4" />
</button>
<button
type="button"
disabled={index === total - 1}
onClick={() => onMove(index + 1)}
aria-label={t('actions.down')}
className="cursor-pointer border-0 bg-transparent p-0 text-ink-3 hover:text-ink disabled:cursor-not-allowed disabled:opacity-40"
>
<TbArrowDown aria-hidden="true" className="size-4" />
</button>
<button
type="button"
onClick={onRemove}
aria-label={t('actions.remove')}
className="cursor-pointer border-0 bg-transparent p-0 text-ink-3 hover:text-signal"
>
<TbTrash aria-hidden="true" className="size-4" />
</button>
</div>
{type === 'text' ? (
<AdminField id={`${id}-text`} label={t('blockFields.text')}>
<textarea
id={`${id}-text`}
rows={6}
value={text(data, 'text')}
placeholder={t('blockFields.textPlaceholder')}
onChange={event => set('text', event.target.value)}
className={textareaClass}
/>
</AdminField>
) : null}
{type === 'image' ? (
<EntryMediaPicker
label={t('blockFields.image')}
media={media}
selected={text(data, 'mediaId') === '' ? [] : [text(data, 'mediaId')]}
accept={accept}
onSelect={ids => pick('mediaId', ids)}
onUpload={files => void uploadInto('mediaId', files)}
/>
) : null}
{type === 'gallery' ? (
<EntryMediaPicker
label={t('blockFields.gallery')}
media={media}
selected={list(data, 'mediaIds')}
multiple
accept={accept}
onSelect={ids => set('mediaIds', ids)}
onUpload={files => void uploadIntoList('mediaIds', files)}
hint={t('blockFields.galleryHint')}
/>
) : null}
{type === 'before_after' ? (
<div className="grid gap-5 md:grid-cols-2">
<EntryMediaPicker
label={t('blockFields.before')}
media={media}
selected={text(data, 'beforeMediaId') === '' ? [] : [text(data, 'beforeMediaId')]}
accept={accept}
onSelect={ids => pick('beforeMediaId', ids)}
onUpload={files => void uploadInto('beforeMediaId', files)}
/>
<EntryMediaPicker
label={t('blockFields.after')}
media={media}
selected={text(data, 'afterMediaId') === '' ? [] : [text(data, 'afterMediaId')]}
accept={accept}
onSelect={ids => pick('afterMediaId', ids)}
onUpload={files => void uploadInto('afterMediaId', files)}
/>
</div>
) : null}
{type === 'video' ? (
<div className="flex flex-col gap-5">
<EntryMediaPicker
label={t('blockFields.video')}
media={media}
selected={text(data, 'mediaId') === '' ? [] : [text(data, 'mediaId')]}
accept={accept}
onSelect={ids => pick('mediaId', ids)}
onUpload={files => void uploadInto('mediaId', files)}
hint={t('blockFields.videoHint')}
/>
<div className="grid gap-5 md:grid-cols-2">
<AdminField id={`${id}-url`} label={t('blockFields.url')}>
<input
id={`${id}-url`}
type="url"
value={text(data, 'url')}
onChange={event => set('url', event.target.value)}
className={`${inputClass} font-mono`}
/>
</AdminField>
<AdminField id={`${id}-title`} label={t('blockFields.label')}>
<input
id={`${id}-title`}
type="text"
value={text(data, 'title')}
onChange={event => set('title', event.target.value)}
className={inputClass}
/>
</AdminField>
</div>
</div>
) : null}
{type === 'quote' ? (
<div className="flex flex-col gap-5">
<AdminField id={`${id}-quote`} label={t('blockFields.quote')}>
<textarea
id={`${id}-quote`}
rows={3}
value={text(data, 'text')}
onChange={event => set('text', event.target.value)}
className={textareaClass}
/>
</AdminField>
<AdminField id={`${id}-source`} label={t('blockFields.source')}>
<input
id={`${id}-source`}
type="text"
value={text(data, 'source')}
onChange={event => set('source', event.target.value)}
className={inputClass}
/>
</AdminField>
</div>
) : null}
{type === 'code' ? (
<div className="flex flex-col gap-5">
<AdminField id={`${id}-language`} label={t('blockFields.language')}>
<input
id={`${id}-language`}
type="text"
value={text(data, 'language')}
onChange={event => set('language', event.target.value)}
className={`${inputClass} font-mono`}
/>
</AdminField>
<AdminField id={`${id}-code`} label={t('blockFields.code')}>
<textarea
id={`${id}-code`}
rows={8}
spellCheck={false}
value={text(data, 'code')}
onChange={event => set('code', event.target.value)}
className={`${textareaClass} font-mono text-small`}
/>
</AdminField>
</div>
) : null}
{type === 'link' ? (
<div className="flex flex-col gap-5">
<div className="grid gap-5 md:grid-cols-2">
<AdminField id={`${id}-linkurl`} label={t('blockFields.url')}>
<input
id={`${id}-linkurl`}
type="url"
value={text(data, 'url')}
onChange={event => set('url', event.target.value)}
className={`${inputClass} font-mono`}
/>
</AdminField>
<AdminField id={`${id}-linklabel`} label={t('blockFields.label')}>
<input
id={`${id}-linklabel`}
type="text"
value={text(data, 'label')}
onChange={event => set('label', event.target.value)}
className={inputClass}
/>
</AdminField>
</div>
<AdminField id={`${id}-linkdescription`} label={t('blockFields.description')}>
<input
id={`${id}-linkdescription`}
type="text"
value={text(data, 'description')}
onChange={event => set('description', event.target.value)}
className={inputClass}
/>
</AdminField>
</div>
) : null}
{type === 'callout' ? (
<div className="flex flex-col gap-5">
<div className="grid gap-5 md:grid-cols-2">
<AdminField id={`${id}-callouttitle`} label={t('blockFields.label')}>
<input
id={`${id}-callouttitle`}
type="text"
value={text(data, 'title')}
onChange={event => set('title', event.target.value)}
className={inputClass}
/>
</AdminField>
<AdminField id={`${id}-tone`} label={t('blockFields.tone')}>
<select
id={`${id}-tone`}
value={text(data, 'tone') === 'warning' ? 'warning' : 'info'}
onChange={event => set('tone', event.target.value)}
className={selectClass}
>
<option value="info">{t('blockFields.toneInfo')}</option>
<option value="warning">{t('blockFields.toneWarning')}</option>
</select>
</AdminField>
</div>
<AdminField id={`${id}-callouttext`} label={t('blockFields.text')}>
<textarea
id={`${id}-callouttext`}
rows={3}
value={text(data, 'text')}
onChange={event => set('text', event.target.value)}
className={textareaClass}
/>
</AdminField>
</div>
) : null}
</li>
)
}
+814
View File
@@ -0,0 +1,814 @@
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import Link from 'next/link'
import { useTranslations } from 'next-intl'
import {
TbArchive,
TbCalendarClock,
TbCalendarX,
TbDeviceFloppy,
TbEye,
TbPencil,
TbPhotoPlus,
TbPlus,
TbRotate2,
TbSend,
TbX,
} from 'react-icons/tb'
import { AdminField } from './AdminField'
import { AdminNotice } from './AdminNotice'
import { EntryBlockCard } from './EntryBlockCard'
import { EntryMediaPicker } from './EntryMediaPicker'
import { EntryPreview } from './EntryPreview'
import { EntryStatusChip } from './EntryStatusChip'
import {
ghostButtonClass,
hintClass,
inputClass,
labelClass,
metaClass,
primaryButtonClass,
quietLinkClass,
selectClass,
textareaClass,
} from './styles'
import { blockTypes, emptyBlockData, isBlockEmpty, type BlockType, type BlockValues } from '~/domain/blocks'
import { checkPublish } from '~/domain/publish-checks'
import { defaultPostTypeColor } from '~/domain/post-type'
import { slugify } from '~/domain/slug'
import type { Audience, PostStatus } from '~/domain/types'
import { adminEntriesPath, adminEntryPath } from '~/lib/admin-routes'
import {
archiveEntry,
loadEntryMedia,
publishEntry,
resumeEntry,
saveEntry,
scheduleEntry,
unscheduleEntry,
uploadEntryImage,
} from '~/lib/entry-actions'
import type { EntryErrorKey, EntryInput, EntryMedia, EntryResult, EntryState, EntryTypeOption } from '~/lib/entry-types'
export type EntryEditorProject = {
id: string
slug: string
name: string
code: string
color: string
}
export type EntryEditorEntry = {
id: string
number: number
slug: string
status: PostStatus
publishAt: string | null
updatedLabel: string
projectId: string
title: string
teaser: string
typeId: string
audience: Audience
coverMediaId: string | null
blocks: { type: BlockType, data: BlockValues }[]
}
export type EntryEditorProps = {
projects: EntryEditorProject[]
types: EntryTypeOption[]
media: EntryMedia[]
accept: string
defaultProjectId: string
entry?: EntryEditorEntry
}
type EditorBlock = {
key: string
type: BlockType
data: BlockValues
}
type Form = {
projectId: string
title: string
teaser: string
slug: string
typeId: string
audience: Audience
cover: string | null
blocks: EditorBlock[]
}
type Upload = {
key: string
name: string
status: 'pending' | 'done' | 'failed'
reason?: string
}
const audiences: Audience[] = ['internal', 'customer', 'public']
let counter = 0
function nextKey(): string {
counter += 1
return `block-${counter}`
}
function initialForm(entry: EntryEditorEntry | undefined, defaultProjectId: string, defaultTypeId: string): Form {
if (!entry) {
return {
projectId: defaultProjectId,
title: '',
teaser: '',
slug: '',
typeId: defaultTypeId,
audience: 'internal',
cover: null,
blocks: [{ key: nextKey(), type: 'text', data: emptyBlockData('text') }],
}
}
return {
projectId: entry.projectId,
title: entry.title,
teaser: entry.teaser,
slug: entry.slug,
typeId: entry.typeId,
audience: entry.audience,
cover: entry.coverMediaId,
blocks: entry.blocks.map(block => ({ key: nextKey(), type: block.type, data: block.data })),
}
}
function payloadOf(form: Form): EntryInput {
return {
projectId: form.projectId,
title: form.title,
teaser: form.teaser,
slug: form.slug.trim() === '' ? undefined : form.slug.trim(),
typeId: form.typeId,
audience: form.audience,
coverMediaId: form.cover,
blocks: form.blocks.map(block => ({ type: block.type, data: block.data })),
}
}
function localTime(date: Date): string {
return date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
}
export function EntryEditor({ projects, types, media: initialMedia, accept, defaultProjectId, entry }: EntryEditorProps) {
const t = useTranslations('admin.entries')
const [form, setForm] = useState<Form>(() => initialForm(entry, defaultProjectId, types[0]?.id ?? ''))
const [media, setMedia] = useState<EntryMedia[]>(initialMedia)
const [status, setStatus] = useState<EntryState | null>(
entry
? {
id: entry.id,
number: entry.number,
slug: entry.slug,
status: entry.status,
publishAt: entry.publishAt,
updatedAt: '',
}
: null,
)
const [savedAt, setSavedAt] = useState<Date | null>(null)
const [saving, setSaving] = useState(false)
const [busy, setBusy] = useState(false)
const [error, setError] = useState<EntryErrorKey | null>(null)
const [blockers, setBlockers] = useState<string[]>([])
const [preview, setPreview] = useState(false)
const [scope, setScope] = useState<Audience>('customer')
const [when, setWhen] = useState('')
const [uploads, setUploads] = useState<Upload[]>([])
const [dropping, setDropping] = useState(false)
const [dragIndex, setDragIndex] = useState<number | null>(null)
const [adding, setAdding] = useState(false)
const key = useMemo(() => JSON.stringify(payloadOf(form)), [form])
const formRef = useRef(form)
const keyRef = useRef(key)
const idRef = useRef(entry?.id ?? '')
const savingRef = useRef(false)
const savedKey = useRef<string | null>(null)
formRef.current = form
keyRef.current = key
if (savedKey.current === null) {
savedKey.current = entry ? key : ''
}
const project = projects.find(item => item.id === form.projectId) ?? projects[0]
const selectedType = types.find(item => item.id === form.typeId) ?? types[0]
const touched = entry !== undefined || status !== null || form.title.trim() !== ''
const dirty = touched && key !== savedKey.current
const checks = useMemo(() => {
const ids = new Set<string>()
for (const block of form.blocks) {
for (const field of ['mediaId', 'beforeMediaId', 'afterMediaId']) {
const value = block.data[field]
if (typeof value === 'string' && value !== '') {
ids.add(value)
}
}
const many = block.data.mediaIds
if (Array.isArray(many)) {
for (const value of many) {
if (typeof value === 'string' && value !== '') {
ids.add(value)
}
}
}
}
if (form.cover) {
ids.add(form.cover)
}
return checkPublish({
title: form.title,
audience: form.audience,
blockCount: form.blocks.filter(block => !isBlockEmpty({ type: block.type, data: block.data })).length,
images: [...ids].map(id => ({ hasAlt: (media.find(item => item.id === id)?.alt ?? '').trim() !== '' })),
tagCount: 0,
hasCover: form.cover !== null,
})
}, [form, media])
const persist = useCallback(async (): Promise<EntryState | null> => {
const current = formRef.current
if (current.title.trim() === '' || savingRef.current) {
return null
}
const snapshot = keyRef.current
if (snapshot === savedKey.current) {
return null
}
savingRef.current = true
setSaving(true)
setError(null)
const created = idRef.current === ''
const result = await saveEntry({ ...payloadOf(current), id: idRef.current === '' ? undefined : idRef.current })
savingRef.current = false
setSaving(false)
if (!result.ok) {
setError(result.error)
return null
}
savedKey.current = snapshot
idRef.current = result.entry.id
setStatus(result.entry)
setSavedAt(new Date())
if (created) {
window.history.replaceState(null, '', adminEntryPath(result.entry.id))
}
if (keyRef.current !== savedKey.current) {
return persist()
}
return result.entry
}, [])
useEffect(() => {
if (form.title.trim() === '' || key === savedKey.current) {
return
}
const timer = setTimeout(() => void persist(), 1400)
return () => clearTimeout(timer)
}, [key, form.title, persist])
const upload = useCallback(async (files: File[]): Promise<string[]> => {
const projectId = formRef.current.projectId
const accepted = files.filter(file => file.type.startsWith('image/'))
const done: string[] = []
for (const file of accepted) {
const mark = `upload-${Date.now()}-${done.length}-${file.name}`
setUploads(current => [...current, { key: mark, name: file.name, status: 'pending' }])
const data = new FormData()
data.set('projectId', projectId)
data.set('file', file)
const result = await uploadEntryImage(data)
if (result.ok) {
setMedia(current => [result.media, ...current])
setUploads(current => current.map(item => (item.key === mark ? { ...item, status: 'done' } : item)))
done.push(result.media.id)
} else {
setUploads(current =>
current.map(item => (item.key === mark ? { ...item, status: 'failed', reason: result.error } : item)),
)
}
}
return done
}, [])
const insertFiles = useCallback(async (files: File[]) => {
const ids = await upload(files)
if (ids.length === 0) {
return
}
const block: EditorBlock = ids.length === 1
? { key: nextKey(), type: 'image', data: { mediaId: ids[0]! } }
: { key: nextKey(), type: 'gallery', data: { mediaIds: ids } }
setForm(current => {
const single = current.blocks[0]
const base = current.blocks.length === 1 && single && isBlockEmpty({ type: single.type, data: single.data })
? []
: current.blocks
return { ...current, cover: current.cover ?? ids[0]!, blocks: [...base, block] }
})
}, [upload])
useEffect(() => {
function onPaste(event: ClipboardEvent) {
const files = [...(event.clipboardData?.files ?? [])].filter(file => file.type.startsWith('image/'))
if (files.length === 0) {
return
}
event.preventDefault()
void insertFiles(files)
}
document.addEventListener('paste', onPaste)
return () => document.removeEventListener('paste', onPaste)
}, [insertFiles])
async function run(action: (id: string) => Promise<EntryResult>) {
setBusy(true)
setBlockers([])
setError(null)
await persist()
const id = idRef.current
if (id === '') {
setBusy(false)
setError('titleMissing')
return
}
const result = await action(id)
setBusy(false)
if (!result.ok) {
setError(result.error)
setBlockers(result.blockers ?? [])
return
}
setStatus(result.entry)
setSavedAt(new Date())
}
function update(patch: Partial<Form>) {
setForm(current => ({ ...current, ...patch }))
}
function changeProject(projectId: string) {
update({ projectId, cover: null })
setMedia([])
void loadEntryMedia(projectId).then(result => {
if (result.ok) {
setMedia(result.items)
}
})
}
function addBlock(type: BlockType) {
setAdding(false)
setForm(current => ({
...current,
blocks: [...current.blocks, { key: nextKey(), type, data: emptyBlockData(type) }],
}))
}
function moveBlock(from: number, to: number) {
if (to < 0 || from === to) {
return
}
setForm(current => {
if (to >= current.blocks.length) {
return current
}
const blocks = [...current.blocks]
const [moved] = blocks.splice(from, 1)
if (!moved) {
return current
}
blocks.splice(to, 0, moved)
return { ...current, blocks }
})
}
const saveLabel = saving
? t('save.pending')
: dirty
? t('save.dirty')
: savedAt
? t('save.saved', { time: localTime(savedAt) })
: entry
? t('save.stored', { time: entry.updatedLabel })
: t('save.idle')
const current = status?.status ?? 'draft'
return (
<div className="flex flex-col gap-8">
<div className="sticky top-0 z-10 flex flex-wrap items-center gap-x-5 gap-y-3 border-b border-rule bg-paper py-3">
<Link href={adminEntriesPath} className={quietLinkClass}>
{t('back')}
</Link>
<EntryStatusChip status={current} label={t(`status.${current}`)} />
{status ? (
<span className={metaClass}>
{project ? `${project.code}-${String(status.number).padStart(4, '0')}` : status.number}
</span>
) : null}
<span className={metaClass}>{saveLabel}</span>
<span aria-hidden="true" className="h-px min-w-6 flex-1 bg-rule" />
<button
type="button"
onClick={() => setPreview(value => !value)}
className={ghostButtonClass}
>
{preview ? <TbPencil aria-hidden="true" className="size-4" /> : <TbEye aria-hidden="true" className="size-4" />}
{preview ? t('actions.edit') : t('actions.preview')}
</button>
<button
type="button"
disabled={saving || !dirty || form.title.trim() === ''}
onClick={() => void persist()}
className={ghostButtonClass}
>
<TbDeviceFloppy aria-hidden="true" className="size-4" />
{t('actions.save')}
</button>
{current === 'published' ? (
<button type="button" disabled={busy} onClick={() => void run(id => archiveEntry({ id }))} className={ghostButtonClass}>
<TbArchive aria-hidden="true" className="size-4" />
{t('actions.archive')}
</button>
) : current === 'archived' ? (
<button type="button" disabled={busy} onClick={() => void run(id => resumeEntry({ id }))} className={ghostButtonClass}>
<TbRotate2 aria-hidden="true" className="size-4" />
{t('actions.resume')}
</button>
) : (
<button
type="button"
disabled={busy || form.title.trim() === ''}
onClick={() => void run(id => publishEntry({ id }))}
className={primaryButtonClass}
>
<TbSend aria-hidden="true" className="size-4" />
{busy ? t('actions.publishing') : t('actions.publish')}
</button>
)}
</div>
{error ? <AdminNotice tone="alert">{t(`errors.${error}`)}</AdminNotice> : null}
{blockers.length > 0 ? (
<AdminNotice tone="alert" title={t('checks.blockersTitle')}>
{blockers.map(entry => t(`checks.${entry}`)).join(' ')}
</AdminNotice>
) : null}
{preview ? (
<EntryPreview
title={form.title}
teaser={form.teaser}
typeLabel={selectedType?.label ?? ''}
typeColor={selectedType?.color ?? defaultPostTypeColor}
audience={form.audience}
status={current}
number={status?.number ?? null}
code={project?.code ?? ''}
color={project?.color ?? '#000000'}
media={media}
cover={form.cover}
blocks={form.blocks}
scope={scope}
onScope={setScope}
/>
) : (
<div
onDragOver={event => {
if (event.dataTransfer.types.includes('Files')) {
event.preventDefault()
setDropping(true)
}
}}
onDragLeave={() => setDropping(false)}
onDrop={event => {
const files = [...event.dataTransfer.files]
setDropping(false)
if (files.length === 0) {
return
}
event.preventDefault()
void insertFiles(files)
}}
className={`flex flex-col gap-8 border border-dashed px-1 py-1 ${dropping ? 'border-signal' : 'border-transparent'}`}
>
<div className="grid gap-8 lg:grid-cols-[minmax(0,1fr)_20rem] lg:items-start">
<div className="flex min-w-0 flex-col gap-8">
<div className="flex flex-col gap-3">
<label htmlFor="entry-title" className="sr-only">
{t('fields.title')}
</label>
<input
id="entry-title"
value={form.title}
autoFocus={!entry}
placeholder={t('fields.titlePlaceholder')}
onChange={event => update({ title: event.target.value })}
className="w-full min-w-0 border-0 bg-transparent p-0 font-display text-title font-bold text-ink placeholder:text-ink-3 focus:outline-none"
/>
</div>
<AdminField id="entry-teaser" label={t('fields.teaser')} hint={t('hints.teaser')}>
<textarea
id="entry-teaser"
rows={2}
value={form.teaser}
placeholder={t('fields.teaserPlaceholder')}
onChange={event => update({ teaser: event.target.value })}
className={textareaClass}
/>
</AdminField>
{uploads.length > 0 ? (
<ul className="m-0 flex list-none flex-col gap-1 p-0">
{uploads.map(item => (
<li key={item.key} className={metaClass}>
{item.status === 'pending'
? t('uploads.pending', { name: item.name })
: item.status === 'done'
? t('uploads.done', { name: item.name })
: t('uploads.failed', { name: item.name, reason: t(`errors.${item.reason ?? 'unknown'}`) })}
</li>
))}
</ul>
) : null}
<ul className="m-0 flex list-none flex-col gap-4 p-0">
{form.blocks.map((block, index) => (
<EntryBlockCard
key={block.key}
type={block.type}
data={block.data}
index={index}
total={form.blocks.length}
media={media}
accept={accept}
dragging={dragIndex === index}
onChange={data =>
setForm(state => ({
...state,
blocks: state.blocks.map(item => (item.key === block.key ? { ...item, data } : item)),
}))
}
onRemove={() =>
setForm(state => ({ ...state, blocks: state.blocks.filter(item => item.key !== block.key) }))
}
onMove={to => moveBlock(index, to)}
onDragStart={() => setDragIndex(index)}
onDragEnd={() => setDragIndex(null)}
onDropOn={() => {
if (dragIndex !== null) {
moveBlock(dragIndex, index)
}
setDragIndex(null)
}}
upload={upload}
/>
))}
</ul>
<div className="flex flex-col gap-3">
<div className="flex flex-wrap items-center gap-4">
<button type="button" onClick={() => setAdding(value => !value)} className={ghostButtonClass}>
{adding ? <TbX aria-hidden="true" className="size-4" /> : <TbPlus aria-hidden="true" className="size-4" />}
{adding ? t('actions.close') : t('actions.addBlock')}
</button>
<span className={hintClass}>
<TbPhotoPlus aria-hidden="true" className="mr-2 inline size-4 align-text-bottom" />
{t('hints.drop')}
</span>
</div>
{adding ? (
<ul className="m-0 flex list-none flex-wrap gap-3 p-0">
{blockTypes.map(type => (
<li key={type}>
<button type="button" onClick={() => addBlock(type)} className={ghostButtonClass}>
{t(`blocks.${type}`)}
</button>
</li>
))}
</ul>
) : null}
</div>
</div>
<aside className="flex min-w-0 flex-col gap-6 lg:sticky lg:top-20">
<div className="grid gap-5">
<AdminField
id="entry-project"
label={t('fields.project')}
hint={entry ? t('hints.projectMove') : undefined}
>
<select
id="entry-project"
value={form.projectId}
onChange={event => changeProject(event.target.value)}
className={selectClass}
>
{projects.map(item => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
</select>
</AdminField>
<AdminField id="entry-type" label={t('fields.type')}>
<select
id="entry-type"
value={form.typeId}
onChange={event => update({ typeId: event.target.value })}
className={selectClass}
>
{types.map(item => (
<option key={item.id} value={item.id}>
{item.label}
</option>
))}
</select>
</AdminField>
<AdminField id="entry-audience" label={t('fields.audience')} hint={t('hints.audience')}>
<select
id="entry-audience"
value={form.audience}
onChange={event => update({ audience: event.target.value as Audience })}
className={selectClass}
>
{audiences.map(item => (
<option key={item} value={item}>
{t(`audience.${item}`)}
</option>
))}
</select>
</AdminField>
</div>
<div className="flex flex-col gap-6 border-t border-rule pt-6">
<EntryMediaPicker
label={t('fields.cover')}
media={media}
selected={form.cover ? [form.cover] : []}
accept={accept}
onSelect={ids => update({ cover: ids[0] ?? null })}
onUpload={files => void upload(files).then(ids => ids[0] && update({ cover: ids[0] }))}
hint={t('hints.cover')}
/>
<div className="grid gap-5">
<AdminField id="entry-slug" label={t('fields.slug')} hint={t('hints.slug')}>
<input
id="entry-slug"
value={form.slug}
spellCheck={false}
autoComplete="off"
placeholder={slugify(form.title)}
onChange={event => update({ slug: event.target.value })}
className={`${inputClass} font-mono`}
/>
</AdminField>
<div className="flex min-w-0 flex-col gap-3">
<span className={labelClass}>{t('schedule.title')}</span>
{current === 'scheduled' && status?.publishAt ? (
<div className="flex flex-wrap items-center gap-4">
<span suppressHydrationWarning className={metaClass}>
{new Date(status.publishAt).toLocaleString()}
</span>
<button
type="button"
disabled={busy}
onClick={() => void run(id => unscheduleEntry({ id }))}
className={ghostButtonClass}
>
<TbCalendarX aria-hidden="true" className="size-4" />
{t('actions.unschedule')}
</button>
</div>
) : (
<div className="flex flex-wrap items-end gap-4">
<input
id="entry-schedule"
type="datetime-local"
value={when}
onChange={event => setWhen(event.target.value)}
className={`${inputClass} font-mono`}
/>
<button
type="button"
disabled={busy || when === ''}
onClick={() => void run(id => scheduleEntry({ id, publishAt: when }))}
className={ghostButtonClass}
>
<TbCalendarClock aria-hidden="true" className="size-4" />
{t('actions.schedule')}
</button>
</div>
)}
<span className={hintClass}>{t('hints.schedule')}</span>
</div>
</div>
{checks.hints.length > 0 || checks.blockers.length > 0 ? (
<ul className="m-0 flex list-none flex-col gap-1 p-0">
{checks.blockers.map(item => (
<li key={item} className="font-mono text-micro text-signal">
{t(`checks.${item}`)}
</li>
))}
{checks.hints.map(item => (
<li key={item} className={metaClass}>
{t(`checks.${item}`)}
</li>
))}
</ul>
) : null}
</div> </aside>
</div>
</div>
)}
</div>
)
}
+103
View File
@@ -0,0 +1,103 @@
'use client'
import { useRef } from 'react'
import Link from 'next/link'
import { useTranslations } from 'next-intl'
import { TbSearch } from 'react-icons/tb'
import { AdminField } from './AdminField'
import { ghostButtonClass, inputClass, quietLinkClass, selectClass } from './styles'
import { postStatuses } from '~/domain/post-status'
import { adminEntriesPath } from '~/lib/admin-routes'
import type { EntryTypeOption } from '~/lib/entry-types'
export type EntryFiltersProps = {
projects: { slug: string, name: string }[]
types: EntryTypeOption[]
project: string
status: string
type: string
search: string
filtered: boolean
}
export function EntryFilters({ projects, types, project, status, type, search, filtered }: EntryFiltersProps) {
const t = useTranslations('admin.entries.filters')
const states = useTranslations('admin.entries.status')
const form = useRef<HTMLFormElement>(null)
function submit() {
form.current?.requestSubmit()
}
return (
<form
ref={form}
action={adminEntriesPath}
className="flex flex-col gap-5 border border-rule bg-surface px-5 py-4"
>
<div className="grid gap-5 md:grid-cols-4">
<AdminField id="filter-project" label={t('project')}>
<select
id="filter-project"
name="project"
defaultValue={project}
onChange={submit}
className={selectClass}
>
<option value="">{t('allProjects')}</option>
{projects.map(item => (
<option key={item.slug} value={item.slug}>
{item.name}
</option>
))}
</select>
</AdminField>
<AdminField id="filter-status" label={t('status')}>
<select id="filter-status" name="status" defaultValue={status} onChange={submit} className={selectClass}>
<option value="">{t('allStatus')}</option>
{postStatuses.map(item => (
<option key={item} value={item}>
{states(item)}
</option>
))}
</select>
</AdminField>
<AdminField id="filter-type" label={t('type')}>
<select id="filter-type" name="type" defaultValue={type} onChange={submit} className={selectClass}>
<option value="">{t('allTypes')}</option>
{types.map(item => (
<option key={item.id} value={item.key}>
{item.label}
</option>
))}
</select>
</AdminField>
<AdminField id="filter-search" label={t('search')}>
<input
id="filter-search"
name="q"
type="search"
defaultValue={search}
placeholder={t('searchPlaceholder')}
className={inputClass}
/>
</AdminField>
</div>
<div className="flex flex-wrap items-center gap-6">
<button type="submit" className={ghostButtonClass}>
<TbSearch aria-hidden="true" className="size-4" />
{t('submit')}
</button>
{filtered ? (
<Link href={adminEntriesPath} className={quietLinkClass}>
{t('reset')}
</Link>
) : null}
</div>
</form>
)
}
+153
View File
@@ -0,0 +1,153 @@
'use client'
import { useId, useState } from 'react'
import Image from 'next/image'
import { useTranslations } from 'next-intl'
import { TbAlertTriangle, TbLibraryPhoto, TbTrash, TbUpload, TbX } from 'react-icons/tb'
import { Scroller } from '~/components/ui/Scroller'
import { ghostButtonClass, hintClass, labelClass, metaClass } from './styles'
import type { EntryMedia } from '~/lib/entry-types'
export type EntryMediaPickerProps = {
label: string
media: EntryMedia[]
selected: string[]
multiple?: boolean
accept: string
onSelect: (ids: string[]) => void
onUpload: (files: File[]) => void
hint?: string
}
export function EntryMediaPicker({
label,
media,
selected,
multiple = false,
accept,
onSelect,
onUpload,
hint,
}: EntryMediaPickerProps) {
const t = useTranslations('admin.entries')
const [open, setOpen] = useState(false)
const inputId = useId()
const chosen = selected
.map(id => media.find(item => item.id === id))
.filter((item): item is EntryMedia => item !== undefined)
function toggle(id: string) {
if (multiple) {
onSelect(selected.includes(id) ? selected.filter(entry => entry !== id) : [...selected, id])
return
}
onSelect(selected[0] === id ? [] : [id])
setOpen(false)
}
return (
<div className="flex min-w-0 flex-col gap-3">
<span className={labelClass}>{label}</span>
{chosen.length > 0 ? (
<ul className="m-0 flex list-none flex-wrap gap-3 p-0">
{chosen.map(item => (
<li key={item.id} className="flex w-40 min-w-0 flex-col gap-1">
<span className="relative block aspect-video w-full overflow-hidden border border-rule bg-paper">
<Image src={item.thumbnail} alt={item.alt} fill sizes="10rem" className="object-cover" />
</span>
<span className="flex items-center gap-2">
<span className="min-w-0 flex-1 truncate font-mono text-micro text-ink-3">{item.filename}</span>
<button
type="button"
onClick={() => onSelect(selected.filter(entry => entry !== item.id))}
aria-label={t('actions.clear')}
className="cursor-pointer border-0 bg-transparent p-0 text-ink-3 hover:text-signal"
>
<TbTrash aria-hidden="true" className="size-4" />
</button>
</span>
{item.alt.trim() === '' ? (
<span className="flex items-center gap-1 font-mono text-micro text-caution">
<TbAlertTriangle aria-hidden="true" className="size-3 shrink-0" />
{t('media.altMissing')}
</span>
) : null}
</li>
))}
</ul>
) : null}
<div className="flex flex-wrap items-center gap-3">
<button type="button" onClick={() => setOpen(value => !value)} className={ghostButtonClass}>
{open ? <TbX aria-hidden="true" className="size-4" /> : <TbLibraryPhoto aria-hidden="true" className="size-4" />}
{open ? t('actions.close') : chosen.length > 0 ? t('actions.change') : t('actions.choose')}
</button>
<label htmlFor={inputId} className={ghostButtonClass}>
<TbUpload aria-hidden="true" className="size-4" />
{t('actions.upload')}
<input
id={inputId}
type="file"
accept={accept}
multiple={multiple}
className="sr-only"
onChange={event => {
const files = [...(event.target.files ?? [])]
event.target.value = ''
if (files.length > 0) {
onUpload(files)
}
}}
/>
</label>
</div>
{hint ? <span className={hintClass}>{hint}</span> : null}
{open ? (
media.length === 0 ? (
<p className={`m-0 border border-rule bg-surface px-4 py-3 ${hintClass}`}>{t('media.empty')}</p>
) : (
<Scroller className="max-h-96 border border-rule bg-surface">
<ul className="m-0 grid list-none grid-cols-2 gap-3 p-3 md:grid-cols-4">
{media.map(item => {
const active = selected.includes(item.id)
return (
<li key={item.id} className="min-w-0">
<button
type="button"
onClick={() => toggle(item.id)}
aria-pressed={active}
className={`flex w-full cursor-pointer flex-col gap-1 border bg-paper p-1 text-left ${
active ? 'border-signal' : 'border-rule hover:border-ink-3'
}`}
>
<span className="relative block aspect-video w-full overflow-hidden">
<Image
src={item.thumbnail}
alt={item.alt}
fill
sizes="(min-width: 46rem) 12rem, 45vw"
className="object-cover"
/>
</span>
<span className="truncate font-mono text-micro text-ink-2">{item.filename}</span>
<span className={metaClass}>
{item.alt.trim() === '' ? t('media.altMissing') : `${item.width} x ${item.height}`}
</span>
</button>
</li>
)
})}
</ul>
</Scroller>
)
) : null}
</div>
)
}
+274
View File
@@ -0,0 +1,274 @@
'use client'
import Image from 'next/image'
import { useTranslations } from 'next-intl'
import { TbAlertTriangle, TbArrowUpRight, TbInfoCircle, TbPlayerPlay } from 'react-icons/tb'
import { AdminNotice } from './AdminNotice'
import { EntryStatusChip } from './EntryStatusChip'
import { hintClass, labelClass } from './styles'
import { Scroller } from '~/components/ui/Scroller'
import { canSee } from '~/domain/audience'
import type { BlockType, BlockValues } from '~/domain/blocks'
import type { Audience, PostStatus } from '~/domain/types'
import type { EntryMedia } from '~/lib/entry-types'
export type EntryPreviewBlock = {
key: string
type: BlockType
data: BlockValues
}
export type EntryPreviewProps = {
title: string
teaser: string
typeLabel: string
typeColor: string
audience: Audience
status: PostStatus
number: number | null
code: string
color: string
media: EntryMedia[]
cover: string | null
blocks: EntryPreviewBlock[]
scope: Audience
onScope: (scope: Audience) => void
}
const scopes: Audience[] = ['internal', 'customer', 'public']
function text(data: BlockValues, key: string): string {
const value = data[key]
return typeof value === 'string' ? value.trim() : ''
}
function ids(data: BlockValues, key: string): string[] {
const value = data[key]
return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : []
}
function Picture({ item }: { item: EntryMedia }) {
return (
<figure className="m-0 flex flex-col gap-2">
<span className="relative block aspect-shot w-full overflow-hidden border border-rule bg-surface">
<Image src={item.src} alt={item.alt} fill sizes="(min-width: 46rem) 38rem, 92vw" className="object-cover" />
</span>
{item.caption === '' ? null : (
<figcaption className="font-mono text-micro text-ink-3">{item.caption}</figcaption>
)}
</figure>
)
}
export function EntryPreview({
title,
teaser,
typeLabel,
typeColor,
audience,
status,
number,
code,
color,
media,
cover,
blocks,
scope,
onScope,
}: EntryPreviewProps) {
const t = useTranslations('admin.entries')
const visible = canSee(scope, audience)
const find = (id: string) => media.find(item => item.id === id)
const coverItem = cover ? find(cover) : undefined
return (
<section className="flex flex-col gap-6">
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
<span className={labelClass}>{t('preview.scope')}</span>
{scopes.map(entry => (
<button
key={entry}
type="button"
onClick={() => onScope(entry)}
aria-pressed={scope === entry}
className={`cursor-pointer border px-3 py-1 font-mono text-micro font-semibold uppercase tracking-chip ${
scope === entry ? 'border-ink bg-ink text-paper' : 'border-rule bg-transparent text-ink-2 hover:border-ink-3'
}`}
>
{t(`audience.${entry}`)}
</button>
))}
</div>
{visible ? (
<article className="flex flex-col gap-6 border border-rule bg-surface px-6 py-6">
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
<span
style={{ background: color }}
className="inline-flex items-center px-2 py-1 font-mono text-micro font-semibold uppercase tracking-mark text-paper"
>
{number === null ? code : `${code}-${String(number).padStart(4, '0')}`}
</span>
<span style={{ color: typeColor }} className="font-mono text-micro font-semibold uppercase tracking-mark">
{typeLabel}
</span>
<EntryStatusChip status={status} label={t(`status.${status}`)} />
</div>
{coverItem ? <Picture item={coverItem} /> : null}
<h2 className="m-0 text-title font-bold text-balance text-ink">
{title.trim() === '' ? t('preview.untitled') : title}
</h2>
{teaser.trim() === '' ? null : <p className="m-0 max-w-read text-lead text-pretty text-ink-2">{teaser}</p>}
{blocks.length === 0 ? (
<p className={`m-0 ${hintClass}`}>{t('preview.empty')}</p>
) : (
<div className="flex max-w-read flex-col gap-8">
{blocks.map(block => {
if (block.type === 'text') {
const body = text(block.data, 'text')
return body === '' ? null : (
<div key={block.key} className="flex flex-col gap-4">
{body.split(/\n{2,}/).map((part, index) => (
<p key={index} className="m-0 text-base text-ink">
{part.trim()}
</p>
))}
</div>
)
}
if (block.type === 'image' || block.type === 'video') {
const item = find(text(block.data, 'mediaId'))
const url = text(block.data, 'url')
if (item) {
return <Picture key={block.key} item={item} />
}
return url === '' ? null : (
<span
key={block.key}
className="inline-flex items-center gap-3 self-start border border-rule px-4 py-3 font-display text-small text-ink"
>
<TbPlayerPlay aria-hidden="true" className="size-4 text-signal" />
{text(block.data, 'title') === '' ? url : text(block.data, 'title')}
</span>
)
}
if (block.type === 'gallery') {
const items = ids(block.data, 'mediaIds').map(find).filter((item): item is EntryMedia => item !== undefined)
return items.length === 0 ? null : (
<div key={block.key} className="grid grid-cols-2 gap-3">
{items.map(item => (
<Picture key={item.id} item={item} />
))}
</div>
)
}
if (block.type === 'before_after') {
const before = find(text(block.data, 'beforeMediaId'))
const after = find(text(block.data, 'afterMediaId'))
return !before && !after ? null : (
<div key={block.key} className="grid gap-3 md:grid-cols-2">
{before ? <Picture item={before} /> : null}
{after ? <Picture item={after} /> : null}
</div>
)
}
if (block.type === 'quote') {
const body = text(block.data, 'text')
return body === '' ? null : (
<blockquote key={block.key} className="m-0 border-l-2 border-signal pl-5">
<p className="m-0 text-lead text-ink">{body}</p>
{text(block.data, 'source') === '' ? null : (
<cite className="mt-2 block font-mono text-micro not-italic text-ink-3">
{text(block.data, 'source')}
</cite>
)}
</blockquote>
)
}
if (block.type === 'code') {
const body = text(block.data, 'code')
return body === '' ? null : (
<div key={block.key} className="overflow-hidden border border-rule">
{text(block.data, 'language') === '' ? null : (
<div className="border-b border-rule px-4 py-2 font-mono text-micro uppercase tracking-chip text-ink-3">
{text(block.data, 'language')}
</div>
)}
<Scroller className="max-h-96">
<pre className="m-0 w-max min-w-full px-4 py-3 font-mono text-small text-ink">
<code>{body}</code>
</pre>
</Scroller>
</div>
)
}
if (block.type === 'link') {
const url = text(block.data, 'url')
return url === '' ? null : (
<span key={block.key} className="flex items-start gap-3 border border-rule px-4 py-3">
<TbArrowUpRight aria-hidden="true" className="mt-1 size-4 shrink-0 text-ink-3" />
<span className="flex flex-col gap-1">
<span className="font-display text-small text-ink">
{text(block.data, 'label') === '' ? url : text(block.data, 'label')}
</span>
{text(block.data, 'description') === '' ? null : (
<span className="text-small text-ink-2">{text(block.data, 'description')}</span>
)}
<span className="font-mono text-micro text-ink-3">{url}</span>
</span>
</span>
)
}
const body = text(block.data, 'text')
const warning = text(block.data, 'tone') === 'warning'
const Icon = warning ? TbAlertTriangle : TbInfoCircle
return body === '' ? null : (
<span
key={block.key}
className={`flex items-start gap-3 border border-rule border-l-2 px-4 py-3 ${
warning ? 'border-l-signal' : 'border-l-link'
}`}
>
<Icon aria-hidden="true" className={`mt-1 size-4 shrink-0 ${warning ? 'text-signal' : 'text-link'}`} />
<span className="flex flex-col gap-1">
{text(block.data, 'title') === '' ? null : (
<span className="font-display text-small text-ink">{text(block.data, 'title')}</span>
)}
<span className="text-ink-2">{body}</span>
</span>
</span>
)
})}
</div>
)}
</article>
) : (
<AdminNotice title={t('preview.hiddenTitle')}>
{t('preview.hidden', { scope: t(`audience.${scope}`), audience: t(`audience.${audience}`) })}
</AdminNotice>
)}
</section>
)
}
+24
View File
@@ -0,0 +1,24 @@
import type { PostStatus } from '~/domain/types'
const tones: Record<PostStatus, string> = {
draft: 'border-rule text-ink-3',
review: 'border-caution text-caution',
scheduled: 'border-link text-link',
published: 'border-positive text-positive',
archived: 'border-rule text-ink-3',
}
export type EntryStatusChipProps = {
status: PostStatus
label: string
}
export function EntryStatusChip({ status, label }: EntryStatusChipProps) {
return (
<span
className={`inline-flex shrink-0 items-center border px-2 py-0.5 font-mono text-micro font-semibold uppercase tracking-chip ${tones[status]}`}
>
{label}
</span>
)
}
+81
View File
@@ -0,0 +1,81 @@
'use client'
import { useActionState } from 'react'
import { useTranslations } from 'next-intl'
import { TbAlertTriangle, TbArrowNarrowRight } from 'react-icons/tb'
import { signIn } from '~/lib/auth-actions'
import { emptyLoginState, type LoginErrorKey, type LoginState } from '~/lib/auth-types'
export type LoginFormProps = {
next?: string
}
const fieldClass = 'min-w-0 border-b border-rule bg-transparent px-1 py-2 font-body text-base text-ink placeholder:text-ink-3 focus:border-signal'
const labelClass = 'font-mono text-micro font-semibold uppercase tracking-label text-ink-3'
export function LoginForm({ next }: LoginFormProps) {
const t = useTranslations('login')
const [state, formAction, pending] = useActionState<LoginState, FormData>(signIn, emptyLoginState)
const messages: Record<LoginErrorKey, string> = {
email: t('errors.email'),
password: t('errors.password'),
credentials: t('errors.credentials'),
tooMany: t('errors.tooMany'),
unknown: t('errors.unknown'),
}
return (
<form action={formAction} className="flex flex-col gap-6">
{next ? <input type="hidden" name="next" value={next} /> : null}
{state.error ? (
<p role="alert" className="m-0 flex gap-3 border-l-2 border-signal bg-surface px-4 py-3 text-small text-pretty text-ink-2">
<TbAlertTriangle aria-hidden="true" className="mt-1 size-4 shrink-0 text-signal" />
<span>{messages[state.error]}</span>
</p>
) : null}
<div className="flex flex-col gap-2">
<label htmlFor="email" className={labelClass}>
{t('email')}
</label>
<input
id="email"
name="email"
type="email"
autoComplete="username"
required
defaultValue={state.email ?? ''}
className={fieldClass}
/>
</div>
<div className="flex flex-col gap-2">
<label htmlFor="password" className={labelClass}>
{t('password')}
</label>
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
className={fieldClass}
/>
</div>
<div className="flex flex-wrap items-center gap-4 pt-2">
<button
type="submit"
disabled={pending}
className="inline-flex cursor-pointer items-center gap-2 bg-ink px-4 py-2.5 font-mono text-micro font-semibold uppercase tracking-label text-paper hover:bg-signal disabled:cursor-progress disabled:opacity-60"
>
<TbArrowNarrowRight aria-hidden="true" className="size-4 shrink-0" />
{pending ? t('pending') : t('submit')}
</button>
</div>
</form>
)
}
+80
View File
@@ -0,0 +1,80 @@
'use client'
import { useActionState } from 'react'
import Link from 'next/link'
import { useTranslations } from 'next-intl'
import { TbMailForward } from 'react-icons/tb'
import {
emptyMagicLinkState,
magicLinkMinutes,
type MagicLinkErrorKey,
type MagicLinkState,
} from '~/domain/magic-link'
import { requestMagicLink } from '~/lib/auth-actions'
import { loginPath } from '~/lib/auth-routes'
import { AdminNotice } from './AdminNotice'
import { inputClass, labelClass, primaryButtonClass, quietLinkClass } from './styles'
export type MagicLinkFormProps = {
next?: string
}
export function MagicLinkForm({ next }: MagicLinkFormProps) {
const t = useTranslations('login.magic')
const [state, formAction, pending] = useActionState<MagicLinkState, FormData>(
requestMagicLink,
emptyMagicLinkState,
)
const messages: Record<MagicLinkErrorKey, string> = {
email: t('errors.email'),
tooMany: t('errors.tooMany', { minutes: magicLinkMinutes }),
}
if (state.status === 'sent') {
return (
<AdminNotice
tone="done"
title={t('sentTitle')}
action={
<Link href={loginPath(next)} className={quietLinkClass}>
{t('again')}
</Link>
}
>
{`${t('sent', { email: state.email ?? '' })} ${t('sentHint', { minutes: magicLinkMinutes })}`}
</AdminNotice>
)
}
return (
<form action={formAction} className="flex flex-col gap-6">
{next ? <input type="hidden" name="next" value={next} /> : null}
{state.error ? <AdminNotice tone="alert">{messages[state.error]}</AdminNotice> : null}
<div className="flex flex-col gap-2">
<label htmlFor="magic-email" className={labelClass}>
{t('email')}
</label>
<input
id="magic-email"
name="email"
type="email"
autoComplete="username"
required
defaultValue={state.email ?? ''}
className={inputClass}
/>
<p className="m-0 text-small text-pretty text-ink-3">{t('intro')}</p>
</div>
<div className="flex flex-wrap items-center gap-4">
<button type="submit" disabled={pending} className={primaryButtonClass}>
<TbMailForward aria-hidden="true" className="size-4 shrink-0" />
{pending ? t('pending') : t('submit')}
</button>
</div>
</form>
)
}
+64
View File
@@ -0,0 +1,64 @@
import Image from 'next/image'
import { getTranslations } from 'next-intl/server'
import { TbAlertTriangle } from 'react-icons/tb'
import { MediaMetaForm } from './MediaMetaForm'
import { metaClass } from './styles'
import { hasAlt, mediaAlt, mediaThumbnail } from '~/lib/media-url'
import type { Media } from '~/data/schema'
export type MediaCardProps = {
item: Media
project: string
}
function kilobytes(bytes: number): number {
return Math.max(1, Math.round(bytes / 1024))
}
export async function MediaCard({ item, project }: MediaCardProps) {
const t = await getTranslations('media')
return (
<li className="flex flex-col gap-5 border border-rule bg-surface p-5 md:flex-row">
<div className="relative aspect-video w-full shrink-0 overflow-hidden border border-rule bg-paper md:w-2/5">
<Image
src={mediaThumbnail(item)}
alt={mediaAlt(item)}
fill
sizes="(min-width: 46rem) 20rem, 100vw"
className="object-cover"
/>
</div>
<div className="flex min-w-0 flex-1 flex-col gap-4">
<div className="flex flex-col gap-1">
<span className="truncate font-display text-base font-semibold text-ink">{item.originalFilename}</span>
<span className={metaClass}>
{t('card.meta', {
width: item.width ?? 0,
height: item.height ?? 0,
size: kilobytes(item.byteSize),
variants: item.variants.length,
})}
</span>
</div>
{item.variantError ? (
<p role="alert" className="m-0 flex gap-3 border-l-2 border-signal px-4 py-2 text-small text-pretty text-ink-2">
<TbAlertTriangle aria-hidden="true" className="mt-1 size-4 shrink-0 text-signal" />
<span>{t('card.variantError')}</span>
</p>
) : null}
{hasAlt(item) ? null : (
<p className="m-0 flex gap-3 border-l-2 border-caution px-4 py-2 text-small text-pretty text-ink-2">
<TbAlertTriangle aria-hidden="true" className="mt-1 size-4 shrink-0 text-caution" />
<span>{t('card.altMissing')}</span>
</p>
)}
<MediaMetaForm mediaId={item.id} project={project} alt={item.alt ?? ''} caption={item.caption ?? ''} />
</div>
</li>
)
}
+24
View File
@@ -0,0 +1,24 @@
'use client'
import { useTranslations } from 'next-intl'
import { AdminNotice } from './AdminNotice'
import type { AdminFormState } from '~/lib/admin-forms'
export type MediaMessageProps = {
state: AdminFormState
silentOnDone?: boolean
}
export function MediaMessage({ state, silentOnDone = false }: MediaMessageProps) {
const t = useTranslations('media.messages')
if (state.status === 'idle' || !state.message) {
return null
}
if (state.status === 'ok' && silentOnDone) {
return null
}
return <AdminNotice tone={state.status === 'error' ? 'alert' : 'done'}>{t(state.message)}</AdminNotice>
}
+53
View File
@@ -0,0 +1,53 @@
'use client'
import { useActionState } from 'react'
import { useTranslations } from 'next-intl'
import { TbDeviceFloppy } from 'react-icons/tb'
import { AdminField } from './AdminField'
import { AdminSubmit } from './AdminSubmit'
import { MediaMessage } from './MediaMessage'
import { inputClass } from './styles'
import { emptyAdminFormState, type AdminFormState } from '~/lib/admin-forms'
import { saveMediaMetaAction } from '~/lib/media-actions'
export type MediaMetaFormProps = {
mediaId: string
project: string
alt: string
caption: string
}
export function MediaMetaForm({ mediaId, project, alt, caption }: MediaMetaFormProps) {
const t = useTranslations('media')
const [state, formAction] = useActionState<AdminFormState, FormData>(saveMediaMetaAction, emptyAdminFormState)
return (
<form action={formAction} className="flex flex-col gap-4">
<input type="hidden" name="mediaId" value={mediaId} />
<input type="hidden" name="project" value={project} />
<MediaMessage state={state} />
<AdminField id={`alt-${mediaId}`} label={t('fields.alt')}>
<input
id={`alt-${mediaId}`}
name="alt"
type="text"
defaultValue={alt}
placeholder={t('fields.altPlaceholder')}
className={inputClass}
/>
</AdminField>
<AdminField id={`caption-${mediaId}`} label={t('fields.caption')}>
<input id={`caption-${mediaId}`} name="caption" type="text" defaultValue={caption} className={inputClass} />
</AdminField>
<div className="flex flex-wrap items-center gap-3">
<AdminSubmit quiet pendingLabel={t('meta.pending')} icon={<TbDeviceFloppy className="size-4" />}>
{t('meta.submit')}
</AdminSubmit>
</div>
</form>
)
}
+59
View File
@@ -0,0 +1,59 @@
'use client'
import { useActionState, useEffect, useRef } from 'react'
import { useTranslations } from 'next-intl'
import { TbUpload } from 'react-icons/tb'
import { AdminField } from './AdminField'
import { AdminSubmit } from './AdminSubmit'
import { MediaMessage } from './MediaMessage'
import { fileClass, hintClass, inputClass, labelClass } from './styles'
import { emptyAdminFormState, type AdminFormState } from '~/lib/admin-forms'
import { uploadMediaAction } from '~/lib/media-actions'
export type MediaUploadFormProps = {
project: string
accept: string
}
export function MediaUploadForm({ project, accept }: MediaUploadFormProps) {
const t = useTranslations('media')
const form = useRef<HTMLFormElement>(null)
const [state, formAction] = useActionState<AdminFormState, FormData>(uploadMediaAction, emptyAdminFormState)
useEffect(() => {
if (state.status === 'ok') {
form.current?.reset()
}
}, [state])
return (
<form ref={form} action={formAction} className="flex flex-col gap-5 border border-rule bg-surface px-5 py-5">
<input type="hidden" name="project" value={project} />
<div className="flex flex-col gap-2">
<span className={labelClass}>{t('upload.title')}</span>
<p className={`m-0 max-w-read text-pretty ${hintClass}`}>{t('upload.hint')}</p>
</div>
<MediaMessage state={state} silentOnDone />
<AdminField id="media-file" label={t('upload.file')} hint={t('upload.limit')}>
<input id="media-file" name="file" type="file" accept={accept} required className={fileClass} />
</AdminField>
<AdminField id="media-alt" label={t('fields.alt')} hint={t('fields.altHint')}>
<input id="media-alt" name="alt" type="text" placeholder={t('fields.altPlaceholder')} className={inputClass} />
</AdminField>
<AdminField id="media-caption" label={t('fields.caption')}>
<input id="media-caption" name="caption" type="text" className={inputClass} />
</AdminField>
<div className="flex flex-wrap items-center gap-4">
<AdminSubmit pendingLabel={t('upload.pending')} icon={<TbUpload className="size-4" />}>
{t('upload.submit')}
</AdminSubmit>
</div>
</form>
)
}
@@ -0,0 +1,38 @@
'use client'
import { useActionState } from 'react'
import { useTranslations } from 'next-intl'
import { TbTrash } from 'react-icons/tb'
import { AdminSubmit } from './AdminSubmit'
import { errorClass, hintClass } from './styles'
import { removePostType } from '~/lib/admin-actions'
import { emptyAdminFormState } from '~/lib/admin-forms'
export type PostTypeDeleteButtonProps = {
id: string
postCount: number
}
export function PostTypeDeleteButton({ id, postCount }: PostTypeDeleteButtonProps) {
const t = useTranslations('admin.postTypes')
const messages = useTranslations('admin.messages')
const [state, formAction] = useActionState(removePostType, emptyAdminFormState)
if (postCount > 0) {
return <span className={hintClass}>{t('deleteBlocked', { count: postCount })}</span>
}
return (
<form action={formAction} className="flex flex-col gap-2">
<input type="hidden" name="id" value={id} />
<AdminSubmit quiet pendingLabel={t('deleting')} icon={<TbTrash className="size-4" />}>
{t('delete')}
</AdminSubmit>
{state.status === 'error' && state.message ? (
<span role="alert" className={errorClass}>
{messages(state.message)}
</span>
) : null}
</form>
)
}
+156
View File
@@ -0,0 +1,156 @@
'use client'
import { useActionState, useState } from 'react'
import Link from 'next/link'
import { useTranslations } from 'next-intl'
import { TbDeviceFloppy } from 'react-icons/tb'
import { AdminColorField } from './AdminColorField'
import { AdminField } from './AdminField'
import { AdminFormMessage } from './AdminFormMessage'
import { AdminSubmit } from './AdminSubmit'
import { checkboxClass, hintClass, inputClass, quietLinkClass } from './styles'
import { savePostType } from '~/lib/admin-actions'
import { emptyAdminFormState } from '~/lib/admin-forms'
import { adminPostTypesPath } from '~/lib/admin-routes'
import { defaultPostTypeColor, maxPostTypeLabelLength, maxPostTypeSort } from '~/domain/post-type'
import { slugify } from '~/domain/slug'
export type PostTypeFormValues = {
id: string
key: string
labelDe: string
labelEn: string
color: string
sort: number
isActive: boolean
}
export type PostTypeFormProps = {
postType?: PostTypeFormValues
locked?: boolean
}
export function PostTypeForm({ postType, locked = false }: PostTypeFormProps) {
const t = useTranslations('admin.postTypes')
const errors = useTranslations('admin.errors')
const [state, formAction] = useActionState(savePostType, emptyAdminFormState)
const [labelDe, setLabelDe] = useState(postType?.labelDe ?? '')
const [labelEn, setLabelEn] = useState(postType?.labelEn ?? '')
const [key, setKey] = useState(postType?.key ?? '')
const [keyTouched, setKeyTouched] = useState((postType?.key ?? '') !== '')
const [color, setColor] = useState(postType?.color ?? defaultPostTypeColor)
const [sort, setSort] = useState(String(postType?.sort ?? 0))
const [isActive, setIsActive] = useState(postType?.isActive ?? true)
function fieldError(field: string): string | undefined {
const value = state.fields?.[field]
return value ? errors(value) : undefined
}
return (
<form action={formAction} className="flex flex-col gap-8">
{postType ? <input type="hidden" name="id" value={postType.id} /> : null}
<AdminFormMessage state={state} />
<div className="grid gap-6 md:grid-cols-2">
<AdminField id="labelDe" label={t('fields.labelDe')} error={fieldError('labelDe')}>
<input
id="labelDe"
name="labelDe"
value={labelDe}
required
maxLength={maxPostTypeLabelLength}
autoComplete="off"
onChange={event => {
setLabelDe(event.target.value)
if (!keyTouched) {
setKey(slugify(event.target.value))
}
}}
className={inputClass}
/>
</AdminField>
<AdminField id="labelEn" label={t('fields.labelEn')} error={fieldError('labelEn')}>
<input
id="labelEn"
name="labelEn"
value={labelEn}
maxLength={maxPostTypeLabelLength}
autoComplete="off"
onChange={event => setLabelEn(event.target.value)}
className={inputClass}
/>
</AdminField>
<AdminField id="key" label={t('fields.key')} hint={t('hints.key')} error={fieldError('key')}>
<input
id="key"
name="key"
value={key}
spellCheck={false}
autoComplete="off"
onChange={event => {
setKeyTouched(true)
setKey(event.target.value)
}}
className={`${inputClass} font-mono`}
/>
</AdminField>
<AdminColorField
id="color"
name="color"
label={t('fields.color')}
pickerLabel={t('fields.colorPicker')}
value={color}
onChange={setColor}
hint={t('hints.color')}
error={fieldError('color')}
/>
<AdminField id="sort" label={t('fields.sort')} hint={t('hints.sort')} error={fieldError('sort')}>
<input
id="sort"
name="sort"
type="number"
min={0}
max={maxPostTypeSort}
step={1}
value={sort}
onChange={event => setSort(event.target.value)}
className={`${inputClass} font-mono tabular-nums`}
/>
</AdminField>
<div className="flex min-w-0 flex-col gap-2">
<label htmlFor="isActive" className="flex items-center gap-3">
<input
id="isActive"
name="isActive"
type="checkbox"
checked={isActive}
onChange={event => setIsActive(event.target.checked)}
className={checkboxClass}
/>
<span className="font-mono text-micro font-semibold uppercase tracking-label text-ink-3">
{t('fields.isActive')}
</span>
</label>
<span className={hintClass}>{locked ? t('hints.activeInUse') : t('hints.isActive')}</span>
</div>
</div>
<div className="flex flex-wrap items-center gap-6">
<AdminSubmit pendingLabel={t('saving')} icon={<TbDeviceFloppy className="size-4" />}>
{postType ? t('save') : t('create')}
</AdminSubmit>
<Link href={adminPostTypesPath} className={quietLinkClass}>
{t('back')}
</Link>
</div>
</form>
)
}
+196
View File
@@ -0,0 +1,196 @@
'use client'
import { useActionState, useState } from 'react'
import Link from 'next/link'
import { useTranslations } from 'next-intl'
import { TbDeviceFloppy } from 'react-icons/tb'
import { AdminColorField } from './AdminColorField'
import { AdminField } from './AdminField'
import { AdminFormMessage } from './AdminFormMessage'
import { AdminSubmit } from './AdminSubmit'
import { checkboxClass, hintClass, inputClass, labelClass, quietLinkClass, selectClass, textareaClass } from './styles'
import { saveProject } from '~/lib/admin-actions'
import { emptyAdminFormState } from '~/lib/admin-forms'
import { adminProjectsPath } from '~/lib/admin-routes'
import { defaultColor } from '~/domain/color'
import { slugify } from '~/domain/slug'
export type ProjectFormValues = {
id: string
name: string
slug: string
code: string
description: string
color: string
brandId: string
sort: number
isActive: boolean
}
export type ProjectFormProps = {
brands: { id: string, name: string }[]
project?: ProjectFormValues
}
export function ProjectForm({ brands, project }: ProjectFormProps) {
const t = useTranslations('admin.projects')
const errors = useTranslations('admin.errors')
const [state, formAction] = useActionState(saveProject, emptyAdminFormState)
const [name, setName] = useState(project?.name ?? '')
const [slug, setSlug] = useState(project?.slug ?? '')
const [slugTouched, setSlugTouched] = useState((project?.slug ?? '') !== '')
const [code, setCode] = useState(project?.code ?? '')
const [description, setDescription] = useState(project?.description ?? '')
const [color, setColor] = useState(project?.color ?? defaultColor)
const [brandId, setBrandId] = useState(project?.brandId ?? brands[0]?.id ?? '')
const [sort, setSort] = useState(String(project?.sort ?? 0))
const [active, setActive] = useState(project?.isActive ?? true)
function fieldError(field: string): string | undefined {
const key = state.fields?.[field]
return key ? errors(key) : undefined
}
function changeName(value: string) {
setName(value)
if (!slugTouched) {
setSlug(slugify(value))
}
}
return (
<form action={formAction} className="flex flex-col gap-8">
{project ? <input type="hidden" name="id" value={project.id} /> : null}
<AdminFormMessage state={state} />
<div className="grid gap-6 md:grid-cols-2">
<AdminField id="name" label={t('fields.name')} error={fieldError('name')}>
<input
id="name"
name="name"
value={name}
required
autoComplete="off"
onChange={event => changeName(event.target.value)}
className={inputClass}
/>
</AdminField>
<AdminField id="slug" label={t('fields.slug')} hint={t('hints.slug')} error={fieldError('slug')}>
<input
id="slug"
name="slug"
value={slug}
spellCheck={false}
autoComplete="off"
onChange={event => {
setSlugTouched(true)
setSlug(event.target.value)
}}
className={`${inputClass} font-mono`}
/>
</AdminField>
<AdminField id="code" label={t('fields.code')} hint={t('hints.code')} error={fieldError('code')}>
<input
id="code"
name="code"
value={code}
maxLength={4}
spellCheck={false}
autoComplete="off"
onChange={event => setCode(event.target.value.toUpperCase())}
className={`${inputClass} font-mono uppercase`}
/>
</AdminField>
<AdminField id="brandId" label={t('fields.brand')} error={fieldError('brandId')}>
<select
id="brandId"
name="brandId"
value={brandId}
onChange={event => setBrandId(event.target.value)}
className={selectClass}
>
<option value="">{t('fields.brandEmpty')}</option>
{brands.map(brand => (
<option key={brand.id} value={brand.id}>
{brand.name}
</option>
))}
</select>
</AdminField>
<AdminColorField
id="color"
name="color"
label={t('fields.color')}
pickerLabel={t('fields.colorPicker')}
value={color}
onChange={setColor}
hint={t('hints.color')}
error={fieldError('color')}
/>
<AdminField id="sort" label={t('fields.sort')} hint={t('hints.sort')} error={fieldError('sort')}>
<input
id="sort"
name="sort"
type="number"
min={0}
max={9999}
step={1}
value={sort}
onChange={event => setSort(event.target.value)}
className={`${inputClass} font-mono tabular-nums`}
/>
</AdminField>
<AdminField
id="description"
label={t('fields.description')}
hint={t('hints.description')}
error={fieldError('description')}
className="md:col-span-2"
>
<textarea
id="description"
name="description"
rows={3}
value={description}
onChange={event => setDescription(event.target.value)}
className={textareaClass}
/>
</AdminField>
</div>
<div className="flex flex-col gap-2 border-t border-rule pt-6">
<span className="flex items-center gap-3">
<input
id="isActive"
name="isActive"
type="checkbox"
checked={active}
onChange={event => setActive(event.target.checked)}
className={checkboxClass}
/>
<label htmlFor="isActive" className={labelClass}>
{t('fields.active')}
</label>
</span>
<span className={hintClass}>{t('hints.active')}</span>
</div>
<div className="flex flex-wrap items-center gap-6">
<AdminSubmit pendingLabel={t('saving')} icon={<TbDeviceFloppy className="size-4" />}>
{project ? t('save') : t('create')}
</AdminSubmit>
<Link href={adminProjectsPath} className={quietLinkClass}>
{t('back')}
</Link>
</div>
</form>
)
}
+19
View File
@@ -0,0 +1,19 @@
import { getTranslations } from 'next-intl/server'
import { TbLogout2 } from 'react-icons/tb'
import { signOut } from '~/lib/auth-actions'
export async function SignOutButton() {
const t = await getTranslations('admin')
return (
<form action={signOut}>
<button
type="submit"
className="inline-flex cursor-pointer items-center gap-2 border border-rule bg-transparent px-3 py-2 font-mono text-micro font-semibold uppercase tracking-label text-ink-2 hover:border-signal hover:text-signal"
>
<TbLogout2 aria-hidden="true" className="size-4 shrink-0" />
{t('signOut')}
</button>
</form>
)
}
+46
View File
@@ -0,0 +1,46 @@
'use client'
import { useActionState } from 'react'
import { useTranslations } from 'next-intl'
import { TbShieldCheck, TbShieldOff } from 'react-icons/tb'
import { AdminSubmit } from './AdminSubmit'
import { errorClass, hintClass } from './styles'
import { setUserAdminFlag } from '~/lib/admin-actions'
import { emptyAdminFormState } from '~/lib/admin-forms'
export type UserAdminToggleProps = {
userId: string
isAdmin: boolean
isSelf: boolean
}
export function UserAdminToggle({ userId, isAdmin, isSelf }: UserAdminToggleProps) {
const t = useTranslations('admin.users')
const messages = useTranslations('admin.messages')
const [state, formAction] = useActionState(setUserAdminFlag, emptyAdminFormState)
return (
<form action={formAction} className="flex flex-col gap-2">
<input type="hidden" name="userId" value={userId} />
<input type="hidden" name="isAdmin" value={isAdmin ? '' : 'on'} />
<span className="flex flex-wrap items-center gap-4">
<AdminSubmit
quiet
disabled={isSelf}
pendingLabel={t('saving')}
icon={isAdmin ? <TbShieldOff className="size-4" /> : <TbShieldCheck className="size-4" />}
>
{isAdmin ? t('revokeAdmin') : t('grantAdmin')}
</AdminSubmit>
{isSelf ? <span className={hintClass}>{t('selfHint')}</span> : null}
</span>
{state.status === 'error' && state.message ? (
<span role="alert" className={errorClass}>
{messages(state.message)}
</span>
) : null}
</form>
)
}
+56
View File
@@ -0,0 +1,56 @@
'use client'
import { useActionState } from 'react'
import { useTranslations } from 'next-intl'
import { TbMinus, TbPlus } from 'react-icons/tb'
import { AdminSubmit } from './AdminSubmit'
import { errorClass } from './styles'
import { grantModerator, revokeModerator } from '~/lib/admin-actions'
import { emptyAdminFormState } from '~/lib/admin-forms'
import { projectStyle, projectSurface } from '~/components/ui/Plate'
export type UserRoleRowProps = {
userId: string
projectId: string
projectName: string
projectCode: string
color: string
assigned: boolean
implicit: boolean
}
export function UserRoleRow({ userId, projectId, projectName, projectCode, color, assigned, implicit }: UserRoleRowProps) {
const t = useTranslations('admin.users')
const messages = useTranslations('admin.messages')
const [state, formAction] = useActionState(assigned ? revokeModerator : grantModerator, emptyAdminFormState)
return (
<li className="flex flex-wrap items-center gap-x-4 gap-y-2 border-b border-rule py-3 last:border-b-0">
<span aria-hidden="true" style={projectStyle(color)} className={`size-2.5 shrink-0 ${projectSurface}`} />
<span className="font-display text-base font-semibold text-ink">{projectName}</span>
<span className="font-mono text-micro font-semibold uppercase tracking-mark text-ink-3">{projectCode}</span>
<span aria-hidden="true" className="h-px min-w-6 flex-1 bg-rule" />
{implicit ? (
<span className="font-mono text-micro uppercase tracking-mark text-ink-3">{t('implicit')}</span>
) : null}
<form action={formAction} className="flex flex-col gap-2">
<input type="hidden" name="userId" value={userId} />
<input type="hidden" name="projectId" value={projectId} />
<AdminSubmit
quiet
pendingLabel={t('saving')}
icon={assigned ? <TbMinus className="size-4" /> : <TbPlus className="size-4" />}
>
{assigned ? t('revokeRole') : t('grantRole')}
</AdminSubmit>
{state.status === 'error' && state.message ? (
<span role="alert" className={errorClass}>
{messages(state.message)}
</span>
) : null}
</form>
</li>
)
}
+29
View File
@@ -0,0 +1,29 @@
export const labelClass = 'font-mono text-micro font-semibold uppercase tracking-label text-ink-3'
const fieldClass = 'w-full min-w-0 border-b border-rule bg-transparent px-1 py-2 font-body text-base text-ink placeholder:text-ink-3 focus:border-signal'
export const inputClass = fieldClass
export const selectClass = `cursor-pointer ${fieldClass}`
export const textareaClass = `resize-y ${fieldClass}`
export const checkboxClass = 'size-4 shrink-0 cursor-pointer accent-signal'
export const fileClass = 'min-w-0 cursor-pointer border border-dashed border-rule bg-transparent px-3 py-3 font-mono text-small text-ink-2 file:mr-4 file:cursor-pointer file:border-0 file:bg-ink file:px-3 file:py-1.5 file:font-mono file:text-micro file:font-semibold file:uppercase file:tracking-label file:text-paper hover:border-signal'
export const primaryButtonClass = 'inline-flex cursor-pointer items-center gap-2 bg-ink px-4 py-2.5 font-mono text-micro font-semibold uppercase tracking-label text-paper no-underline hover:bg-signal disabled:cursor-progress disabled:opacity-60'
export const ghostButtonClass = 'inline-flex cursor-pointer items-center gap-2 border border-rule bg-transparent px-3 py-2 font-mono text-micro font-semibold uppercase tracking-label text-ink-2 no-underline hover:border-signal hover:text-signal disabled:cursor-progress disabled:opacity-60'
export const quietLinkClass = 'font-mono text-micro font-semibold uppercase tracking-label text-ink-2 no-underline hover:text-signal'
export const headCellClass = 'whitespace-nowrap border-b border-rule pb-2 pr-6 text-left font-mono text-micro font-semibold uppercase tracking-label text-ink-3 last:pr-0'
export const cellClass = 'border-b border-rule py-3 pr-6 align-middle text-small text-ink-2 last:pr-0'
export const metaClass = 'font-mono text-micro uppercase tracking-mark text-ink-3 tabular-nums'
export const errorClass = 'font-mono text-small text-signal'
export const hintClass = 'text-small text-pretty text-ink-3'
+81
View File
@@ -0,0 +1,81 @@
import Link from 'next/link'
import type { Route } from 'next'
import { useTranslations } from 'next-intl'
import { Scroller } from '~/components/ui/Scroller'
import { SectionLabel } from '~/components/ui/SectionLabel'
import type { ArchiveMonth } from '~/data/repositories/archive'
export type ArchiveMonthsProps = {
months: ArchiveMonth[]
hrefFor: (month: ArchiveMonth) => Route
activeYear?: number
activeMonth?: number
}
type YearGroup = {
year: number
months: ArchiveMonth[]
}
function group(months: ArchiveMonth[]): YearGroup[] {
const years: YearGroup[] = []
for (const month of months) {
const last = years.at(-1)
if (last && last.year === month.year) {
last.months.push(month)
continue
}
years.push({ year: month.year, months: [month] })
}
return years
}
export function ArchiveMonths({ months, hrefFor, activeYear, activeMonth }: ArchiveMonthsProps) {
const t = useTranslations('archive')
if (months.length <= 1) {
return null
}
return (
<section className="flex flex-col gap-5 border-t border-rule py-10">
<SectionLabel>{t('title')}</SectionLabel>
{group(months).map(year => (
<div key={year.year} className="flex flex-col gap-2 md:flex-row md:gap-6">
<span className="font-mono text-small font-semibold text-ink-3 tabular-nums md:w-16 md:shrink-0">
{year.year}
</span>
<Scroller className="min-w-0 max-w-full md:flex-1" options={{ overflow: { y: 'hidden' } }}>
<div className="flex w-max items-center gap-1 pb-2">
{year.months.map(month => {
const active = month.year === activeYear && month.month === activeMonth
return (
<Link
key={`${month.year}-${month.month}`}
href={hrefFor(month)}
aria-current={active ? 'page' : undefined}
className={`inline-flex items-baseline gap-2 border px-2 py-1 font-mono text-micro font-semibold uppercase tracking-chip no-underline ${
active
? 'border-ink bg-ink text-paper'
: 'border-transparent text-ink-2 hover:border-rule hover:text-ink'
}`}
>
{t(`months.${month.month}`)}
<span className={`tabular-nums ${active ? 'text-paper/70' : 'text-ink-3'}`}>
{month.postCount}
</span>
</Link>
)
})}
</div>
</Scroller>
</div>
))}
</section>
)
}
+27
View File
@@ -0,0 +1,27 @@
import type { ReactNode } from 'react'
import { TbArrowNarrowRight } from 'react-icons/tb'
export type EmptyNoticeProps = {
children: ReactNode
title?: ReactNode
action?: ReactNode
}
export function EmptyNotice({ children, title, action }: EmptyNoticeProps) {
return (
<div className="my-10 flex flex-col gap-3 border-l-2 border-rule bg-surface px-6 py-8">
{title ? (
<span className="font-display text-micro font-semibold uppercase tracking-label text-ink-3">
{title}
</span>
) : null}
<p className="m-0 max-w-read text-base text-pretty text-ink-2">{children}</p>
{action ? (
<span className="inline-flex items-center gap-2 font-mono text-small text-ink-2">
<TbArrowNarrowRight aria-hidden="true" className="size-4 shrink-0 text-ink-3" />
{action}
</span>
) : null}
</div>
)
}
+30
View File
@@ -0,0 +1,30 @@
import type { ReactNode } from 'react'
import { EntryRow } from './EntryRow'
import { SectionLabel } from '~/components/ui/SectionLabel'
import type { PostListItem } from '~/data/repositories/posts'
export type EntryListProps = {
items: PostListItem[]
showProject?: boolean
label?: ReactNode
meta?: ReactNode
highlight?: string
className?: string
}
export function EntryList({ items, showProject = true, label, meta, highlight, className }: EntryListProps) {
if (items.length === 0) {
return null
}
return (
<section className={`flex flex-col gap-3 ${className ?? ''}`}>
{label ? <SectionLabel meta={meta}>{label}</SectionLabel> : null}
<div className="flex flex-col">
{items.map((item, index) => (
<EntryRow key={item.id} item={item} index={index} showCode={showProject} highlight={highlight} />
))}
</div>
</section>
)
}
+69
View File
@@ -0,0 +1,69 @@
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 { postPath } from '~/lib/routes'
import type { PostListItem } from '~/data/repositories/posts'
export type EntryRowProps = {
item: PostListItem
index?: number
showCode?: boolean
highlight?: string
className?: string
}
const stagger = 40
const maxStagger = 12
export function EntryRow({ item, index = 0, 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 ?? ''}`}
>
<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 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">
<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">
{item.teaser}
</span>
) : (
<span aria-hidden="true" className="hidden xl:col-span-3 xl: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">
{item.publishAt ? <EntryDate date={item.publishAt} /> : null}
{item.authorName ? <AuthorMark name={item.authorName} /> : null}
</span>
</span>
</Link>
)
}
+91
View File
@@ -0,0 +1,91 @@
import Link from 'next/link'
import { useTranslations } from 'next-intl'
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 { Stamp } from '~/components/ui/Stamp'
import { postPath, projectPath } from '~/lib/routes'
import type { PostListItem } from '~/data/repositories/posts'
export type FeatureEntryProps = {
item: PostListItem
cover?: CoverMedia | null
newest?: boolean
showProject?: boolean
as?: 'h1' | 'h2'
className?: string
}
const coverSizes = '(min-width: 64rem) 40rem, (min-width: 46rem) 90vw, 100vw'
export function FeatureEntry({
item,
cover,
newest = false,
showProject = true,
as: Title = 'h2',
className,
}: FeatureEntryProps) {
const t = useTranslations('entry')
const source = { code: item.projectCode, slug: item.projectSlug }
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 ?? ''}`}
>
<Cover
media={cover}
sizes={coverSizes}
priority
className="aspect-cover w-full border-b border-rule"
fallback={<Plate project={source} color={item.projectColor} number={item.number} size="lead" className="w-full" />}
/>
<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}
</div>
<Title className="relative text-hero font-bold text-balance">
<Link href={postPath(item.projectSlug, item.slug)} className="text-ink no-underline">
{item.title}
</Link>
</Title>
{item.teaser ? (
<p className="relative m-0 max-w-read text-lead text-pretty text-ink-2">{item.teaser}</p>
) : null}
<div className="relative mt-auto flex flex-wrap items-center gap-x-3 gap-y-2 pt-2 font-mono text-small text-ink-3">
{item.authorName ? (
<span className="inline-flex items-center gap-2 text-ink-2">
<AuthorMark name={item.authorName} />
{item.authorName}
</span>
) : null}
{showProject ? (
<>
{item.authorName ? <span aria-hidden="true" className="text-rule">/</span> : null}
<Link href={projectPath(item.projectSlug)} className="text-ink-2 no-underline hover:text-ink">
{item.projectName}
</Link>
</>
) : null}
<span className="sr-only">{t('number', { number: item.number })}</span>
</div>
</div>
</article>
)
}
+50
View File
@@ -0,0 +1,50 @@
import Link from 'next/link'
import type { Route } from 'next'
import { useFormatter, useTranslations } from 'next-intl'
import { SectionLabel } from '~/components/ui/SectionLabel'
import { monthOnlyFormat, monthYearFormat } from '~/lib/dates'
export type FilterBarProps = {
year?: number
month?: number
resetHref: Route
}
export function periodLabel(
format: ReturnType<typeof useFormatter>,
year: number | undefined,
month: number | undefined,
): string | undefined {
if (month !== undefined) {
const reference = new Date(Date.UTC(year ?? new Date().getUTCFullYear(), month - 1, 1))
return format.dateTime(reference, year !== undefined ? monthYearFormat : monthOnlyFormat)
}
return year !== undefined ? String(year) : undefined
}
export function FilterBar({ year, month, resetHref }: FilterBarProps) {
const t = useTranslations('archive')
const format = useFormatter()
const label = periodLabel(format, year, month)
if (label === undefined) {
return null
}
return (
<div className="pt-8">
<SectionLabel
as="h2"
meta={
<Link href={resetHref} className="text-ink-2 no-underline hover:text-ink">
{t('all')}
</Link>
}
>
{t('filtered', { period: label })}
</SectionLabel>
</div>
)
}
+19
View File
@@ -0,0 +1,19 @@
import type { ReactNode } from 'react'
export type MastheadProps = {
title: ReactNode
intro?: string | null
eyebrow?: ReactNode
children?: ReactNode
}
export function Masthead({ title, intro, eyebrow, children }: MastheadProps) {
return (
<section className="flex flex-col gap-4 pb-8 pt-12">
{eyebrow ? <div>{eyebrow}</div> : null}
<h1 className="text-hero font-bold text-balance">{title}</h1>
{intro ? <p className="m-0 max-w-read text-lead text-pretty text-ink-2">{intro}</p> : null}
{children ? <div className="grid gap-2 pt-2">{children}</div> : null}
</section>
)
}
+58
View File
@@ -0,0 +1,58 @@
import Link from 'next/link'
import type { Route } from 'next'
import { useTranslations } from 'next-intl'
import { TbArrowLeft, TbArrowRight } from 'react-icons/tb'
import { pageCount } from '~/lib/query'
export type PaginationProps = {
page: number
perPage: number
total: number
hrefFor: (page: number) => Route
}
const shape = 'inline-flex items-center gap-2 font-mono text-micro font-semibold uppercase tracking-mark no-underline'
export function Pagination({ page, perPage, total, hrefFor }: PaginationProps) {
const t = useTranslations('pagination')
const pages = pageCount(total, perPage)
if (total === 0 || pages <= 1) {
return null
}
const from = (page - 1) * perPage + 1
const to = Math.min(page * perPage, total)
const hasPrevious = page > 1
const hasNext = page < pages
return (
<nav className="flex flex-wrap items-center gap-4 border-t border-rule py-6">
{hasPrevious ? (
<Link href={hrefFor(page - 1)} className={`${shape} text-ink-2 hover:text-ink`}>
<TbArrowLeft aria-hidden="true" className="size-4" />
{t('previous')}
</Link>
) : (
<span aria-disabled="true" className={`${shape} text-ink-3`}>
<TbArrowLeft aria-hidden="true" className="size-4" />
{t('previous')}
</span>
)}
<span className="mx-auto font-mono text-small text-ink-3 tabular-nums">
{t('status', { from, to, total })}
</span>
{hasNext ? (
<Link href={hrefFor(page + 1)} className={`${shape} text-ink-2 hover:text-ink`}>
{t('next')}
<TbArrowRight aria-hidden="true" className="size-4" />
</Link>
) : (
<span aria-disabled="true" className={`${shape} text-ink-3`}>
{t('next')}
<TbArrowRight aria-hidden="true" className="size-4" />
</span>
)}
</nav>
)
}
+151
View File
@@ -0,0 +1,151 @@
import Link from 'next/link'
import { useFormatter, useLocale, useTranslations } from 'next-intl'
import { TbArrowNarrowLeft } from 'react-icons/tb'
import { EmptyNotice } from './EmptyNotice'
import { BlockRenderer } from '~/components/blocks/BlockRenderer'
import { Wrap } from '~/components/layout/Wrap'
import { AuthorMark } from '~/components/ui/AuthorMark'
import { Badge } from '~/components/ui/Badge'
import { Cover } from '~/components/ui/Cover'
import { EntryDate } from '~/components/ui/EntryDate'
import { FieldRow } from '~/components/ui/FieldRow'
import { entryMark, entryNumber, Plate, projectStyle, projectSurface } from '~/components/ui/Plate'
import { SectionLabel } from '~/components/ui/SectionLabel'
import { Watermark } from '~/components/ui/Watermark'
import { postTypeLabel } from '~/domain/post-type'
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 type { Locale } from '~/i18n/config'
export type PostDetailProps = {
detail: ArchivePostDetail
}
const column = 'mx-auto w-full max-w-read'
const coverSizes = '(min-width: 63rem) 63rem, 100vw'
export function PostDetail({ detail }: PostDetailProps) {
const t = useTranslations('post')
const entry = useTranslations('entry')
const locale = useLocale() as Locale
const format = useFormatter()
const { post, type, project, brand, blocks, media, cover } = detail
const content = contentBlocks(blocks, post.teaser, post.coverMediaId)
const minutes = readingMinutes(blocks, post.teaser)
const caption = cover?.caption ?? undefined
return (
<main id="content">
<Wrap>
<article style={projectStyle(project.color)} className="flex flex-col pb-16 pt-8">
<figure className="m-0 flex flex-col gap-2">
<Cover
media={cover ? toCover(cover) : null}
sizes={coverSizes}
priority
className="aspect-cover w-full border border-rule bg-surface shadow-lift"
fallback={
<Plate
project={project}
color={project.color}
number={post.number}
size="lead"
className="w-full"
/>
}
/>
{caption ? (
<figcaption className="font-mono text-micro text-ink-3">{caption}</figcaption>
) : null}
</figure>
<header className="relative mt-8 overflow-hidden">
<Watermark number={post.number} className="-right-2 -top-6" />
<div className={`relative flex flex-wrap items-center gap-x-4 gap-y-3 ${column}`}>
<span className="inline-flex items-center gap-2 font-mono text-micro">
<span aria-hidden="true" className={`size-2.5 shrink-0 ${projectSurface}`} />
<span className="font-semibold uppercase tracking-mark text-ink-2 tabular-nums">
{entryMark(project, post.number)}
</span>
</span>
<Link
href={projectPath(project.slug)}
className="font-mono text-micro uppercase tracking-mark text-ink-2 no-underline hover:text-ink"
>
{brand.name} / {project.name}
</Link>
<Badge type={type} className="ml-auto" />
</div>
<h1 className={`relative mt-5 text-hero font-bold text-balance ${column}`}>{post.title}</h1>
<div
className={`relative mt-4 flex flex-wrap items-center gap-x-3 gap-y-2 font-mono text-small text-ink-3 ${column}`}
>
{post.publishAt ? <EntryDate date={post.publishAt} long /> : null}
{post.authorName ? (
<>
<span aria-hidden="true" className="text-rule">/</span>
<span className="inline-flex items-center gap-2 text-ink-2">
<AuthorMark name={post.authorName} />
{post.authorName}
</span>
</>
) : null}
<span aria-hidden="true" className="text-rule">/</span>
<span className="tabular-nums">{t('readingTime', { minutes })}</span>
</div>
</header>
{post.teaser ? (
<p className={`m-0 mt-6 text-lead text-pretty text-ink-2 ${column}`}>{post.teaser}</p>
) : null}
{content.length > 0 ? (
<div className={`mt-10 ${column}`}>
<BlockRenderer blocks={content} media={media} />
</div>
) : blocks.length === 0 && !post.teaser ? (
<div className={`mt-10 ${column}`}>
<EmptyNotice title={t('emptyTitle')}>{t('empty')}</EmptyNotice>
</div>
) : null}
<footer className={`mt-14 border-t border-rule pt-8 ${column}`}>
<SectionLabel as="h2" className="mb-5">{t('details')}</SectionLabel>
<div className="grid gap-2">
<FieldRow label={t('fields.project')}>{project.name}</FieldRow>
{post.publishAt ? (
<FieldRow label={t('fields.published')}>
<time dateTime={post.publishAt.toISOString()}>
{format.dateTime(post.publishAt, longDateTimeFormat)}
</time>
</FieldRow>
) : null}
<FieldRow label={t('fields.type')}>{postTypeLabel(type, locale)}</FieldRow>
{post.authorName ? <FieldRow label={t('fields.author')}>{post.authorName}</FieldRow> : null}
{media.length > 0 ? (
<FieldRow label={t('fields.images')}>{entry('images', { count: media.length })}</FieldRow>
) : null}
<FieldRow label={t('fields.number')}>{entryNumber(post.number)}</FieldRow>
</div>
<div className="mt-8">
<Link
href={projectPath(project.slug)}
className="inline-flex items-center gap-2 font-mono text-small text-ink-2 no-underline hover:text-ink"
>
<TbArrowNarrowLeft aria-hidden="true" className="size-4 shrink-0" />
{t('back', { project: project.name })}
</Link>
</div>
</footer>
</article>
</Wrap>
</main>
)
}
+178
View File
@@ -0,0 +1,178 @@
import Link from 'next/link'
import type { Route } from 'next'
import { useTranslations } from 'next-intl'
import { TbArrowNarrowLeft } from 'react-icons/tb'
import { ArchiveMonths } from './ArchiveMonths'
import { EmptyNotice } from './EmptyNotice'
import { EntryList } from './EntryList'
import { FeatureEntry } from './FeatureEntry'
import { FilterBar } from './FilterBar'
import { Pagination } from './Pagination'
import { SideEntries } from './SideEntries'
import { TypeFilter } from './TypeFilter'
import { ViewHeader } from './ViewHeader'
import { Wrap } from '~/components/layout/Wrap'
import { Plate } from '~/components/ui/Plate'
import { monthPath, overviewPath, projectPath, yearPath } from '~/lib/routes'
import { perPage } from '~/lib/query'
import type { CoverMedia } from '~/components/ui/Cover'
import type { ArchiveListResult, ArchiveMonth, ArchiveStats, PostTypeCount } from '~/data/repositories/archive'
import type { ProjectView } from '~/lib/project-view'
export type ProjectArchiveProps = {
view: ProjectView
stats: ArchiveStats
months: ArchiveMonth[]
list: ArchiveListResult
typeCounts: PostTypeCount[]
page: number
covers?: Map<string, CoverMedia>
highest?: number
year?: number
month?: number
type?: string
}
const sideCount = 2
const sideThreshold = 4
export function ProjectArchive({
view,
stats,
months,
list,
typeCounts,
page,
covers,
highest,
year,
month,
type,
}: ProjectArchiveProps) {
const t = useTranslations('overview')
const archive = useTranslations('archive')
const entry = useTranslations('entry')
const { brand, project } = view
const period = year !== undefined || month !== undefined
const narrowed = period || type !== undefined
const beyond = list.total > 0 && list.items.length === 0
const emptyTitle = beyond ? t('emptyPageTitle') : type && list.total === 0 ? t('emptyTypeTitle') : period ? t('emptyFilteredTitle') : t('emptyTitle')
const emptyText = beyond ? t('emptyPage') : type && list.total === 0 ? t('emptyType') : period ? t('emptyFiltered') : t('emptyProject')
const pathFor = (options: { page?: number, type?: string }): Route => {
if (year !== undefined && month !== undefined) {
return monthPath(project.slug, year, month, options)
}
if (year !== undefined) {
return yearPath(project.slug, year, options)
}
return projectPath(project.slug, options)
}
const lead = page === 1 && !narrowed ? list.items[0] : undefined
const side = lead && list.items.length >= sideThreshold ? list.items.slice(1, 1 + sideCount) : []
const rows = lead ? list.items.slice(1 + side.length) : list.items
const updated = page === 1 ? list.items[0]?.publishAt ?? null : stats.to ?? null
const typeTotal = typeCounts.reduce((sum, count) => sum + count.postCount, 0)
return (
<main id="content">
<Wrap>
<ViewHeader
title={project.name}
intro={project.description}
updated={updated}
total={list.total}
eyebrow={
<>
<span>{brand.name}</span>
<Link
href={overviewPath()}
className="inline-flex items-center gap-2 text-ink-3 no-underline hover:text-ink"
>
<TbArrowNarrowLeft aria-hidden="true" className="size-4 shrink-0" />
{t('allProjects')}
</Link>
</>
}
>
<div className="flex flex-wrap items-center gap-x-5 gap-y-3">
<Plate
project={project}
color={project.color}
number={highest ?? project.postCount}
showNumber={project.postCount > 0}
size="row"
/>
<span className="font-mono text-small text-ink-3 tabular-nums">
{archive('entries', { count: project.postCount })}
</span>
</div>
<TypeFilter
counts={typeCounts}
total={typeTotal}
active={type}
hrefFor={target => pathFor({ type: target })}
/>
</ViewHeader>
<FilterBar year={year} month={month} resetHref={projectPath(project.slug)} />
{list.items.length === 0 ? (
<EmptyNotice
title={emptyTitle}
action={
<Link
href={beyond ? pathFor({ type }) : narrowed ? projectPath(project.slug) : overviewPath()}
className="font-mono text-small"
>
{beyond ? archive('firstPage') : narrowed ? archive('all') : t('allProjects')}
</Link>
}
>
{emptyText}
</EmptyNotice>
) : (
<div className="flex flex-col gap-10 pb-14 pt-6">
{lead ? (
<div className="flex flex-col gap-6 lg:flex-row lg:items-start">
<FeatureEntry
item={lead}
cover={covers?.get(lead.id)}
newest
showProject={false}
className="min-w-0 flex-1"
/>
<SideEntries items={side} covers={covers} className="lg:w-72 lg:shrink-0" />
</div>
) : null}
<EntryList
items={rows}
showProject={false}
label={lead ? entry('older') : entry('latest')}
/>
<Pagination
page={page}
perPage={perPage}
total={list.total}
hrefFor={target => pathFor({ page: target, type })}
/>
</div>
)}
<ArchiveMonths
months={months}
activeYear={year}
activeMonth={month}
hrefFor={item => monthPath(project.slug, item.year, item.month, { type })}
/>
</Wrap>
</main>
)
}
+78
View File
@@ -0,0 +1,78 @@
import Link from 'next/link'
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 { SectionLabel } from '~/components/ui/SectionLabel'
import { postPath } from '~/lib/routes'
import type { PostListItem } from '~/data/repositories/posts'
export type SideEntriesProps = {
items: PostListItem[]
covers?: Map<string, CoverMedia>
className?: string
}
const coverSizes = '(min-width: 64rem) 6rem, 20vw'
export function SideEntries({ items, covers, className }: SideEntriesProps) {
const t = useTranslations('entry')
if (items.length === 0) {
return null
}
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">
{items.map(item => {
const source = { code: item.projectCode, slug: item.projectSlug }
return (
<li key={item.id}>
<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"
>
<Cover
media={covers?.get(item.id)}
sizes={coverSizes}
className="aspect-video w-24 shrink-0"
fallback={
<Plate
project={source}
color={item.projectColor}
number={item.number}
size="card"
className="w-full"
/>
}
/>
<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>
<Badge type={item.type} />
</span>
<span className="font-display text-small 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>
) : null}
{item.publishAt ? (
<EntryDate date={item.publishAt} className="font-mono text-micro text-ink-3" />
) : null}
</span>
</Link>
</li>
)
})}
</ul>
</aside>
)
}
+72
View File
@@ -0,0 +1,72 @@
import Link from 'next/link'
import type { Route } from 'next'
import { useLocale, useTranslations } from 'next-intl'
import { Scroller } from '~/components/ui/Scroller'
import { postTypeLabel } from '~/domain/post-type'
import type { PostTypeCount } from '~/data/repositories/archive'
import type { Locale } from '~/i18n/config'
export type TypeFilterProps = {
counts: PostTypeCount[]
total: number
active?: string
hrefFor: (type?: string) => Route
}
const shape = 'inline-flex items-baseline gap-2 rounded-sm border px-3 py-1.5 font-display text-micro font-semibold uppercase tracking-chip no-underline whitespace-nowrap transition-colors duration-120 motion-reduce:transition-none'
const tones = {
on: 'border-ink bg-ink text-paper',
off: 'border-rule bg-surface text-ink-2 hover:border-ink-3 hover:text-ink',
}
export function TypeFilter({ counts, total, active, hrefFor }: TypeFilterProps) {
const t = useTranslations('filter')
const locale = useLocale() as Locale
if (counts.length <= 1) {
return null
}
return (
<div className="flex flex-wrap items-baseline gap-x-4 gap-y-2">
<span className="font-mono text-micro font-semibold uppercase tracking-label text-ink-3">{t('type')}</span>
<Scroller className="min-w-0 flex-1" options={{ overflow: { y: 'hidden' } }}>
<div className="flex w-max items-center gap-1.5 pb-1">
<Link
href={hrefFor()}
aria-current={active === undefined ? 'page' : undefined}
className={`${shape} ${active === undefined ? tones.on : tones.off}`}
>
{t('allTypes')}
<span className={`tabular-nums ${active === undefined ? 'text-paper/70' : 'text-ink-3'}`}>
{total}
</span>
</Link>
{counts.map(entry => {
const on = entry.type.key === active
return (
<Link
key={entry.type.id}
href={hrefFor(entry.type.key)}
aria-current={on ? 'page' : undefined}
className={`${shape} ${on ? tones.on : tones.off}`}
>
<span
aria-hidden="true"
style={{ background: entry.type.color }}
className="size-2 shrink-0 self-center"
/>
{postTypeLabel(entry.type, locale)}
<span className={`tabular-nums ${on ? 'text-paper/70' : 'text-ink-3'}`}>
{entry.postCount}
</span>
</Link>
)
})}
</div>
</Scroller>
</div>
)
}
+41
View File
@@ -0,0 +1,41 @@
import type { ReactNode } from 'react'
import { useFormatter, useTranslations } from 'next-intl'
import { dateFormat } from '~/lib/dates'
export type ViewHeaderProps = {
title: ReactNode
intro?: string | null
eyebrow?: ReactNode
updated?: Date | null
total?: number
children?: ReactNode
}
export function ViewHeader({ title, intro, eyebrow, updated, total, children }: ViewHeaderProps) {
const t = useTranslations('overview')
const archive = useTranslations('archive')
const format = useFormatter()
return (
<header className="flex flex-col gap-4 border-b border-rule pb-5 pt-8">
{eyebrow ? (
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 font-mono text-micro uppercase tracking-label text-ink-3">
{eyebrow}
</div>
) : null}
<div className="flex flex-wrap items-baseline justify-between gap-x-6 gap-y-2">
<h1 className="text-title font-bold text-balance">{title}</h1>
<p className="m-0 flex flex-wrap items-baseline gap-x-3 gap-y-1 font-mono text-small text-ink-3 tabular-nums">
{updated ? <span>{t('updated', { date: format.dateTime(updated, dateFormat) })}</span> : null}
{updated && total !== undefined ? <span aria-hidden="true" className="text-rule">/</span> : null}
{total !== undefined ? <span>{archive('entries', { count: total })}</span> : null}
</p>
</div>
{intro ? <p className="m-0 max-w-read text-pretty text-ink-2">{intro}</p> : null}
{children}
</header>
)
}
@@ -0,0 +1,32 @@
import { useTranslations } from 'next-intl'
import { BeforeAfterSlider } from './BeforeAfterSlider'
import { MediaFrame } from './MediaFrame'
import { readMedia, readString } from './data'
import { toCover } from '~/lib/cover'
import type { BlockProps } from './types'
export function BeforeAfterBlock({ data, media }: BlockProps) {
const t = useTranslations('block')
const before = readMedia(media, readString(data, 'beforeMediaId', 'before_media_id', 'before'))
const after = readMedia(media, readString(data, 'afterMediaId', 'after_media_id', 'after'))
if (before === undefined && after === undefined) {
return null
}
if (before === undefined || after === undefined) {
const single = before ?? after
return single ? <MediaFrame item={single} label={before ? t('before') : t('after')} /> : null
}
return (
<BeforeAfterSlider
before={toCover(before)}
after={toCover(after)}
beforeAlt={before.alt ?? ''}
afterAlt={after.alt ?? ''}
caption={after.caption ?? before.caption}
/>
)
}
@@ -0,0 +1,76 @@
'use client'
import { useId, useState, type CSSProperties } from 'react'
import { useTranslations } from 'next-intl'
import { Cover, type CoverMedia } from '~/components/ui/Cover'
export type BeforeAfterSliderProps = {
before: CoverMedia
after: CoverMedia
beforeAlt: string
afterAlt: string
caption?: string | null
}
const sizes = '(min-width: 63rem) 38rem, 92vw'
const start = 50
export function BeforeAfterSlider({ before, after, beforeAlt, afterAlt, caption }: BeforeAfterSliderProps) {
const t = useTranslations('block')
const [position, setPosition] = useState(start)
const inputId = useId()
const clip: CSSProperties = { clipPath: `inset(0 ${100 - position}% 0 0)` }
const handle: CSSProperties = { left: `${position}%` }
return (
<figure className="m-0 flex flex-col gap-3">
<div className="relative aspect-shot w-full overflow-hidden border border-rule bg-surface">
<Cover
media={after}
sizes={sizes}
alt={afterAlt}
className="absolute inset-0 size-full"
fallback={<span className="flex size-full bg-surface" />}
/>
<span style={clip} className="absolute inset-0 block">
<Cover
media={before}
sizes={sizes}
alt={beforeAlt}
className="absolute inset-0 size-full"
fallback={<span className="flex size-full bg-surface" />}
/>
</span>
<span
aria-hidden="true"
style={handle}
className="pointer-events-none absolute inset-y-0 w-0.5 -translate-x-1/2 bg-paper"
/>
<span className="pointer-events-none absolute left-2 top-2 bg-ink/70 px-2 py-1 font-mono text-micro uppercase tracking-chip text-paper">
{t('before')}
</span>
<span className="pointer-events-none absolute right-2 top-2 bg-ink/70 px-2 py-1 font-mono text-micro uppercase tracking-chip text-paper">
{t('after')}
</span>
</div>
<label htmlFor={inputId} className="sr-only">
{t('compare')}
</label>
<input
id={inputId}
type="range"
min={0}
max={100}
step={1}
value={position}
onChange={event => setPosition(Number(event.target.value))}
aria-valuetext={t('comparePosition', { percent: position })}
className="w-full cursor-pointer accent-signal"
/>
{caption ? <figcaption className="font-mono text-micro text-ink-3">{caption}</figcaption> : null}
</figure>
)
}
+26
View File
@@ -0,0 +1,26 @@
import { findBlockComponent } from './registry'
import type { Block, Media } from '~/data/schema'
export type BlockRendererProps = {
blocks: Block[]
media: Media[]
}
export function BlockRenderer({ blocks, media }: BlockRendererProps) {
const index = new Map(media.map(item => [item.id, item]))
return (
<div className="flex flex-col gap-8">
{blocks.map(block => {
const Component = findBlockComponent(block.type)
if (!Component) {
console.warn(`logbuch: unbekannter Blocktyp ${block.type} in Block ${block.id}`)
return null
}
return <Component key={block.id} data={block.data} media={index} />
})}
</div>
)
}
+30
View File
@@ -0,0 +1,30 @@
import { TbAlertTriangle, TbInfoCircle } from 'react-icons/tb'
import { readString } from './data'
import type { BlockProps } from './types'
export function CalloutBlock({ data }: BlockProps) {
const text = readString(data, 'text', 'body')
if (text === undefined) {
return null
}
const tone = readString(data, 'tone', 'variant')
const warning = tone === 'warning' || tone === 'breaking' || tone === 'danger'
const Icon = warning ? TbAlertTriangle : TbInfoCircle
const title = readString(data, 'title', 'label')
return (
<div
className={`flex items-start gap-3 border border-rule border-l-2 bg-surface px-4 py-3 ${
warning ? 'border-l-signal' : 'border-l-link'
}`}
>
<Icon aria-hidden="true" className={`mt-1 size-4 shrink-0 ${warning ? 'text-signal' : 'text-link'}`} />
<span className="flex flex-col gap-1">
{title ? <span className="font-display text-small text-ink">{title}</span> : null}
<span className="text-ink-2">{text}</span>
</span>
</div>
)
}
+27
View File
@@ -0,0 +1,27 @@
import { Scroller } from '~/components/ui/Scroller'
import { readString } from './data'
import type { BlockProps } from './types'
export function CodeBlock({ data }: BlockProps) {
const code = readString(data, 'code', 'text')
const language = readString(data, 'language', 'lang')
if (code === undefined) {
return null
}
return (
<div className="overflow-hidden border border-rule bg-surface">
{language ? (
<div className="border-b border-rule px-4 py-2 font-mono text-micro uppercase tracking-chip text-ink-3">
{language}
</div>
) : null}
<Scroller className="max-h-96">
<pre className="m-0 w-max min-w-full px-4 py-3 font-mono text-small leading-relaxed text-ink">
<code>{code}</code>
</pre>
</Scroller>
</div>
)
}
+22
View File
@@ -0,0 +1,22 @@
import { GalleryFrames, type GalleryPicture } from './GalleryFrames'
import { readMediaList, readStrings } from './data'
import { toCover } from '~/lib/cover'
import type { BlockProps } from './types'
export function GalleryBlock({ data, media }: BlockProps) {
const items = readMediaList(media, readStrings(data, 'mediaIds', 'media_ids', 'items'))
if (items.length === 0) {
return null
}
const pictures: GalleryPicture[] = items.map(item => ({
id: item.id,
media: toCover(item),
alt: item.alt ?? '',
caption: item.caption,
filename: item.originalFilename,
}))
return <GalleryFrames items={pictures} />
}
+205
View File
@@ -0,0 +1,205 @@
'use client'
import { useCallback, useEffect, useRef, useState, type KeyboardEvent } from 'react'
import { useTranslations } from 'next-intl'
import { TbChevronLeft, TbChevronRight, TbX } from 'react-icons/tb'
import { Cover, type CoverMedia } from '~/components/ui/Cover'
export type GalleryPicture = {
id: string
media: CoverMedia
alt: string
caption: string | null
filename: string
}
export type GalleryFramesProps = {
items: GalleryPicture[]
}
const thumbSizes = '(min-width: 63rem) 12rem, 45vw'
const fullSizes = '100vw'
const focusable = 'button:not([disabled])'
export function GalleryFrames({ items }: GalleryFramesProps) {
const t = useTranslations('block')
const [index, setIndex] = useState<number | undefined>(undefined)
const dialogRef = useRef<HTMLDivElement>(null)
const closeRef = useRef<HTMLButtonElement>(null)
const openerRef = useRef<HTMLButtonElement>(null)
const step = useCallback((delta: number) => {
setIndex(current => {
if (current === undefined || items.length === 0) {
return current
}
return (current + delta + items.length) % items.length
})
}, [items.length])
const close = useCallback(() => {
setIndex(undefined)
openerRef.current?.focus()
}, [])
useEffect(() => {
if (index === undefined) {
return
}
closeRef.current?.focus()
const onKey = (event: globalThis.KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault()
close()
return
}
if (event.key === 'ArrowRight') {
event.preventDefault()
step(1)
return
}
if (event.key === 'ArrowLeft') {
event.preventDefault()
step(-1)
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [close, index, step])
const keepFocus = useCallback((event: KeyboardEvent<HTMLDivElement>) => {
if (event.key !== 'Tab' || !dialogRef.current) {
return
}
const nodes = [...dialogRef.current.querySelectorAll<HTMLElement>(focusable)]
const first = nodes[0]
const last = nodes.at(-1)
if (!first || !last) {
return
}
if (event.shiftKey && document.activeElement === first) {
event.preventDefault()
last.focus()
return
}
if (!event.shiftKey && document.activeElement === last) {
event.preventDefault()
first.focus()
}
}, [])
if (items.length === 0) {
return null
}
const current = index === undefined ? undefined : items[index]
return (
<div className="flex flex-col gap-3">
<ul className="m-0 grid list-none grid-cols-2 gap-2 p-0 md:grid-cols-3">
{items.map((item, position) => (
<li key={item.id} className="flex">
<button
type="button"
aria-label={`${t('open')} ${t('position', { index: position + 1, total: items.length })}`}
onClick={event => {
openerRef.current = event.currentTarget
setIndex(position)
}}
className="group flex w-full cursor-pointer flex-col gap-1 border-0 bg-transparent p-0 text-left"
>
<Cover
media={item.media}
sizes={thumbSizes}
alt={item.alt}
className="aspect-shot w-full border border-rule bg-surface transition-colors duration-120 group-hover:border-ink-3 motion-reduce:transition-none"
fallback={
<span className="flex flex-1 items-center justify-center border border-rule bg-surface px-3 py-5 font-mono text-micro text-ink-3">
{item.filename}
</span>
}
/>
{item.caption ? (
<span className="line-clamp-1 font-mono text-micro text-ink-3">{item.caption}</span>
) : null}
</button>
</li>
))}
</ul>
{current ? (
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-label={t('gallery')}
onKeyDown={keepFocus}
className="fixed inset-0 z-40 flex flex-col gap-3 bg-ink/90 p-4 md:p-8"
>
<div className="flex items-center gap-4 font-mono text-micro uppercase tracking-chip text-paper">
<span className="tabular-nums">
{t('position', { index: (index ?? 0) + 1, total: items.length })}
</span>
<button
ref={closeRef}
type="button"
onClick={close}
aria-label={t('close')}
className="ml-auto inline-flex cursor-pointer items-center justify-center border border-paper/40 p-2 text-paper hover:border-paper"
>
<TbX aria-hidden="true" className="size-4" />
</button>
</div>
<div className="flex min-h-0 flex-1 items-center gap-3">
<button
type="button"
onClick={() => step(-1)}
aria-label={t('previous')}
className="inline-flex shrink-0 cursor-pointer items-center justify-center border border-paper/40 p-2 text-paper hover:border-paper"
>
<TbChevronLeft aria-hidden="true" className="size-5" />
</button>
<Cover
media={current.media}
sizes={fullSizes}
alt={current.alt}
fit="contain"
className="h-full min-w-0 flex-1"
fallback={
<span className="flex flex-1 items-center justify-center font-mono text-micro text-paper">
{current.filename}
</span>
}
/>
<button
type="button"
onClick={() => step(1)}
aria-label={t('next')}
className="inline-flex shrink-0 cursor-pointer items-center justify-center border border-paper/40 p-2 text-paper hover:border-paper"
>
<TbChevronRight aria-hidden="true" className="size-5" />
</button>
</div>
{current.caption ? (
<p className="m-0 text-center font-mono text-micro text-paper/80">{current.caption}</p>
) : null}
</div>
) : null}
</div>
)
}
+13
View File
@@ -0,0 +1,13 @@
import { MediaFrame } from './MediaFrame'
import { readMedia, readString } from './data'
import type { BlockProps } from './types'
export function ImageBlock({ data, media }: BlockProps) {
const item = readMedia(media, readString(data, 'mediaId', 'media_id', 'id'))
if (item === undefined) {
return null
}
return <MediaFrame item={item} ratio="wide" />
}
+29
View File
@@ -0,0 +1,29 @@
import { TbArrowUpRight } from 'react-icons/tb'
import { readString } from './data'
import type { BlockProps } from './types'
export function LinkBlock({ data }: BlockProps) {
const url = readString(data, 'url', 'href')
if (url === undefined) {
return null
}
const label = readString(data, 'label', 'title') ?? url
const description = readString(data, 'description', 'teaser')
return (
<a
href={url}
rel="noreferrer noopener"
className="flex items-start gap-3 border border-rule bg-surface px-4 py-3 no-underline hover:border-ink-3"
>
<TbArrowUpRight aria-hidden="true" className="mt-1 size-4 shrink-0 text-ink-3" />
<span className="flex flex-col gap-1">
<span className="font-display text-small text-ink">{label}</span>
{description ? <span className="text-small text-ink-2">{description}</span> : null}
<span className="font-mono text-micro text-ink-3">{url}</span>
</span>
</a>
)
}
+47
View File
@@ -0,0 +1,47 @@
import { Cover } from '~/components/ui/Cover'
import { toCover } from '~/lib/cover'
import type { Media } from '~/data/schema'
export type MediaFrameProps = {
item: Media
ratio?: 'wide' | 'shot'
label?: string
sizes?: string
priority?: boolean
className?: string
}
const ratios = {
wide: 'aspect-cover',
shot: 'aspect-shot',
}
const defaultSizes = '(min-width: 63rem) 38rem, 92vw'
export function MediaFrame({ item, ratio = 'shot', label, sizes, priority = false, className }: MediaFrameProps) {
const caption = item.caption ?? undefined
return (
<figure className={`m-0 flex min-w-0 flex-1 flex-col gap-2 ${className ?? ''}`}>
<Cover
media={toCover(item)}
sizes={sizes ?? defaultSizes}
alt={item.alt ?? ''}
priority={priority}
className={`w-full border border-rule bg-surface ${ratios[ratio]}`}
fallback={
<span className="flex flex-1 items-center justify-center border border-rule bg-surface px-4 py-6 font-mono text-micro text-ink-3">
{item.originalFilename}
</span>
}
/>
{label || caption ? (
<figcaption className="font-mono text-micro text-ink-3">
{label ? <span className="uppercase tracking-chip text-ink-2">{label}</span> : null}
{label && caption ? <span aria-hidden="true"> / </span> : null}
{caption}
</figcaption>
) : null}
</figure>
)
}
+18
View File
@@ -0,0 +1,18 @@
import { readString } from './data'
import type { BlockProps } from './types'
export function QuoteBlock({ data }: BlockProps) {
const text = readString(data, 'text', 'quote')
const source = readString(data, 'source', 'author', 'cite')
if (text === undefined) {
return null
}
return (
<blockquote className="m-0 border-l-2 border-signal pl-5">
<p className="m-0 text-lead text-ink">{text}</p>
{source ? <cite className="mt-2 block font-mono text-micro not-italic text-ink-3">{source}</cite> : null}
</blockquote>
)
}
+22
View File
@@ -0,0 +1,22 @@
import { readString } from './data'
import type { BlockProps } from './types'
export function TextBlock({ data }: BlockProps) {
const text = readString(data, 'text', 'body', 'content')
if (text === undefined) {
return null
}
const paragraphs = text.split(/\n{2,}/).map(part => part.trim()).filter(part => part !== '')
return (
<div className="flex flex-col gap-4">
{paragraphs.map((paragraph, index) => (
<p key={index} className="m-0 text-base text-ink">
{paragraph}
</p>
))}
</div>
)
}
+49
View File
@@ -0,0 +1,49 @@
import { useTranslations } from 'next-intl'
import { TbPlayerPlay } from 'react-icons/tb'
import { MediaFrame } from './MediaFrame'
import { readMedia, readString } from './data'
import { mediaSrc } from '~/lib/media-url'
import type { BlockProps } from './types'
export function VideoBlock({ data, media }: BlockProps) {
const t = useTranslations('block')
const item = readMedia(media, readString(data, 'mediaId', 'media_id'))
const url = readString(data, 'url', 'href')
if (item && item.kind === 'video') {
return (
<figure className="m-0 flex flex-col gap-2">
<video
controls
preload="metadata"
src={mediaSrc(item)}
aria-label={item.alt ?? t('video')}
className="aspect-shot w-full border border-rule bg-surface"
/>
{item.caption ? (
<figcaption className="font-mono text-micro text-ink-3">{item.caption}</figcaption>
) : null}
</figure>
)
}
if (item) {
return <MediaFrame item={item} ratio="wide" label={t('video')} />
}
if (url === undefined) {
return null
}
return (
<a
href={url}
rel="noreferrer noopener"
target="_blank"
className="inline-flex items-center gap-3 self-start border border-rule bg-surface px-4 py-3 font-display text-small no-underline hover:border-ink-3"
>
<TbPlayerPlay aria-hidden="true" className="size-5 text-signal" />
<span>{readString(data, 'title', 'label') ?? t('video')}</span>
</a>
)
}
+35
View File
@@ -0,0 +1,35 @@
import type { Media } from '~/data/schema'
export type BlockData = Record<string, unknown>
export function readString(data: BlockData, ...keys: string[]): string | undefined {
for (const key of keys) {
const value = data[key]
if (typeof value === 'string' && value.trim() !== '') {
return value
}
}
return undefined
}
export function readStrings(data: BlockData, ...keys: string[]): string[] {
for (const key of keys) {
const value = data[key]
if (Array.isArray(value)) {
return value.filter((entry): entry is string => typeof entry === 'string' && entry.trim() !== '')
}
}
return []
}
export function readMedia(media: Map<string, Media>, id: string | undefined): Media | undefined {
return id === undefined ? undefined : media.get(id)
}
export function readMediaList(media: Map<string, Media>, ids: string[]): Media[] {
return ids.map(id => media.get(id)).filter((entry): entry is Media => entry !== undefined)
}

Some files were not shown because too many files have changed in this diff Show More