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>
)
}