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:
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 <token></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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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"',
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 },
|
||||
})
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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.')
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { AdminField } from './AdminField'
|
||||
import { inputClass } from './styles'
|
||||
import { isColor } from '~/domain/color'
|
||||
|
||||
export type AdminColorFieldProps = {
|
||||
id: string
|
||||
name: string
|
||||
label: ReactNode
|
||||
pickerLabel: string
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
hint?: ReactNode
|
||||
error?: ReactNode
|
||||
}
|
||||
|
||||
export function AdminColorField({ id, name, label, pickerLabel, value, onChange, hint, error }: AdminColorFieldProps) {
|
||||
return (
|
||||
<AdminField id={id} label={label} hint={hint} error={error}>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
id={`${id}-picker`}
|
||||
type="color"
|
||||
aria-label={pickerLabel}
|
||||
value={isColor(value) ? value : '#000000'}
|
||||
onChange={event => onChange(event.target.value)}
|
||||
className="h-10 w-12 shrink-0 cursor-pointer border border-rule bg-surface p-1"
|
||||
/>
|
||||
<input
|
||||
id={id}
|
||||
name={name}
|
||||
value={value}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
onChange={event => onChange(event.target.value)}
|
||||
className={`${inputClass} font-mono`}
|
||||
/>
|
||||
</div>
|
||||
</AdminField>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { errorClass, hintClass, labelClass } from './styles'
|
||||
|
||||
export type AdminFieldProps = {
|
||||
id: string
|
||||
label: ReactNode
|
||||
children: ReactNode
|
||||
hint?: ReactNode
|
||||
error?: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function AdminField({ id, label, children, hint, error, className }: AdminFieldProps) {
|
||||
return (
|
||||
<div className={`flex min-w-0 flex-col gap-2 ${className ?? ''}`}>
|
||||
<label htmlFor={id} className={labelClass}>
|
||||
{label}
|
||||
</label>
|
||||
{children}
|
||||
{hint ? <span className={hintClass}>{hint}</span> : null}
|
||||
{error ? (
|
||||
<span role="alert" className={errorClass}>
|
||||
{error}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { AdminNotice } from './AdminNotice'
|
||||
import type { AdminFormState } from '~/lib/admin-forms'
|
||||
|
||||
export type AdminFormMessageProps = {
|
||||
state: AdminFormState
|
||||
silentOnDone?: boolean
|
||||
}
|
||||
|
||||
export function AdminFormMessage({ state, silentOnDone = false }: AdminFormMessageProps) {
|
||||
const t = useTranslations('admin.messages')
|
||||
|
||||
if (state.status === 'idle' || !state.message) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (state.status === 'ok' && silentOnDone) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminNotice tone={state.status === 'error' ? 'alert' : 'done'}>{t(state.message)}</AdminNotice>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
export type AdminHeadingProps = {
|
||||
eyebrow?: ReactNode
|
||||
title: ReactNode
|
||||
intro?: ReactNode
|
||||
actions?: ReactNode
|
||||
}
|
||||
|
||||
export function AdminHeading({ eyebrow, title, intro, actions }: AdminHeadingProps) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-end justify-between gap-x-8 gap-y-4">
|
||||
<div className="flex min-w-0 flex-col gap-3">
|
||||
{eyebrow ? (
|
||||
<span className="font-mono text-micro font-semibold uppercase tracking-label text-ink-3">
|
||||
{eyebrow}
|
||||
</span>
|
||||
) : null}
|
||||
<h1 className="text-title font-bold text-balance">{title}</h1>
|
||||
{intro ? <p className="m-0 max-w-read text-base text-pretty text-ink-2">{intro}</p> : null}
|
||||
</div>
|
||||
{actions ? <div className="flex flex-wrap items-center gap-3">{actions}</div> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { TbAlertTriangle, TbCircleCheck, TbInfoCircle } from 'react-icons/tb'
|
||||
|
||||
export type AdminNoticeTone = 'info' | 'alert' | 'done'
|
||||
|
||||
export type AdminNoticeProps = {
|
||||
children: ReactNode
|
||||
title?: ReactNode
|
||||
action?: ReactNode
|
||||
tone?: AdminNoticeTone
|
||||
}
|
||||
|
||||
const tones: Record<AdminNoticeTone, string> = {
|
||||
info: 'border-rule',
|
||||
alert: 'border-signal',
|
||||
done: 'border-positive',
|
||||
}
|
||||
|
||||
const icons: Record<AdminNoticeTone, string> = {
|
||||
info: 'text-ink-3',
|
||||
alert: 'text-signal',
|
||||
done: 'text-positive',
|
||||
}
|
||||
|
||||
const glyphs = {
|
||||
info: TbInfoCircle,
|
||||
alert: TbAlertTriangle,
|
||||
done: TbCircleCheck,
|
||||
}
|
||||
|
||||
export function AdminNotice({ children, title, action, tone = 'info' }: AdminNoticeProps) {
|
||||
const Icon = glyphs[tone]
|
||||
|
||||
return (
|
||||
<div
|
||||
role={tone === 'alert' ? 'alert' : undefined}
|
||||
className={`flex gap-3 border-l-2 bg-surface px-5 py-4 ${tones[tone]}`}
|
||||
>
|
||||
<Icon aria-hidden="true" className={`mt-1 size-4 shrink-0 ${icons[tone]}`} />
|
||||
<div className="flex flex-col gap-2">
|
||||
{title ? (
|
||||
<span className="font-display text-micro font-semibold uppercase tracking-label text-ink-3">
|
||||
{title}
|
||||
</span>
|
||||
) : null}
|
||||
<p className="m-0 max-w-read text-base text-pretty text-ink-2">{children}</p>
|
||||
{action ? <span className="font-mono text-small text-ink-2">{action}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { useFormStatus } from 'react-dom'
|
||||
import { ghostButtonClass, primaryButtonClass } from './styles'
|
||||
|
||||
export type AdminSubmitProps = {
|
||||
children: ReactNode
|
||||
pendingLabel: ReactNode
|
||||
icon?: ReactNode
|
||||
quiet?: boolean
|
||||
disabled?: boolean
|
||||
name?: string
|
||||
value?: string
|
||||
}
|
||||
|
||||
export function AdminSubmit({ children, pendingLabel, icon, quiet = false, disabled = false, name, value }: AdminSubmitProps) {
|
||||
const { pending } = useFormStatus()
|
||||
|
||||
return (
|
||||
<button
|
||||
type="submit"
|
||||
name={name}
|
||||
value={value}
|
||||
disabled={pending || disabled}
|
||||
className={quiet ? ghostButtonClass : primaryButtonClass}
|
||||
>
|
||||
{icon ? <span aria-hidden="true" className="flex shrink-0">{icon}</span> : null}
|
||||
{pending ? pendingLabel : children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Scroller } from '~/components/ui/Scroller'
|
||||
|
||||
export type ApiEndpointProps = {
|
||||
method: string
|
||||
path: string
|
||||
summary: string
|
||||
auth: string
|
||||
example: string
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
export function ApiEndpoint({ method, path, summary, auth, example, children }: ApiEndpointProps) {
|
||||
return (
|
||||
<article className="flex flex-col gap-3 border-t border-rule pt-5">
|
||||
<div className="flex flex-wrap items-baseline gap-3">
|
||||
<span className="border border-ink px-2 py-0.5 font-mono text-micro font-semibold uppercase tracking-label text-ink">
|
||||
{method}
|
||||
</span>
|
||||
<code className="font-mono text-small text-ink">{path}</code>
|
||||
<span className="ml-auto font-mono text-micro uppercase tracking-mark text-ink-3">{auth}</span>
|
||||
</div>
|
||||
|
||||
<p className="m-0 max-w-read text-pretty text-ink-2">{summary}</p>
|
||||
|
||||
{children}
|
||||
|
||||
<Scroller options={{ overflow: { y: 'hidden' } }}>
|
||||
<pre className="m-0 w-max min-w-full bg-surface p-4 font-mono text-small text-ink-2">{example}</pre>
|
||||
</Scroller>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
'use client'
|
||||
|
||||
import { useActionState, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { TbDeviceFloppy } from 'react-icons/tb'
|
||||
import { AdminColorField } from './AdminColorField'
|
||||
import { AdminField } from './AdminField'
|
||||
import { AdminFormMessage } from './AdminFormMessage'
|
||||
import { AdminSubmit } from './AdminSubmit'
|
||||
import { inputClass, quietLinkClass } from './styles'
|
||||
import { saveBrand } from '~/lib/admin-actions'
|
||||
import { emptyAdminFormState } from '~/lib/admin-forms'
|
||||
import { adminBrandsPath } from '~/lib/admin-routes'
|
||||
import { defaultColor } from '~/domain/color'
|
||||
import { slugify } from '~/domain/slug'
|
||||
|
||||
export type BrandFormValues = {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
color: string
|
||||
sort: number
|
||||
}
|
||||
|
||||
export type BrandFormProps = {
|
||||
brand?: BrandFormValues
|
||||
}
|
||||
|
||||
export function BrandForm({ brand }: BrandFormProps) {
|
||||
const t = useTranslations('admin.brands')
|
||||
const errors = useTranslations('admin.errors')
|
||||
const [state, formAction] = useActionState(saveBrand, emptyAdminFormState)
|
||||
const [name, setName] = useState(brand?.name ?? '')
|
||||
const [slug, setSlug] = useState(brand?.slug ?? '')
|
||||
const [slugTouched, setSlugTouched] = useState((brand?.slug ?? '') !== '')
|
||||
const [color, setColor] = useState(brand?.color ?? defaultColor)
|
||||
const [sort, setSort] = useState(String(brand?.sort ?? 0))
|
||||
|
||||
function fieldError(field: string): string | undefined {
|
||||
const key = state.fields?.[field]
|
||||
return key ? errors(key) : undefined
|
||||
}
|
||||
|
||||
return (
|
||||
<form action={formAction} className="flex flex-col gap-8">
|
||||
{brand ? <input type="hidden" name="id" value={brand.id} /> : null}
|
||||
|
||||
<AdminFormMessage state={state} />
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<AdminField id="name" label={t('fields.name')} error={fieldError('name')}>
|
||||
<input
|
||||
id="name"
|
||||
name="name"
|
||||
value={name}
|
||||
required
|
||||
autoComplete="off"
|
||||
onChange={event => {
|
||||
setName(event.target.value)
|
||||
|
||||
if (!slugTouched) {
|
||||
setSlug(slugify(event.target.value))
|
||||
}
|
||||
}}
|
||||
className={inputClass}
|
||||
/>
|
||||
</AdminField>
|
||||
|
||||
<AdminField id="slug" label={t('fields.slug')} hint={t('hints.slug')} error={fieldError('slug')}>
|
||||
<input
|
||||
id="slug"
|
||||
name="slug"
|
||||
value={slug}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
onChange={event => {
|
||||
setSlugTouched(true)
|
||||
setSlug(event.target.value)
|
||||
}}
|
||||
className={`${inputClass} font-mono`}
|
||||
/>
|
||||
</AdminField>
|
||||
|
||||
<AdminColorField
|
||||
id="color"
|
||||
name="color"
|
||||
label={t('fields.color')}
|
||||
pickerLabel={t('fields.colorPicker')}
|
||||
value={color}
|
||||
onChange={setColor}
|
||||
hint={t('hints.color')}
|
||||
error={fieldError('color')}
|
||||
/>
|
||||
|
||||
<AdminField id="sort" label={t('fields.sort')} hint={t('hints.sort')} error={fieldError('sort')}>
|
||||
<input
|
||||
id="sort"
|
||||
name="sort"
|
||||
type="number"
|
||||
min={0}
|
||||
max={9999}
|
||||
step={1}
|
||||
value={sort}
|
||||
onChange={event => setSort(event.target.value)}
|
||||
className={`${inputClass} font-mono tabular-nums`}
|
||||
/>
|
||||
</AdminField>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-6">
|
||||
<AdminSubmit pendingLabel={t('saving')} icon={<TbDeviceFloppy className="size-4" />}>
|
||||
{brand ? t('save') : t('create')}
|
||||
</AdminSubmit>
|
||||
<Link href={adminBrandsPath} className={quietLinkClass}>
|
||||
{t('back')}
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
'use client'
|
||||
|
||||
import { useActionState, useEffect, useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { TbCopy, TbKey } from 'react-icons/tb'
|
||||
import { AdminField } from './AdminField'
|
||||
import { AdminFormMessage } from './AdminFormMessage'
|
||||
import { AdminSubmit } from './AdminSubmit'
|
||||
import { ghostButtonClass, hintClass, inputClass, labelClass, selectClass } from './styles'
|
||||
import { createApiClient } from '~/lib/admin-actions'
|
||||
import { emptyAdminFormState } from '~/lib/admin-forms'
|
||||
|
||||
export type ClientFormProps = {
|
||||
projects: { id: string, name: string }[]
|
||||
}
|
||||
|
||||
const modes = ['read', 'write'] as const
|
||||
|
||||
const scopes = ['internal', 'customer', 'public'] as const
|
||||
|
||||
export function ClientForm({ projects }: ClientFormProps) {
|
||||
const t = useTranslations('admin.clients')
|
||||
const errors = useTranslations('admin.errors')
|
||||
const [state, formAction] = useActionState(createApiClient, emptyAdminFormState)
|
||||
const [name, setName] = useState('')
|
||||
const [mode, setMode] = useState<string>('read')
|
||||
const [scope, setScope] = useState<string>('customer')
|
||||
const [projectId, setProjectId] = useState('')
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (state.status === 'ok' && state.token) {
|
||||
setName('')
|
||||
setCopied(false)
|
||||
}
|
||||
}, [state.status, state.token])
|
||||
|
||||
function fieldError(field: string): string | undefined {
|
||||
const key = state.fields?.[field]
|
||||
return key ? errors(key) : undefined
|
||||
}
|
||||
|
||||
async function copyToken(token: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(token)
|
||||
setCopied(true)
|
||||
} catch {
|
||||
setCopied(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
{state.token ? (
|
||||
<div className="flex flex-col gap-3 border-l-2 border-signal bg-surface px-5 py-4">
|
||||
<span className="font-display text-micro font-semibold uppercase tracking-label text-ink-3">
|
||||
{t('tokenTitle')}
|
||||
</span>
|
||||
<code className="block break-all font-mono text-base text-ink">{state.token}</code>
|
||||
<p className="m-0 max-w-read text-small text-pretty text-ink-2">{t('tokenHint')}</p>
|
||||
<span className="flex flex-wrap items-center gap-3">
|
||||
<button type="button" onClick={() => copyToken(state.token ?? '')} className={ghostButtonClass}>
|
||||
<TbCopy aria-hidden="true" className="size-4 shrink-0" />
|
||||
{copied ? t('copied') : t('copy')}
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<form action={formAction} className="flex flex-col gap-8">
|
||||
<AdminFormMessage state={state} silentOnDone />
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<AdminField id="name" label={t('fields.name')} hint={t('hints.name')} error={fieldError('name')}>
|
||||
<input
|
||||
id="name"
|
||||
name="name"
|
||||
value={name}
|
||||
required
|
||||
autoComplete="off"
|
||||
onChange={event => setName(event.target.value)}
|
||||
className={inputClass}
|
||||
/>
|
||||
</AdminField>
|
||||
|
||||
<AdminField id="projectId" label={t('fields.project')} hint={t('hints.project')} error={fieldError('projectId')}>
|
||||
<select
|
||||
id="projectId"
|
||||
name="projectId"
|
||||
value={projectId}
|
||||
onChange={event => setProjectId(event.target.value)}
|
||||
className={selectClass}
|
||||
>
|
||||
<option value="">{t('allProjects')}</option>
|
||||
{projects.map(project => (
|
||||
<option key={project.id} value={project.id}>
|
||||
{project.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</AdminField>
|
||||
|
||||
<AdminField id="mode" label={t('fields.mode')} hint={t('hints.mode')} error={fieldError('mode')}>
|
||||
<select
|
||||
id="mode"
|
||||
name="mode"
|
||||
value={mode}
|
||||
onChange={event => setMode(event.target.value)}
|
||||
className={selectClass}
|
||||
>
|
||||
{modes.map(value => (
|
||||
<option key={value} value={value}>
|
||||
{t(`modes.${value}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</AdminField>
|
||||
|
||||
<AdminField id="scope" label={t('fields.scope')} hint={t('hints.scope')} error={fieldError('scope')}>
|
||||
<select
|
||||
id="scope"
|
||||
name="scope"
|
||||
value={scope}
|
||||
onChange={event => setScope(event.target.value)}
|
||||
className={selectClass}
|
||||
>
|
||||
{scopes.map(value => (
|
||||
<option key={value} value={value}>
|
||||
{t(`scopes.${value}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</AdminField>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 border-t border-rule pt-6">
|
||||
<span className={labelClass}>{t('publishTitle')}</span>
|
||||
<span className={hintClass}>{t('publishHint')}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-6">
|
||||
<AdminSubmit pendingLabel={t('creating')} icon={<TbKey className="size-4" />}>
|
||||
{t('create')}
|
||||
</AdminSubmit>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
'use client'
|
||||
|
||||
import { useActionState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { TbBan } from 'react-icons/tb'
|
||||
import { AdminSubmit } from './AdminSubmit'
|
||||
import { errorClass } from './styles'
|
||||
import { revokeApiClient } from '~/lib/admin-actions'
|
||||
import { emptyAdminFormState } from '~/lib/admin-forms'
|
||||
|
||||
export type ClientRevokeButtonProps = {
|
||||
id: string
|
||||
}
|
||||
|
||||
export function ClientRevokeButton({ id }: ClientRevokeButtonProps) {
|
||||
const t = useTranslations('admin.clients')
|
||||
const messages = useTranslations('admin.messages')
|
||||
const [state, formAction] = useActionState(revokeApiClient, emptyAdminFormState)
|
||||
|
||||
return (
|
||||
<form action={formAction} className="flex flex-col gap-2">
|
||||
<input type="hidden" name="id" value={id} />
|
||||
<AdminSubmit quiet pendingLabel={t('revoking')} icon={<TbBan className="size-4" />}>
|
||||
{t('revoke')}
|
||||
</AdminSubmit>
|
||||
{state.status === 'error' && state.message ? (
|
||||
<span role="alert" className={errorClass}>
|
||||
{messages(state.message)}
|
||||
</span>
|
||||
) : null}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
'use client'
|
||||
|
||||
import { useId } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { TbArrowDown, TbArrowUp, TbGripVertical, TbTrash } from 'react-icons/tb'
|
||||
import { AdminField } from './AdminField'
|
||||
import { EntryMediaPicker } from './EntryMediaPicker'
|
||||
import { inputClass, selectClass, textareaClass } from './styles'
|
||||
import type { BlockType, BlockValues } from '~/domain/blocks'
|
||||
import type { EntryMedia } from '~/lib/entry-types'
|
||||
|
||||
export type EntryBlockCardProps = {
|
||||
type: BlockType
|
||||
data: BlockValues
|
||||
index: number
|
||||
total: number
|
||||
media: EntryMedia[]
|
||||
accept: string
|
||||
dragging: boolean
|
||||
onChange: (data: BlockValues) => void
|
||||
onRemove: () => void
|
||||
onMove: (to: number) => void
|
||||
onDragStart: () => void
|
||||
onDragEnd: () => void
|
||||
onDropOn: () => void
|
||||
upload: (files: File[]) => Promise<string[]>
|
||||
}
|
||||
|
||||
function text(data: BlockValues, key: string): string {
|
||||
const value = data[key]
|
||||
|
||||
return typeof value === 'string' ? value : ''
|
||||
}
|
||||
|
||||
function list(data: BlockValues, key: string): string[] {
|
||||
const value = data[key]
|
||||
|
||||
return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : []
|
||||
}
|
||||
|
||||
export function EntryBlockCard({
|
||||
type,
|
||||
data,
|
||||
index,
|
||||
total,
|
||||
media,
|
||||
accept,
|
||||
dragging,
|
||||
onChange,
|
||||
onRemove,
|
||||
onMove,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
onDropOn,
|
||||
upload,
|
||||
}: EntryBlockCardProps) {
|
||||
const t = useTranslations('admin.entries')
|
||||
const id = useId()
|
||||
|
||||
function set(key: string, value: unknown) {
|
||||
onChange({ ...data, [key]: value })
|
||||
}
|
||||
|
||||
function pick(key: string, ids: string[]) {
|
||||
set(key, ids[0] ?? '')
|
||||
}
|
||||
|
||||
async function uploadInto(key: string, files: File[]) {
|
||||
const ids = await upload(files)
|
||||
|
||||
if (ids.length > 0) {
|
||||
set(key, ids[0])
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadIntoList(key: string, files: File[]) {
|
||||
const ids = await upload(files)
|
||||
|
||||
if (ids.length > 0) {
|
||||
set(key, [...list(data, key), ...ids])
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<li
|
||||
onDragOver={event => event.preventDefault()}
|
||||
onDrop={event => {
|
||||
if (event.dataTransfer.files.length > 0) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
onDropOn()
|
||||
}}
|
||||
className={`flex flex-col gap-4 border bg-surface px-5 py-4 ${dragging ? 'border-signal' : 'border-rule'}`}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
|
||||
<span
|
||||
draggable
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
aria-label={t('actions.drag')}
|
||||
className="flex cursor-grab items-center text-ink-3 hover:text-ink"
|
||||
>
|
||||
<TbGripVertical aria-hidden="true" className="size-4" />
|
||||
</span>
|
||||
<span className="font-mono text-micro font-semibold uppercase tracking-label text-ink-2">
|
||||
{t(`blocks.${type}`)}
|
||||
</span>
|
||||
<span aria-hidden="true" className="h-px min-w-6 flex-1 bg-rule" />
|
||||
<button
|
||||
type="button"
|
||||
disabled={index === 0}
|
||||
onClick={() => onMove(index - 1)}
|
||||
aria-label={t('actions.up')}
|
||||
className="cursor-pointer border-0 bg-transparent p-0 text-ink-3 hover:text-ink disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<TbArrowUp aria-hidden="true" className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={index === total - 1}
|
||||
onClick={() => onMove(index + 1)}
|
||||
aria-label={t('actions.down')}
|
||||
className="cursor-pointer border-0 bg-transparent p-0 text-ink-3 hover:text-ink disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<TbArrowDown aria-hidden="true" className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
aria-label={t('actions.remove')}
|
||||
className="cursor-pointer border-0 bg-transparent p-0 text-ink-3 hover:text-signal"
|
||||
>
|
||||
<TbTrash aria-hidden="true" className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{type === 'text' ? (
|
||||
<AdminField id={`${id}-text`} label={t('blockFields.text')}>
|
||||
<textarea
|
||||
id={`${id}-text`}
|
||||
rows={6}
|
||||
value={text(data, 'text')}
|
||||
placeholder={t('blockFields.textPlaceholder')}
|
||||
onChange={event => set('text', event.target.value)}
|
||||
className={textareaClass}
|
||||
/>
|
||||
</AdminField>
|
||||
) : null}
|
||||
|
||||
{type === 'image' ? (
|
||||
<EntryMediaPicker
|
||||
label={t('blockFields.image')}
|
||||
media={media}
|
||||
selected={text(data, 'mediaId') === '' ? [] : [text(data, 'mediaId')]}
|
||||
accept={accept}
|
||||
onSelect={ids => pick('mediaId', ids)}
|
||||
onUpload={files => void uploadInto('mediaId', files)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{type === 'gallery' ? (
|
||||
<EntryMediaPicker
|
||||
label={t('blockFields.gallery')}
|
||||
media={media}
|
||||
selected={list(data, 'mediaIds')}
|
||||
multiple
|
||||
accept={accept}
|
||||
onSelect={ids => set('mediaIds', ids)}
|
||||
onUpload={files => void uploadIntoList('mediaIds', files)}
|
||||
hint={t('blockFields.galleryHint')}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{type === 'before_after' ? (
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
<EntryMediaPicker
|
||||
label={t('blockFields.before')}
|
||||
media={media}
|
||||
selected={text(data, 'beforeMediaId') === '' ? [] : [text(data, 'beforeMediaId')]}
|
||||
accept={accept}
|
||||
onSelect={ids => pick('beforeMediaId', ids)}
|
||||
onUpload={files => void uploadInto('beforeMediaId', files)}
|
||||
/>
|
||||
<EntryMediaPicker
|
||||
label={t('blockFields.after')}
|
||||
media={media}
|
||||
selected={text(data, 'afterMediaId') === '' ? [] : [text(data, 'afterMediaId')]}
|
||||
accept={accept}
|
||||
onSelect={ids => pick('afterMediaId', ids)}
|
||||
onUpload={files => void uploadInto('afterMediaId', files)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{type === 'video' ? (
|
||||
<div className="flex flex-col gap-5">
|
||||
<EntryMediaPicker
|
||||
label={t('blockFields.video')}
|
||||
media={media}
|
||||
selected={text(data, 'mediaId') === '' ? [] : [text(data, 'mediaId')]}
|
||||
accept={accept}
|
||||
onSelect={ids => pick('mediaId', ids)}
|
||||
onUpload={files => void uploadInto('mediaId', files)}
|
||||
hint={t('blockFields.videoHint')}
|
||||
/>
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
<AdminField id={`${id}-url`} label={t('blockFields.url')}>
|
||||
<input
|
||||
id={`${id}-url`}
|
||||
type="url"
|
||||
value={text(data, 'url')}
|
||||
onChange={event => set('url', event.target.value)}
|
||||
className={`${inputClass} font-mono`}
|
||||
/>
|
||||
</AdminField>
|
||||
<AdminField id={`${id}-title`} label={t('blockFields.label')}>
|
||||
<input
|
||||
id={`${id}-title`}
|
||||
type="text"
|
||||
value={text(data, 'title')}
|
||||
onChange={event => set('title', event.target.value)}
|
||||
className={inputClass}
|
||||
/>
|
||||
</AdminField>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{type === 'quote' ? (
|
||||
<div className="flex flex-col gap-5">
|
||||
<AdminField id={`${id}-quote`} label={t('blockFields.quote')}>
|
||||
<textarea
|
||||
id={`${id}-quote`}
|
||||
rows={3}
|
||||
value={text(data, 'text')}
|
||||
onChange={event => set('text', event.target.value)}
|
||||
className={textareaClass}
|
||||
/>
|
||||
</AdminField>
|
||||
<AdminField id={`${id}-source`} label={t('blockFields.source')}>
|
||||
<input
|
||||
id={`${id}-source`}
|
||||
type="text"
|
||||
value={text(data, 'source')}
|
||||
onChange={event => set('source', event.target.value)}
|
||||
className={inputClass}
|
||||
/>
|
||||
</AdminField>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{type === 'code' ? (
|
||||
<div className="flex flex-col gap-5">
|
||||
<AdminField id={`${id}-language`} label={t('blockFields.language')}>
|
||||
<input
|
||||
id={`${id}-language`}
|
||||
type="text"
|
||||
value={text(data, 'language')}
|
||||
onChange={event => set('language', event.target.value)}
|
||||
className={`${inputClass} font-mono`}
|
||||
/>
|
||||
</AdminField>
|
||||
<AdminField id={`${id}-code`} label={t('blockFields.code')}>
|
||||
<textarea
|
||||
id={`${id}-code`}
|
||||
rows={8}
|
||||
spellCheck={false}
|
||||
value={text(data, 'code')}
|
||||
onChange={event => set('code', event.target.value)}
|
||||
className={`${textareaClass} font-mono text-small`}
|
||||
/>
|
||||
</AdminField>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{type === 'link' ? (
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
<AdminField id={`${id}-linkurl`} label={t('blockFields.url')}>
|
||||
<input
|
||||
id={`${id}-linkurl`}
|
||||
type="url"
|
||||
value={text(data, 'url')}
|
||||
onChange={event => set('url', event.target.value)}
|
||||
className={`${inputClass} font-mono`}
|
||||
/>
|
||||
</AdminField>
|
||||
<AdminField id={`${id}-linklabel`} label={t('blockFields.label')}>
|
||||
<input
|
||||
id={`${id}-linklabel`}
|
||||
type="text"
|
||||
value={text(data, 'label')}
|
||||
onChange={event => set('label', event.target.value)}
|
||||
className={inputClass}
|
||||
/>
|
||||
</AdminField>
|
||||
</div>
|
||||
<AdminField id={`${id}-linkdescription`} label={t('blockFields.description')}>
|
||||
<input
|
||||
id={`${id}-linkdescription`}
|
||||
type="text"
|
||||
value={text(data, 'description')}
|
||||
onChange={event => set('description', event.target.value)}
|
||||
className={inputClass}
|
||||
/>
|
||||
</AdminField>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{type === 'callout' ? (
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
<AdminField id={`${id}-callouttitle`} label={t('blockFields.label')}>
|
||||
<input
|
||||
id={`${id}-callouttitle`}
|
||||
type="text"
|
||||
value={text(data, 'title')}
|
||||
onChange={event => set('title', event.target.value)}
|
||||
className={inputClass}
|
||||
/>
|
||||
</AdminField>
|
||||
<AdminField id={`${id}-tone`} label={t('blockFields.tone')}>
|
||||
<select
|
||||
id={`${id}-tone`}
|
||||
value={text(data, 'tone') === 'warning' ? 'warning' : 'info'}
|
||||
onChange={event => set('tone', event.target.value)}
|
||||
className={selectClass}
|
||||
>
|
||||
<option value="info">{t('blockFields.toneInfo')}</option>
|
||||
<option value="warning">{t('blockFields.toneWarning')}</option>
|
||||
</select>
|
||||
</AdminField>
|
||||
</div>
|
||||
<AdminField id={`${id}-callouttext`} label={t('blockFields.text')}>
|
||||
<textarea
|
||||
id={`${id}-callouttext`}
|
||||
rows={3}
|
||||
value={text(data, 'text')}
|
||||
onChange={event => set('text', event.target.value)}
|
||||
className={textareaClass}
|
||||
/>
|
||||
</AdminField>
|
||||
</div>
|
||||
) : null}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,814 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import {
|
||||
TbArchive,
|
||||
TbCalendarClock,
|
||||
TbCalendarX,
|
||||
TbDeviceFloppy,
|
||||
TbEye,
|
||||
TbPencil,
|
||||
TbPhotoPlus,
|
||||
TbPlus,
|
||||
TbRotate2,
|
||||
TbSend,
|
||||
TbX,
|
||||
} from 'react-icons/tb'
|
||||
import { AdminField } from './AdminField'
|
||||
import { AdminNotice } from './AdminNotice'
|
||||
import { EntryBlockCard } from './EntryBlockCard'
|
||||
import { EntryMediaPicker } from './EntryMediaPicker'
|
||||
import { EntryPreview } from './EntryPreview'
|
||||
import { EntryStatusChip } from './EntryStatusChip'
|
||||
import {
|
||||
ghostButtonClass,
|
||||
hintClass,
|
||||
inputClass,
|
||||
labelClass,
|
||||
metaClass,
|
||||
primaryButtonClass,
|
||||
quietLinkClass,
|
||||
selectClass,
|
||||
textareaClass,
|
||||
} from './styles'
|
||||
import { blockTypes, emptyBlockData, isBlockEmpty, type BlockType, type BlockValues } from '~/domain/blocks'
|
||||
import { checkPublish } from '~/domain/publish-checks'
|
||||
import { defaultPostTypeColor } from '~/domain/post-type'
|
||||
import { slugify } from '~/domain/slug'
|
||||
import type { Audience, PostStatus } from '~/domain/types'
|
||||
import { adminEntriesPath, adminEntryPath } from '~/lib/admin-routes'
|
||||
import {
|
||||
archiveEntry,
|
||||
loadEntryMedia,
|
||||
publishEntry,
|
||||
resumeEntry,
|
||||
saveEntry,
|
||||
scheduleEntry,
|
||||
unscheduleEntry,
|
||||
uploadEntryImage,
|
||||
} from '~/lib/entry-actions'
|
||||
import type { EntryErrorKey, EntryInput, EntryMedia, EntryResult, EntryState, EntryTypeOption } from '~/lib/entry-types'
|
||||
|
||||
export type EntryEditorProject = {
|
||||
id: string
|
||||
slug: string
|
||||
name: string
|
||||
code: string
|
||||
color: string
|
||||
}
|
||||
|
||||
export type EntryEditorEntry = {
|
||||
id: string
|
||||
number: number
|
||||
slug: string
|
||||
status: PostStatus
|
||||
publishAt: string | null
|
||||
updatedLabel: string
|
||||
projectId: string
|
||||
title: string
|
||||
teaser: string
|
||||
typeId: string
|
||||
audience: Audience
|
||||
coverMediaId: string | null
|
||||
blocks: { type: BlockType, data: BlockValues }[]
|
||||
}
|
||||
|
||||
export type EntryEditorProps = {
|
||||
projects: EntryEditorProject[]
|
||||
types: EntryTypeOption[]
|
||||
media: EntryMedia[]
|
||||
accept: string
|
||||
defaultProjectId: string
|
||||
entry?: EntryEditorEntry
|
||||
}
|
||||
|
||||
type EditorBlock = {
|
||||
key: string
|
||||
type: BlockType
|
||||
data: BlockValues
|
||||
}
|
||||
|
||||
type Form = {
|
||||
projectId: string
|
||||
title: string
|
||||
teaser: string
|
||||
slug: string
|
||||
typeId: string
|
||||
audience: Audience
|
||||
cover: string | null
|
||||
blocks: EditorBlock[]
|
||||
}
|
||||
|
||||
type Upload = {
|
||||
key: string
|
||||
name: string
|
||||
status: 'pending' | 'done' | 'failed'
|
||||
reason?: string
|
||||
}
|
||||
|
||||
const audiences: Audience[] = ['internal', 'customer', 'public']
|
||||
|
||||
let counter = 0
|
||||
|
||||
function nextKey(): string {
|
||||
counter += 1
|
||||
|
||||
return `block-${counter}`
|
||||
}
|
||||
|
||||
function initialForm(entry: EntryEditorEntry | undefined, defaultProjectId: string, defaultTypeId: string): Form {
|
||||
if (!entry) {
|
||||
return {
|
||||
projectId: defaultProjectId,
|
||||
title: '',
|
||||
teaser: '',
|
||||
slug: '',
|
||||
typeId: defaultTypeId,
|
||||
audience: 'internal',
|
||||
cover: null,
|
||||
blocks: [{ key: nextKey(), type: 'text', data: emptyBlockData('text') }],
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
projectId: entry.projectId,
|
||||
title: entry.title,
|
||||
teaser: entry.teaser,
|
||||
slug: entry.slug,
|
||||
typeId: entry.typeId,
|
||||
audience: entry.audience,
|
||||
cover: entry.coverMediaId,
|
||||
blocks: entry.blocks.map(block => ({ key: nextKey(), type: block.type, data: block.data })),
|
||||
}
|
||||
}
|
||||
|
||||
function payloadOf(form: Form): EntryInput {
|
||||
return {
|
||||
projectId: form.projectId,
|
||||
title: form.title,
|
||||
teaser: form.teaser,
|
||||
slug: form.slug.trim() === '' ? undefined : form.slug.trim(),
|
||||
typeId: form.typeId,
|
||||
audience: form.audience,
|
||||
coverMediaId: form.cover,
|
||||
blocks: form.blocks.map(block => ({ type: block.type, data: block.data })),
|
||||
}
|
||||
}
|
||||
|
||||
function localTime(date: Date): string {
|
||||
return date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
export function EntryEditor({ projects, types, media: initialMedia, accept, defaultProjectId, entry }: EntryEditorProps) {
|
||||
const t = useTranslations('admin.entries')
|
||||
const [form, setForm] = useState<Form>(() => initialForm(entry, defaultProjectId, types[0]?.id ?? ''))
|
||||
const [media, setMedia] = useState<EntryMedia[]>(initialMedia)
|
||||
const [status, setStatus] = useState<EntryState | null>(
|
||||
entry
|
||||
? {
|
||||
id: entry.id,
|
||||
number: entry.number,
|
||||
slug: entry.slug,
|
||||
status: entry.status,
|
||||
publishAt: entry.publishAt,
|
||||
updatedAt: '',
|
||||
}
|
||||
: null,
|
||||
)
|
||||
const [savedAt, setSavedAt] = useState<Date | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<EntryErrorKey | null>(null)
|
||||
const [blockers, setBlockers] = useState<string[]>([])
|
||||
const [preview, setPreview] = useState(false)
|
||||
const [scope, setScope] = useState<Audience>('customer')
|
||||
const [when, setWhen] = useState('')
|
||||
const [uploads, setUploads] = useState<Upload[]>([])
|
||||
const [dropping, setDropping] = useState(false)
|
||||
const [dragIndex, setDragIndex] = useState<number | null>(null)
|
||||
const [adding, setAdding] = useState(false)
|
||||
|
||||
const key = useMemo(() => JSON.stringify(payloadOf(form)), [form])
|
||||
const formRef = useRef(form)
|
||||
const keyRef = useRef(key)
|
||||
const idRef = useRef(entry?.id ?? '')
|
||||
const savingRef = useRef(false)
|
||||
const savedKey = useRef<string | null>(null)
|
||||
|
||||
formRef.current = form
|
||||
keyRef.current = key
|
||||
|
||||
if (savedKey.current === null) {
|
||||
savedKey.current = entry ? key : ''
|
||||
}
|
||||
|
||||
const project = projects.find(item => item.id === form.projectId) ?? projects[0]
|
||||
const selectedType = types.find(item => item.id === form.typeId) ?? types[0]
|
||||
const touched = entry !== undefined || status !== null || form.title.trim() !== ''
|
||||
const dirty = touched && key !== savedKey.current
|
||||
|
||||
const checks = useMemo(() => {
|
||||
const ids = new Set<string>()
|
||||
|
||||
for (const block of form.blocks) {
|
||||
for (const field of ['mediaId', 'beforeMediaId', 'afterMediaId']) {
|
||||
const value = block.data[field]
|
||||
|
||||
if (typeof value === 'string' && value !== '') {
|
||||
ids.add(value)
|
||||
}
|
||||
}
|
||||
|
||||
const many = block.data.mediaIds
|
||||
|
||||
if (Array.isArray(many)) {
|
||||
for (const value of many) {
|
||||
if (typeof value === 'string' && value !== '') {
|
||||
ids.add(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (form.cover) {
|
||||
ids.add(form.cover)
|
||||
}
|
||||
|
||||
return checkPublish({
|
||||
title: form.title,
|
||||
audience: form.audience,
|
||||
blockCount: form.blocks.filter(block => !isBlockEmpty({ type: block.type, data: block.data })).length,
|
||||
images: [...ids].map(id => ({ hasAlt: (media.find(item => item.id === id)?.alt ?? '').trim() !== '' })),
|
||||
tagCount: 0,
|
||||
hasCover: form.cover !== null,
|
||||
})
|
||||
}, [form, media])
|
||||
|
||||
const persist = useCallback(async (): Promise<EntryState | null> => {
|
||||
const current = formRef.current
|
||||
|
||||
if (current.title.trim() === '' || savingRef.current) {
|
||||
return null
|
||||
}
|
||||
|
||||
const snapshot = keyRef.current
|
||||
|
||||
if (snapshot === savedKey.current) {
|
||||
return null
|
||||
}
|
||||
|
||||
savingRef.current = true
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
|
||||
const created = idRef.current === ''
|
||||
const result = await saveEntry({ ...payloadOf(current), id: idRef.current === '' ? undefined : idRef.current })
|
||||
|
||||
savingRef.current = false
|
||||
setSaving(false)
|
||||
|
||||
if (!result.ok) {
|
||||
setError(result.error)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
savedKey.current = snapshot
|
||||
idRef.current = result.entry.id
|
||||
setStatus(result.entry)
|
||||
setSavedAt(new Date())
|
||||
|
||||
if (created) {
|
||||
window.history.replaceState(null, '', adminEntryPath(result.entry.id))
|
||||
}
|
||||
|
||||
if (keyRef.current !== savedKey.current) {
|
||||
return persist()
|
||||
}
|
||||
|
||||
return result.entry
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (form.title.trim() === '' || key === savedKey.current) {
|
||||
return
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => void persist(), 1400)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [key, form.title, persist])
|
||||
|
||||
const upload = useCallback(async (files: File[]): Promise<string[]> => {
|
||||
const projectId = formRef.current.projectId
|
||||
const accepted = files.filter(file => file.type.startsWith('image/'))
|
||||
const done: string[] = []
|
||||
|
||||
for (const file of accepted) {
|
||||
const mark = `upload-${Date.now()}-${done.length}-${file.name}`
|
||||
|
||||
setUploads(current => [...current, { key: mark, name: file.name, status: 'pending' }])
|
||||
|
||||
const data = new FormData()
|
||||
|
||||
data.set('projectId', projectId)
|
||||
data.set('file', file)
|
||||
|
||||
const result = await uploadEntryImage(data)
|
||||
|
||||
if (result.ok) {
|
||||
setMedia(current => [result.media, ...current])
|
||||
setUploads(current => current.map(item => (item.key === mark ? { ...item, status: 'done' } : item)))
|
||||
done.push(result.media.id)
|
||||
} else {
|
||||
setUploads(current =>
|
||||
current.map(item => (item.key === mark ? { ...item, status: 'failed', reason: result.error } : item)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return done
|
||||
}, [])
|
||||
|
||||
const insertFiles = useCallback(async (files: File[]) => {
|
||||
const ids = await upload(files)
|
||||
|
||||
if (ids.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const block: EditorBlock = ids.length === 1
|
||||
? { key: nextKey(), type: 'image', data: { mediaId: ids[0]! } }
|
||||
: { key: nextKey(), type: 'gallery', data: { mediaIds: ids } }
|
||||
|
||||
setForm(current => {
|
||||
const single = current.blocks[0]
|
||||
const base = current.blocks.length === 1 && single && isBlockEmpty({ type: single.type, data: single.data })
|
||||
? []
|
||||
: current.blocks
|
||||
|
||||
return { ...current, cover: current.cover ?? ids[0]!, blocks: [...base, block] }
|
||||
})
|
||||
}, [upload])
|
||||
|
||||
useEffect(() => {
|
||||
function onPaste(event: ClipboardEvent) {
|
||||
const files = [...(event.clipboardData?.files ?? [])].filter(file => file.type.startsWith('image/'))
|
||||
|
||||
if (files.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
void insertFiles(files)
|
||||
}
|
||||
|
||||
document.addEventListener('paste', onPaste)
|
||||
|
||||
return () => document.removeEventListener('paste', onPaste)
|
||||
}, [insertFiles])
|
||||
|
||||
async function run(action: (id: string) => Promise<EntryResult>) {
|
||||
setBusy(true)
|
||||
setBlockers([])
|
||||
setError(null)
|
||||
|
||||
await persist()
|
||||
|
||||
const id = idRef.current
|
||||
|
||||
if (id === '') {
|
||||
setBusy(false)
|
||||
setError('titleMissing')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const result = await action(id)
|
||||
|
||||
setBusy(false)
|
||||
|
||||
if (!result.ok) {
|
||||
setError(result.error)
|
||||
setBlockers(result.blockers ?? [])
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setStatus(result.entry)
|
||||
setSavedAt(new Date())
|
||||
}
|
||||
|
||||
function update(patch: Partial<Form>) {
|
||||
setForm(current => ({ ...current, ...patch }))
|
||||
}
|
||||
|
||||
function changeProject(projectId: string) {
|
||||
update({ projectId, cover: null })
|
||||
setMedia([])
|
||||
|
||||
void loadEntryMedia(projectId).then(result => {
|
||||
if (result.ok) {
|
||||
setMedia(result.items)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function addBlock(type: BlockType) {
|
||||
setAdding(false)
|
||||
setForm(current => ({
|
||||
...current,
|
||||
blocks: [...current.blocks, { key: nextKey(), type, data: emptyBlockData(type) }],
|
||||
}))
|
||||
}
|
||||
|
||||
function moveBlock(from: number, to: number) {
|
||||
if (to < 0 || from === to) {
|
||||
return
|
||||
}
|
||||
|
||||
setForm(current => {
|
||||
if (to >= current.blocks.length) {
|
||||
return current
|
||||
}
|
||||
|
||||
const blocks = [...current.blocks]
|
||||
const [moved] = blocks.splice(from, 1)
|
||||
|
||||
if (!moved) {
|
||||
return current
|
||||
}
|
||||
|
||||
blocks.splice(to, 0, moved)
|
||||
|
||||
return { ...current, blocks }
|
||||
})
|
||||
}
|
||||
|
||||
const saveLabel = saving
|
||||
? t('save.pending')
|
||||
: dirty
|
||||
? t('save.dirty')
|
||||
: savedAt
|
||||
? t('save.saved', { time: localTime(savedAt) })
|
||||
: entry
|
||||
? t('save.stored', { time: entry.updatedLabel })
|
||||
: t('save.idle')
|
||||
|
||||
const current = status?.status ?? 'draft'
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="sticky top-0 z-10 flex flex-wrap items-center gap-x-5 gap-y-3 border-b border-rule bg-paper py-3">
|
||||
<Link href={adminEntriesPath} className={quietLinkClass}>
|
||||
{t('back')}
|
||||
</Link>
|
||||
|
||||
<EntryStatusChip status={current} label={t(`status.${current}`)} />
|
||||
|
||||
{status ? (
|
||||
<span className={metaClass}>
|
||||
{project ? `${project.code}-${String(status.number).padStart(4, '0')}` : status.number}
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
<span className={metaClass}>{saveLabel}</span>
|
||||
|
||||
<span aria-hidden="true" className="h-px min-w-6 flex-1 bg-rule" />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPreview(value => !value)}
|
||||
className={ghostButtonClass}
|
||||
>
|
||||
{preview ? <TbPencil aria-hidden="true" className="size-4" /> : <TbEye aria-hidden="true" className="size-4" />}
|
||||
{preview ? t('actions.edit') : t('actions.preview')}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={saving || !dirty || form.title.trim() === ''}
|
||||
onClick={() => void persist()}
|
||||
className={ghostButtonClass}
|
||||
>
|
||||
<TbDeviceFloppy aria-hidden="true" className="size-4" />
|
||||
{t('actions.save')}
|
||||
</button>
|
||||
|
||||
{current === 'published' ? (
|
||||
<button type="button" disabled={busy} onClick={() => void run(id => archiveEntry({ id }))} className={ghostButtonClass}>
|
||||
<TbArchive aria-hidden="true" className="size-4" />
|
||||
{t('actions.archive')}
|
||||
</button>
|
||||
) : current === 'archived' ? (
|
||||
<button type="button" disabled={busy} onClick={() => void run(id => resumeEntry({ id }))} className={ghostButtonClass}>
|
||||
<TbRotate2 aria-hidden="true" className="size-4" />
|
||||
{t('actions.resume')}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || form.title.trim() === ''}
|
||||
onClick={() => void run(id => publishEntry({ id }))}
|
||||
className={primaryButtonClass}
|
||||
>
|
||||
<TbSend aria-hidden="true" className="size-4" />
|
||||
{busy ? t('actions.publishing') : t('actions.publish')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{error ? <AdminNotice tone="alert">{t(`errors.${error}`)}</AdminNotice> : null}
|
||||
|
||||
{blockers.length > 0 ? (
|
||||
<AdminNotice tone="alert" title={t('checks.blockersTitle')}>
|
||||
{blockers.map(entry => t(`checks.${entry}`)).join(' ')}
|
||||
</AdminNotice>
|
||||
) : null}
|
||||
|
||||
{preview ? (
|
||||
<EntryPreview
|
||||
title={form.title}
|
||||
teaser={form.teaser}
|
||||
typeLabel={selectedType?.label ?? ''}
|
||||
typeColor={selectedType?.color ?? defaultPostTypeColor}
|
||||
audience={form.audience}
|
||||
status={current}
|
||||
number={status?.number ?? null}
|
||||
code={project?.code ?? ''}
|
||||
color={project?.color ?? '#000000'}
|
||||
media={media}
|
||||
cover={form.cover}
|
||||
blocks={form.blocks}
|
||||
scope={scope}
|
||||
onScope={setScope}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
onDragOver={event => {
|
||||
if (event.dataTransfer.types.includes('Files')) {
|
||||
event.preventDefault()
|
||||
setDropping(true)
|
||||
}
|
||||
}}
|
||||
onDragLeave={() => setDropping(false)}
|
||||
onDrop={event => {
|
||||
const files = [...event.dataTransfer.files]
|
||||
|
||||
setDropping(false)
|
||||
|
||||
if (files.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
void insertFiles(files)
|
||||
}}
|
||||
className={`flex flex-col gap-8 border border-dashed px-1 py-1 ${dropping ? 'border-signal' : 'border-transparent'}`}
|
||||
>
|
||||
<div className="grid gap-8 lg:grid-cols-[minmax(0,1fr)_20rem] lg:items-start">
|
||||
<div className="flex min-w-0 flex-col gap-8">
|
||||
<div className="flex flex-col gap-3">
|
||||
<label htmlFor="entry-title" className="sr-only">
|
||||
{t('fields.title')}
|
||||
</label>
|
||||
<input
|
||||
id="entry-title"
|
||||
value={form.title}
|
||||
autoFocus={!entry}
|
||||
placeholder={t('fields.titlePlaceholder')}
|
||||
onChange={event => update({ title: event.target.value })}
|
||||
className="w-full min-w-0 border-0 bg-transparent p-0 font-display text-title font-bold text-ink placeholder:text-ink-3 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AdminField id="entry-teaser" label={t('fields.teaser')} hint={t('hints.teaser')}>
|
||||
<textarea
|
||||
id="entry-teaser"
|
||||
rows={2}
|
||||
value={form.teaser}
|
||||
placeholder={t('fields.teaserPlaceholder')}
|
||||
onChange={event => update({ teaser: event.target.value })}
|
||||
className={textareaClass}
|
||||
/>
|
||||
</AdminField>
|
||||
|
||||
{uploads.length > 0 ? (
|
||||
<ul className="m-0 flex list-none flex-col gap-1 p-0">
|
||||
{uploads.map(item => (
|
||||
<li key={item.key} className={metaClass}>
|
||||
{item.status === 'pending'
|
||||
? t('uploads.pending', { name: item.name })
|
||||
: item.status === 'done'
|
||||
? t('uploads.done', { name: item.name })
|
||||
: t('uploads.failed', { name: item.name, reason: t(`errors.${item.reason ?? 'unknown'}`) })}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
<ul className="m-0 flex list-none flex-col gap-4 p-0">
|
||||
{form.blocks.map((block, index) => (
|
||||
<EntryBlockCard
|
||||
key={block.key}
|
||||
type={block.type}
|
||||
data={block.data}
|
||||
index={index}
|
||||
total={form.blocks.length}
|
||||
media={media}
|
||||
accept={accept}
|
||||
dragging={dragIndex === index}
|
||||
onChange={data =>
|
||||
setForm(state => ({
|
||||
...state,
|
||||
blocks: state.blocks.map(item => (item.key === block.key ? { ...item, data } : item)),
|
||||
}))
|
||||
}
|
||||
onRemove={() =>
|
||||
setForm(state => ({ ...state, blocks: state.blocks.filter(item => item.key !== block.key) }))
|
||||
}
|
||||
onMove={to => moveBlock(index, to)}
|
||||
onDragStart={() => setDragIndex(index)}
|
||||
onDragEnd={() => setDragIndex(null)}
|
||||
onDropOn={() => {
|
||||
if (dragIndex !== null) {
|
||||
moveBlock(dragIndex, index)
|
||||
}
|
||||
|
||||
setDragIndex(null)
|
||||
}}
|
||||
upload={upload}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<button type="button" onClick={() => setAdding(value => !value)} className={ghostButtonClass}>
|
||||
{adding ? <TbX aria-hidden="true" className="size-4" /> : <TbPlus aria-hidden="true" className="size-4" />}
|
||||
{adding ? t('actions.close') : t('actions.addBlock')}
|
||||
</button>
|
||||
<span className={hintClass}>
|
||||
<TbPhotoPlus aria-hidden="true" className="mr-2 inline size-4 align-text-bottom" />
|
||||
{t('hints.drop')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{adding ? (
|
||||
<ul className="m-0 flex list-none flex-wrap gap-3 p-0">
|
||||
{blockTypes.map(type => (
|
||||
<li key={type}>
|
||||
<button type="button" onClick={() => addBlock(type)} className={ghostButtonClass}>
|
||||
{t(`blocks.${type}`)}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<aside className="flex min-w-0 flex-col gap-6 lg:sticky lg:top-20">
|
||||
<div className="grid gap-5">
|
||||
<AdminField
|
||||
id="entry-project"
|
||||
label={t('fields.project')}
|
||||
hint={entry ? t('hints.projectMove') : undefined}
|
||||
>
|
||||
<select
|
||||
id="entry-project"
|
||||
value={form.projectId}
|
||||
onChange={event => changeProject(event.target.value)}
|
||||
className={selectClass}
|
||||
>
|
||||
{projects.map(item => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</AdminField>
|
||||
|
||||
<AdminField id="entry-type" label={t('fields.type')}>
|
||||
<select
|
||||
id="entry-type"
|
||||
value={form.typeId}
|
||||
onChange={event => update({ typeId: event.target.value })}
|
||||
className={selectClass}
|
||||
>
|
||||
{types.map(item => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</AdminField>
|
||||
|
||||
<AdminField id="entry-audience" label={t('fields.audience')} hint={t('hints.audience')}>
|
||||
<select
|
||||
id="entry-audience"
|
||||
value={form.audience}
|
||||
onChange={event => update({ audience: event.target.value as Audience })}
|
||||
className={selectClass}
|
||||
>
|
||||
{audiences.map(item => (
|
||||
<option key={item} value={item}>
|
||||
{t(`audience.${item}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</AdminField>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-6 border-t border-rule pt-6">
|
||||
<EntryMediaPicker
|
||||
label={t('fields.cover')}
|
||||
media={media}
|
||||
selected={form.cover ? [form.cover] : []}
|
||||
accept={accept}
|
||||
onSelect={ids => update({ cover: ids[0] ?? null })}
|
||||
onUpload={files => void upload(files).then(ids => ids[0] && update({ cover: ids[0] }))}
|
||||
hint={t('hints.cover')}
|
||||
/>
|
||||
|
||||
<div className="grid gap-5">
|
||||
<AdminField id="entry-slug" label={t('fields.slug')} hint={t('hints.slug')}>
|
||||
<input
|
||||
id="entry-slug"
|
||||
value={form.slug}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
placeholder={slugify(form.title)}
|
||||
onChange={event => update({ slug: event.target.value })}
|
||||
className={`${inputClass} font-mono`}
|
||||
/>
|
||||
</AdminField>
|
||||
|
||||
<div className="flex min-w-0 flex-col gap-3">
|
||||
<span className={labelClass}>{t('schedule.title')}</span>
|
||||
|
||||
{current === 'scheduled' && status?.publishAt ? (
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<span suppressHydrationWarning className={metaClass}>
|
||||
{new Date(status.publishAt).toLocaleString()}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => void run(id => unscheduleEntry({ id }))}
|
||||
className={ghostButtonClass}
|
||||
>
|
||||
<TbCalendarX aria-hidden="true" className="size-4" />
|
||||
{t('actions.unschedule')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap items-end gap-4">
|
||||
<input
|
||||
id="entry-schedule"
|
||||
type="datetime-local"
|
||||
value={when}
|
||||
onChange={event => setWhen(event.target.value)}
|
||||
className={`${inputClass} font-mono`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || when === ''}
|
||||
onClick={() => void run(id => scheduleEntry({ id, publishAt: when }))}
|
||||
className={ghostButtonClass}
|
||||
>
|
||||
<TbCalendarClock aria-hidden="true" className="size-4" />
|
||||
{t('actions.schedule')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<span className={hintClass}>{t('hints.schedule')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{checks.hints.length > 0 || checks.blockers.length > 0 ? (
|
||||
<ul className="m-0 flex list-none flex-col gap-1 p-0">
|
||||
{checks.blockers.map(item => (
|
||||
<li key={item} className="font-mono text-micro text-signal">
|
||||
{t(`checks.${item}`)}
|
||||
</li>
|
||||
))}
|
||||
{checks.hints.map(item => (
|
||||
<li key={item} className={metaClass}>
|
||||
{t(`checks.${item}`)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</div> </aside>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
'use client'
|
||||
|
||||
import { useRef } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { TbSearch } from 'react-icons/tb'
|
||||
import { AdminField } from './AdminField'
|
||||
import { ghostButtonClass, inputClass, quietLinkClass, selectClass } from './styles'
|
||||
import { postStatuses } from '~/domain/post-status'
|
||||
import { adminEntriesPath } from '~/lib/admin-routes'
|
||||
import type { EntryTypeOption } from '~/lib/entry-types'
|
||||
|
||||
export type EntryFiltersProps = {
|
||||
projects: { slug: string, name: string }[]
|
||||
types: EntryTypeOption[]
|
||||
project: string
|
||||
status: string
|
||||
type: string
|
||||
search: string
|
||||
filtered: boolean
|
||||
}
|
||||
|
||||
export function EntryFilters({ projects, types, project, status, type, search, filtered }: EntryFiltersProps) {
|
||||
const t = useTranslations('admin.entries.filters')
|
||||
const states = useTranslations('admin.entries.status')
|
||||
const form = useRef<HTMLFormElement>(null)
|
||||
|
||||
function submit() {
|
||||
form.current?.requestSubmit()
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
ref={form}
|
||||
action={adminEntriesPath}
|
||||
className="flex flex-col gap-5 border border-rule bg-surface px-5 py-4"
|
||||
>
|
||||
<div className="grid gap-5 md:grid-cols-4">
|
||||
<AdminField id="filter-project" label={t('project')}>
|
||||
<select
|
||||
id="filter-project"
|
||||
name="project"
|
||||
defaultValue={project}
|
||||
onChange={submit}
|
||||
className={selectClass}
|
||||
>
|
||||
<option value="">{t('allProjects')}</option>
|
||||
{projects.map(item => (
|
||||
<option key={item.slug} value={item.slug}>
|
||||
{item.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</AdminField>
|
||||
|
||||
<AdminField id="filter-status" label={t('status')}>
|
||||
<select id="filter-status" name="status" defaultValue={status} onChange={submit} className={selectClass}>
|
||||
<option value="">{t('allStatus')}</option>
|
||||
{postStatuses.map(item => (
|
||||
<option key={item} value={item}>
|
||||
{states(item)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</AdminField>
|
||||
|
||||
<AdminField id="filter-type" label={t('type')}>
|
||||
<select id="filter-type" name="type" defaultValue={type} onChange={submit} className={selectClass}>
|
||||
<option value="">{t('allTypes')}</option>
|
||||
{types.map(item => (
|
||||
<option key={item.id} value={item.key}>
|
||||
{item.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</AdminField>
|
||||
|
||||
<AdminField id="filter-search" label={t('search')}>
|
||||
<input
|
||||
id="filter-search"
|
||||
name="q"
|
||||
type="search"
|
||||
defaultValue={search}
|
||||
placeholder={t('searchPlaceholder')}
|
||||
className={inputClass}
|
||||
/>
|
||||
</AdminField>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-6">
|
||||
<button type="submit" className={ghostButtonClass}>
|
||||
<TbSearch aria-hidden="true" className="size-4" />
|
||||
{t('submit')}
|
||||
</button>
|
||||
{filtered ? (
|
||||
<Link href={adminEntriesPath} className={quietLinkClass}>
|
||||
{t('reset')}
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
'use client'
|
||||
|
||||
import { useId, useState } from 'react'
|
||||
import Image from 'next/image'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { TbAlertTriangle, TbLibraryPhoto, TbTrash, TbUpload, TbX } from 'react-icons/tb'
|
||||
import { Scroller } from '~/components/ui/Scroller'
|
||||
import { ghostButtonClass, hintClass, labelClass, metaClass } from './styles'
|
||||
import type { EntryMedia } from '~/lib/entry-types'
|
||||
|
||||
export type EntryMediaPickerProps = {
|
||||
label: string
|
||||
media: EntryMedia[]
|
||||
selected: string[]
|
||||
multiple?: boolean
|
||||
accept: string
|
||||
onSelect: (ids: string[]) => void
|
||||
onUpload: (files: File[]) => void
|
||||
hint?: string
|
||||
}
|
||||
|
||||
export function EntryMediaPicker({
|
||||
label,
|
||||
media,
|
||||
selected,
|
||||
multiple = false,
|
||||
accept,
|
||||
onSelect,
|
||||
onUpload,
|
||||
hint,
|
||||
}: EntryMediaPickerProps) {
|
||||
const t = useTranslations('admin.entries')
|
||||
const [open, setOpen] = useState(false)
|
||||
const inputId = useId()
|
||||
const chosen = selected
|
||||
.map(id => media.find(item => item.id === id))
|
||||
.filter((item): item is EntryMedia => item !== undefined)
|
||||
|
||||
function toggle(id: string) {
|
||||
if (multiple) {
|
||||
onSelect(selected.includes(id) ? selected.filter(entry => entry !== id) : [...selected, id])
|
||||
return
|
||||
}
|
||||
|
||||
onSelect(selected[0] === id ? [] : [id])
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-3">
|
||||
<span className={labelClass}>{label}</span>
|
||||
|
||||
{chosen.length > 0 ? (
|
||||
<ul className="m-0 flex list-none flex-wrap gap-3 p-0">
|
||||
{chosen.map(item => (
|
||||
<li key={item.id} className="flex w-40 min-w-0 flex-col gap-1">
|
||||
<span className="relative block aspect-video w-full overflow-hidden border border-rule bg-paper">
|
||||
<Image src={item.thumbnail} alt={item.alt} fill sizes="10rem" className="object-cover" />
|
||||
</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-micro text-ink-3">{item.filename}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(selected.filter(entry => entry !== item.id))}
|
||||
aria-label={t('actions.clear')}
|
||||
className="cursor-pointer border-0 bg-transparent p-0 text-ink-3 hover:text-signal"
|
||||
>
|
||||
<TbTrash aria-hidden="true" className="size-4" />
|
||||
</button>
|
||||
</span>
|
||||
{item.alt.trim() === '' ? (
|
||||
<span className="flex items-center gap-1 font-mono text-micro text-caution">
|
||||
<TbAlertTriangle aria-hidden="true" className="size-3 shrink-0" />
|
||||
{t('media.altMissing')}
|
||||
</span>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<button type="button" onClick={() => setOpen(value => !value)} className={ghostButtonClass}>
|
||||
{open ? <TbX aria-hidden="true" className="size-4" /> : <TbLibraryPhoto aria-hidden="true" className="size-4" />}
|
||||
{open ? t('actions.close') : chosen.length > 0 ? t('actions.change') : t('actions.choose')}
|
||||
</button>
|
||||
|
||||
<label htmlFor={inputId} className={ghostButtonClass}>
|
||||
<TbUpload aria-hidden="true" className="size-4" />
|
||||
{t('actions.upload')}
|
||||
<input
|
||||
id={inputId}
|
||||
type="file"
|
||||
accept={accept}
|
||||
multiple={multiple}
|
||||
className="sr-only"
|
||||
onChange={event => {
|
||||
const files = [...(event.target.files ?? [])]
|
||||
event.target.value = ''
|
||||
|
||||
if (files.length > 0) {
|
||||
onUpload(files)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{hint ? <span className={hintClass}>{hint}</span> : null}
|
||||
|
||||
{open ? (
|
||||
media.length === 0 ? (
|
||||
<p className={`m-0 border border-rule bg-surface px-4 py-3 ${hintClass}`}>{t('media.empty')}</p>
|
||||
) : (
|
||||
<Scroller className="max-h-96 border border-rule bg-surface">
|
||||
<ul className="m-0 grid list-none grid-cols-2 gap-3 p-3 md:grid-cols-4">
|
||||
{media.map(item => {
|
||||
const active = selected.includes(item.id)
|
||||
|
||||
return (
|
||||
<li key={item.id} className="min-w-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggle(item.id)}
|
||||
aria-pressed={active}
|
||||
className={`flex w-full cursor-pointer flex-col gap-1 border bg-paper p-1 text-left ${
|
||||
active ? 'border-signal' : 'border-rule hover:border-ink-3'
|
||||
}`}
|
||||
>
|
||||
<span className="relative block aspect-video w-full overflow-hidden">
|
||||
<Image
|
||||
src={item.thumbnail}
|
||||
alt={item.alt}
|
||||
fill
|
||||
sizes="(min-width: 46rem) 12rem, 45vw"
|
||||
className="object-cover"
|
||||
/>
|
||||
</span>
|
||||
<span className="truncate font-mono text-micro text-ink-2">{item.filename}</span>
|
||||
<span className={metaClass}>
|
||||
{item.alt.trim() === '' ? t('media.altMissing') : `${item.width} x ${item.height}`}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</Scroller>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
'use client'
|
||||
|
||||
import Image from 'next/image'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { TbAlertTriangle, TbArrowUpRight, TbInfoCircle, TbPlayerPlay } from 'react-icons/tb'
|
||||
import { AdminNotice } from './AdminNotice'
|
||||
import { EntryStatusChip } from './EntryStatusChip'
|
||||
import { hintClass, labelClass } from './styles'
|
||||
import { Scroller } from '~/components/ui/Scroller'
|
||||
import { canSee } from '~/domain/audience'
|
||||
import type { BlockType, BlockValues } from '~/domain/blocks'
|
||||
import type { Audience, PostStatus } from '~/domain/types'
|
||||
import type { EntryMedia } from '~/lib/entry-types'
|
||||
|
||||
export type EntryPreviewBlock = {
|
||||
key: string
|
||||
type: BlockType
|
||||
data: BlockValues
|
||||
}
|
||||
|
||||
export type EntryPreviewProps = {
|
||||
title: string
|
||||
teaser: string
|
||||
typeLabel: string
|
||||
typeColor: string
|
||||
audience: Audience
|
||||
status: PostStatus
|
||||
number: number | null
|
||||
code: string
|
||||
color: string
|
||||
media: EntryMedia[]
|
||||
cover: string | null
|
||||
blocks: EntryPreviewBlock[]
|
||||
scope: Audience
|
||||
onScope: (scope: Audience) => void
|
||||
}
|
||||
|
||||
const scopes: Audience[] = ['internal', 'customer', 'public']
|
||||
|
||||
function text(data: BlockValues, key: string): string {
|
||||
const value = data[key]
|
||||
|
||||
return typeof value === 'string' ? value.trim() : ''
|
||||
}
|
||||
|
||||
function ids(data: BlockValues, key: string): string[] {
|
||||
const value = data[key]
|
||||
|
||||
return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : []
|
||||
}
|
||||
|
||||
function Picture({ item }: { item: EntryMedia }) {
|
||||
return (
|
||||
<figure className="m-0 flex flex-col gap-2">
|
||||
<span className="relative block aspect-shot w-full overflow-hidden border border-rule bg-surface">
|
||||
<Image src={item.src} alt={item.alt} fill sizes="(min-width: 46rem) 38rem, 92vw" className="object-cover" />
|
||||
</span>
|
||||
{item.caption === '' ? null : (
|
||||
<figcaption className="font-mono text-micro text-ink-3">{item.caption}</figcaption>
|
||||
)}
|
||||
</figure>
|
||||
)
|
||||
}
|
||||
|
||||
export function EntryPreview({
|
||||
title,
|
||||
teaser,
|
||||
typeLabel,
|
||||
typeColor,
|
||||
audience,
|
||||
status,
|
||||
number,
|
||||
code,
|
||||
color,
|
||||
media,
|
||||
cover,
|
||||
blocks,
|
||||
scope,
|
||||
onScope,
|
||||
}: EntryPreviewProps) {
|
||||
const t = useTranslations('admin.entries')
|
||||
const visible = canSee(scope, audience)
|
||||
const find = (id: string) => media.find(item => item.id === id)
|
||||
const coverItem = cover ? find(cover) : undefined
|
||||
|
||||
return (
|
||||
<section className="flex flex-col gap-6">
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
|
||||
<span className={labelClass}>{t('preview.scope')}</span>
|
||||
{scopes.map(entry => (
|
||||
<button
|
||||
key={entry}
|
||||
type="button"
|
||||
onClick={() => onScope(entry)}
|
||||
aria-pressed={scope === entry}
|
||||
className={`cursor-pointer border px-3 py-1 font-mono text-micro font-semibold uppercase tracking-chip ${
|
||||
scope === entry ? 'border-ink bg-ink text-paper' : 'border-rule bg-transparent text-ink-2 hover:border-ink-3'
|
||||
}`}
|
||||
>
|
||||
{t(`audience.${entry}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{visible ? (
|
||||
<article className="flex flex-col gap-6 border border-rule bg-surface px-6 py-6">
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
|
||||
<span
|
||||
style={{ background: color }}
|
||||
className="inline-flex items-center px-2 py-1 font-mono text-micro font-semibold uppercase tracking-mark text-paper"
|
||||
>
|
||||
{number === null ? code : `${code}-${String(number).padStart(4, '0')}`}
|
||||
</span>
|
||||
<span style={{ color: typeColor }} className="font-mono text-micro font-semibold uppercase tracking-mark">
|
||||
{typeLabel}
|
||||
</span>
|
||||
<EntryStatusChip status={status} label={t(`status.${status}`)} />
|
||||
</div>
|
||||
|
||||
{coverItem ? <Picture item={coverItem} /> : null}
|
||||
|
||||
<h2 className="m-0 text-title font-bold text-balance text-ink">
|
||||
{title.trim() === '' ? t('preview.untitled') : title}
|
||||
</h2>
|
||||
|
||||
{teaser.trim() === '' ? null : <p className="m-0 max-w-read text-lead text-pretty text-ink-2">{teaser}</p>}
|
||||
|
||||
{blocks.length === 0 ? (
|
||||
<p className={`m-0 ${hintClass}`}>{t('preview.empty')}</p>
|
||||
) : (
|
||||
<div className="flex max-w-read flex-col gap-8">
|
||||
{blocks.map(block => {
|
||||
if (block.type === 'text') {
|
||||
const body = text(block.data, 'text')
|
||||
|
||||
return body === '' ? null : (
|
||||
<div key={block.key} className="flex flex-col gap-4">
|
||||
{body.split(/\n{2,}/).map((part, index) => (
|
||||
<p key={index} className="m-0 text-base text-ink">
|
||||
{part.trim()}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (block.type === 'image' || block.type === 'video') {
|
||||
const item = find(text(block.data, 'mediaId'))
|
||||
const url = text(block.data, 'url')
|
||||
|
||||
if (item) {
|
||||
return <Picture key={block.key} item={item} />
|
||||
}
|
||||
|
||||
return url === '' ? null : (
|
||||
<span
|
||||
key={block.key}
|
||||
className="inline-flex items-center gap-3 self-start border border-rule px-4 py-3 font-display text-small text-ink"
|
||||
>
|
||||
<TbPlayerPlay aria-hidden="true" className="size-4 text-signal" />
|
||||
{text(block.data, 'title') === '' ? url : text(block.data, 'title')}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
if (block.type === 'gallery') {
|
||||
const items = ids(block.data, 'mediaIds').map(find).filter((item): item is EntryMedia => item !== undefined)
|
||||
|
||||
return items.length === 0 ? null : (
|
||||
<div key={block.key} className="grid grid-cols-2 gap-3">
|
||||
{items.map(item => (
|
||||
<Picture key={item.id} item={item} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (block.type === 'before_after') {
|
||||
const before = find(text(block.data, 'beforeMediaId'))
|
||||
const after = find(text(block.data, 'afterMediaId'))
|
||||
|
||||
return !before && !after ? null : (
|
||||
<div key={block.key} className="grid gap-3 md:grid-cols-2">
|
||||
{before ? <Picture item={before} /> : null}
|
||||
{after ? <Picture item={after} /> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (block.type === 'quote') {
|
||||
const body = text(block.data, 'text')
|
||||
|
||||
return body === '' ? null : (
|
||||
<blockquote key={block.key} className="m-0 border-l-2 border-signal pl-5">
|
||||
<p className="m-0 text-lead text-ink">{body}</p>
|
||||
{text(block.data, 'source') === '' ? null : (
|
||||
<cite className="mt-2 block font-mono text-micro not-italic text-ink-3">
|
||||
{text(block.data, 'source')}
|
||||
</cite>
|
||||
)}
|
||||
</blockquote>
|
||||
)
|
||||
}
|
||||
|
||||
if (block.type === 'code') {
|
||||
const body = text(block.data, 'code')
|
||||
|
||||
return body === '' ? null : (
|
||||
<div key={block.key} className="overflow-hidden border border-rule">
|
||||
{text(block.data, 'language') === '' ? null : (
|
||||
<div className="border-b border-rule px-4 py-2 font-mono text-micro uppercase tracking-chip text-ink-3">
|
||||
{text(block.data, 'language')}
|
||||
</div>
|
||||
)}
|
||||
<Scroller className="max-h-96">
|
||||
<pre className="m-0 w-max min-w-full px-4 py-3 font-mono text-small text-ink">
|
||||
<code>{body}</code>
|
||||
</pre>
|
||||
</Scroller>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (block.type === 'link') {
|
||||
const url = text(block.data, 'url')
|
||||
|
||||
return url === '' ? null : (
|
||||
<span key={block.key} className="flex items-start gap-3 border border-rule px-4 py-3">
|
||||
<TbArrowUpRight aria-hidden="true" className="mt-1 size-4 shrink-0 text-ink-3" />
|
||||
<span className="flex flex-col gap-1">
|
||||
<span className="font-display text-small text-ink">
|
||||
{text(block.data, 'label') === '' ? url : text(block.data, 'label')}
|
||||
</span>
|
||||
{text(block.data, 'description') === '' ? null : (
|
||||
<span className="text-small text-ink-2">{text(block.data, 'description')}</span>
|
||||
)}
|
||||
<span className="font-mono text-micro text-ink-3">{url}</span>
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const body = text(block.data, 'text')
|
||||
const warning = text(block.data, 'tone') === 'warning'
|
||||
const Icon = warning ? TbAlertTriangle : TbInfoCircle
|
||||
|
||||
return body === '' ? null : (
|
||||
<span
|
||||
key={block.key}
|
||||
className={`flex items-start gap-3 border border-rule border-l-2 px-4 py-3 ${
|
||||
warning ? 'border-l-signal' : 'border-l-link'
|
||||
}`}
|
||||
>
|
||||
<Icon aria-hidden="true" className={`mt-1 size-4 shrink-0 ${warning ? 'text-signal' : 'text-link'}`} />
|
||||
<span className="flex flex-col gap-1">
|
||||
{text(block.data, 'title') === '' ? null : (
|
||||
<span className="font-display text-small text-ink">{text(block.data, 'title')}</span>
|
||||
)}
|
||||
<span className="text-ink-2">{body}</span>
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
) : (
|
||||
<AdminNotice title={t('preview.hiddenTitle')}>
|
||||
{t('preview.hidden', { scope: t(`audience.${scope}`), audience: t(`audience.${audience}`) })}
|
||||
</AdminNotice>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { PostStatus } from '~/domain/types'
|
||||
|
||||
const tones: Record<PostStatus, string> = {
|
||||
draft: 'border-rule text-ink-3',
|
||||
review: 'border-caution text-caution',
|
||||
scheduled: 'border-link text-link',
|
||||
published: 'border-positive text-positive',
|
||||
archived: 'border-rule text-ink-3',
|
||||
}
|
||||
|
||||
export type EntryStatusChipProps = {
|
||||
status: PostStatus
|
||||
label: string
|
||||
}
|
||||
|
||||
export function EntryStatusChip({ status, label }: EntryStatusChipProps) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex shrink-0 items-center border px-2 py-0.5 font-mono text-micro font-semibold uppercase tracking-chip ${tones[status]}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
'use client'
|
||||
|
||||
import { useActionState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { TbAlertTriangle, TbArrowNarrowRight } from 'react-icons/tb'
|
||||
import { signIn } from '~/lib/auth-actions'
|
||||
import { emptyLoginState, type LoginErrorKey, type LoginState } from '~/lib/auth-types'
|
||||
|
||||
export type LoginFormProps = {
|
||||
next?: string
|
||||
}
|
||||
|
||||
const fieldClass = 'min-w-0 border-b border-rule bg-transparent px-1 py-2 font-body text-base text-ink placeholder:text-ink-3 focus:border-signal'
|
||||
|
||||
const labelClass = 'font-mono text-micro font-semibold uppercase tracking-label text-ink-3'
|
||||
|
||||
export function LoginForm({ next }: LoginFormProps) {
|
||||
const t = useTranslations('login')
|
||||
const [state, formAction, pending] = useActionState<LoginState, FormData>(signIn, emptyLoginState)
|
||||
|
||||
const messages: Record<LoginErrorKey, string> = {
|
||||
email: t('errors.email'),
|
||||
password: t('errors.password'),
|
||||
credentials: t('errors.credentials'),
|
||||
tooMany: t('errors.tooMany'),
|
||||
unknown: t('errors.unknown'),
|
||||
}
|
||||
|
||||
return (
|
||||
<form action={formAction} className="flex flex-col gap-6">
|
||||
{next ? <input type="hidden" name="next" value={next} /> : null}
|
||||
|
||||
{state.error ? (
|
||||
<p role="alert" className="m-0 flex gap-3 border-l-2 border-signal bg-surface px-4 py-3 text-small text-pretty text-ink-2">
|
||||
<TbAlertTriangle aria-hidden="true" className="mt-1 size-4 shrink-0 text-signal" />
|
||||
<span>{messages[state.error]}</span>
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="email" className={labelClass}>
|
||||
{t('email')}
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="username"
|
||||
required
|
||||
defaultValue={state.email ?? ''}
|
||||
className={fieldClass}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="password" className={labelClass}>
|
||||
{t('password')}
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
className={fieldClass}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-4 pt-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={pending}
|
||||
className="inline-flex cursor-pointer items-center gap-2 bg-ink px-4 py-2.5 font-mono text-micro font-semibold uppercase tracking-label text-paper hover:bg-signal disabled:cursor-progress disabled:opacity-60"
|
||||
>
|
||||
<TbArrowNarrowRight aria-hidden="true" className="size-4 shrink-0" />
|
||||
{pending ? t('pending') : t('submit')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
'use client'
|
||||
|
||||
import { useActionState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { TbMailForward } from 'react-icons/tb'
|
||||
import {
|
||||
emptyMagicLinkState,
|
||||
magicLinkMinutes,
|
||||
type MagicLinkErrorKey,
|
||||
type MagicLinkState,
|
||||
} from '~/domain/magic-link'
|
||||
import { requestMagicLink } from '~/lib/auth-actions'
|
||||
import { loginPath } from '~/lib/auth-routes'
|
||||
import { AdminNotice } from './AdminNotice'
|
||||
import { inputClass, labelClass, primaryButtonClass, quietLinkClass } from './styles'
|
||||
|
||||
export type MagicLinkFormProps = {
|
||||
next?: string
|
||||
}
|
||||
|
||||
export function MagicLinkForm({ next }: MagicLinkFormProps) {
|
||||
const t = useTranslations('login.magic')
|
||||
const [state, formAction, pending] = useActionState<MagicLinkState, FormData>(
|
||||
requestMagicLink,
|
||||
emptyMagicLinkState,
|
||||
)
|
||||
|
||||
const messages: Record<MagicLinkErrorKey, string> = {
|
||||
email: t('errors.email'),
|
||||
tooMany: t('errors.tooMany', { minutes: magicLinkMinutes }),
|
||||
}
|
||||
|
||||
if (state.status === 'sent') {
|
||||
return (
|
||||
<AdminNotice
|
||||
tone="done"
|
||||
title={t('sentTitle')}
|
||||
action={
|
||||
<Link href={loginPath(next)} className={quietLinkClass}>
|
||||
{t('again')}
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
{`${t('sent', { email: state.email ?? '' })} ${t('sentHint', { minutes: magicLinkMinutes })}`}
|
||||
</AdminNotice>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<form action={formAction} className="flex flex-col gap-6">
|
||||
{next ? <input type="hidden" name="next" value={next} /> : null}
|
||||
|
||||
{state.error ? <AdminNotice tone="alert">{messages[state.error]}</AdminNotice> : null}
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="magic-email" className={labelClass}>
|
||||
{t('email')}
|
||||
</label>
|
||||
<input
|
||||
id="magic-email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="username"
|
||||
required
|
||||
defaultValue={state.email ?? ''}
|
||||
className={inputClass}
|
||||
/>
|
||||
<p className="m-0 text-small text-pretty text-ink-3">{t('intro')}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<button type="submit" disabled={pending} className={primaryButtonClass}>
|
||||
<TbMailForward aria-hidden="true" className="size-4 shrink-0" />
|
||||
{pending ? t('pending') : t('submit')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import Image from 'next/image'
|
||||
import { getTranslations } from 'next-intl/server'
|
||||
import { TbAlertTriangle } from 'react-icons/tb'
|
||||
import { MediaMetaForm } from './MediaMetaForm'
|
||||
import { metaClass } from './styles'
|
||||
import { hasAlt, mediaAlt, mediaThumbnail } from '~/lib/media-url'
|
||||
import type { Media } from '~/data/schema'
|
||||
|
||||
export type MediaCardProps = {
|
||||
item: Media
|
||||
project: string
|
||||
}
|
||||
|
||||
function kilobytes(bytes: number): number {
|
||||
return Math.max(1, Math.round(bytes / 1024))
|
||||
}
|
||||
|
||||
export async function MediaCard({ item, project }: MediaCardProps) {
|
||||
const t = await getTranslations('media')
|
||||
|
||||
return (
|
||||
<li className="flex flex-col gap-5 border border-rule bg-surface p-5 md:flex-row">
|
||||
<div className="relative aspect-video w-full shrink-0 overflow-hidden border border-rule bg-paper md:w-2/5">
|
||||
<Image
|
||||
src={mediaThumbnail(item)}
|
||||
alt={mediaAlt(item)}
|
||||
fill
|
||||
sizes="(min-width: 46rem) 20rem, 100vw"
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="truncate font-display text-base font-semibold text-ink">{item.originalFilename}</span>
|
||||
<span className={metaClass}>
|
||||
{t('card.meta', {
|
||||
width: item.width ?? 0,
|
||||
height: item.height ?? 0,
|
||||
size: kilobytes(item.byteSize),
|
||||
variants: item.variants.length,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{item.variantError ? (
|
||||
<p role="alert" className="m-0 flex gap-3 border-l-2 border-signal px-4 py-2 text-small text-pretty text-ink-2">
|
||||
<TbAlertTriangle aria-hidden="true" className="mt-1 size-4 shrink-0 text-signal" />
|
||||
<span>{t('card.variantError')}</span>
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{hasAlt(item) ? null : (
|
||||
<p className="m-0 flex gap-3 border-l-2 border-caution px-4 py-2 text-small text-pretty text-ink-2">
|
||||
<TbAlertTriangle aria-hidden="true" className="mt-1 size-4 shrink-0 text-caution" />
|
||||
<span>{t('card.altMissing')}</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<MediaMetaForm mediaId={item.id} project={project} alt={item.alt ?? ''} caption={item.caption ?? ''} />
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { AdminNotice } from './AdminNotice'
|
||||
import type { AdminFormState } from '~/lib/admin-forms'
|
||||
|
||||
export type MediaMessageProps = {
|
||||
state: AdminFormState
|
||||
silentOnDone?: boolean
|
||||
}
|
||||
|
||||
export function MediaMessage({ state, silentOnDone = false }: MediaMessageProps) {
|
||||
const t = useTranslations('media.messages')
|
||||
|
||||
if (state.status === 'idle' || !state.message) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (state.status === 'ok' && silentOnDone) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <AdminNotice tone={state.status === 'error' ? 'alert' : 'done'}>{t(state.message)}</AdminNotice>
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
'use client'
|
||||
|
||||
import { useActionState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { TbDeviceFloppy } from 'react-icons/tb'
|
||||
import { AdminField } from './AdminField'
|
||||
import { AdminSubmit } from './AdminSubmit'
|
||||
import { MediaMessage } from './MediaMessage'
|
||||
import { inputClass } from './styles'
|
||||
import { emptyAdminFormState, type AdminFormState } from '~/lib/admin-forms'
|
||||
import { saveMediaMetaAction } from '~/lib/media-actions'
|
||||
|
||||
export type MediaMetaFormProps = {
|
||||
mediaId: string
|
||||
project: string
|
||||
alt: string
|
||||
caption: string
|
||||
}
|
||||
|
||||
export function MediaMetaForm({ mediaId, project, alt, caption }: MediaMetaFormProps) {
|
||||
const t = useTranslations('media')
|
||||
const [state, formAction] = useActionState<AdminFormState, FormData>(saveMediaMetaAction, emptyAdminFormState)
|
||||
|
||||
return (
|
||||
<form action={formAction} className="flex flex-col gap-4">
|
||||
<input type="hidden" name="mediaId" value={mediaId} />
|
||||
<input type="hidden" name="project" value={project} />
|
||||
|
||||
<MediaMessage state={state} />
|
||||
|
||||
<AdminField id={`alt-${mediaId}`} label={t('fields.alt')}>
|
||||
<input
|
||||
id={`alt-${mediaId}`}
|
||||
name="alt"
|
||||
type="text"
|
||||
defaultValue={alt}
|
||||
placeholder={t('fields.altPlaceholder')}
|
||||
className={inputClass}
|
||||
/>
|
||||
</AdminField>
|
||||
|
||||
<AdminField id={`caption-${mediaId}`} label={t('fields.caption')}>
|
||||
<input id={`caption-${mediaId}`} name="caption" type="text" defaultValue={caption} className={inputClass} />
|
||||
</AdminField>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<AdminSubmit quiet pendingLabel={t('meta.pending')} icon={<TbDeviceFloppy className="size-4" />}>
|
||||
{t('meta.submit')}
|
||||
</AdminSubmit>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
'use client'
|
||||
|
||||
import { useActionState, useEffect, useRef } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { TbUpload } from 'react-icons/tb'
|
||||
import { AdminField } from './AdminField'
|
||||
import { AdminSubmit } from './AdminSubmit'
|
||||
import { MediaMessage } from './MediaMessage'
|
||||
import { fileClass, hintClass, inputClass, labelClass } from './styles'
|
||||
import { emptyAdminFormState, type AdminFormState } from '~/lib/admin-forms'
|
||||
import { uploadMediaAction } from '~/lib/media-actions'
|
||||
|
||||
export type MediaUploadFormProps = {
|
||||
project: string
|
||||
accept: string
|
||||
}
|
||||
|
||||
export function MediaUploadForm({ project, accept }: MediaUploadFormProps) {
|
||||
const t = useTranslations('media')
|
||||
const form = useRef<HTMLFormElement>(null)
|
||||
const [state, formAction] = useActionState<AdminFormState, FormData>(uploadMediaAction, emptyAdminFormState)
|
||||
|
||||
useEffect(() => {
|
||||
if (state.status === 'ok') {
|
||||
form.current?.reset()
|
||||
}
|
||||
}, [state])
|
||||
|
||||
return (
|
||||
<form ref={form} action={formAction} className="flex flex-col gap-5 border border-rule bg-surface px-5 py-5">
|
||||
<input type="hidden" name="project" value={project} />
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className={labelClass}>{t('upload.title')}</span>
|
||||
<p className={`m-0 max-w-read text-pretty ${hintClass}`}>{t('upload.hint')}</p>
|
||||
</div>
|
||||
|
||||
<MediaMessage state={state} silentOnDone />
|
||||
|
||||
<AdminField id="media-file" label={t('upload.file')} hint={t('upload.limit')}>
|
||||
<input id="media-file" name="file" type="file" accept={accept} required className={fileClass} />
|
||||
</AdminField>
|
||||
|
||||
<AdminField id="media-alt" label={t('fields.alt')} hint={t('fields.altHint')}>
|
||||
<input id="media-alt" name="alt" type="text" placeholder={t('fields.altPlaceholder')} className={inputClass} />
|
||||
</AdminField>
|
||||
|
||||
<AdminField id="media-caption" label={t('fields.caption')}>
|
||||
<input id="media-caption" name="caption" type="text" className={inputClass} />
|
||||
</AdminField>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<AdminSubmit pendingLabel={t('upload.pending')} icon={<TbUpload className="size-4" />}>
|
||||
{t('upload.submit')}
|
||||
</AdminSubmit>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
'use client'
|
||||
|
||||
import { useActionState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { TbTrash } from 'react-icons/tb'
|
||||
import { AdminSubmit } from './AdminSubmit'
|
||||
import { errorClass, hintClass } from './styles'
|
||||
import { removePostType } from '~/lib/admin-actions'
|
||||
import { emptyAdminFormState } from '~/lib/admin-forms'
|
||||
|
||||
export type PostTypeDeleteButtonProps = {
|
||||
id: string
|
||||
postCount: number
|
||||
}
|
||||
|
||||
export function PostTypeDeleteButton({ id, postCount }: PostTypeDeleteButtonProps) {
|
||||
const t = useTranslations('admin.postTypes')
|
||||
const messages = useTranslations('admin.messages')
|
||||
const [state, formAction] = useActionState(removePostType, emptyAdminFormState)
|
||||
|
||||
if (postCount > 0) {
|
||||
return <span className={hintClass}>{t('deleteBlocked', { count: postCount })}</span>
|
||||
}
|
||||
|
||||
return (
|
||||
<form action={formAction} className="flex flex-col gap-2">
|
||||
<input type="hidden" name="id" value={id} />
|
||||
<AdminSubmit quiet pendingLabel={t('deleting')} icon={<TbTrash className="size-4" />}>
|
||||
{t('delete')}
|
||||
</AdminSubmit>
|
||||
{state.status === 'error' && state.message ? (
|
||||
<span role="alert" className={errorClass}>
|
||||
{messages(state.message)}
|
||||
</span>
|
||||
) : null}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
'use client'
|
||||
|
||||
import { useActionState, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { TbDeviceFloppy } from 'react-icons/tb'
|
||||
import { AdminColorField } from './AdminColorField'
|
||||
import { AdminField } from './AdminField'
|
||||
import { AdminFormMessage } from './AdminFormMessage'
|
||||
import { AdminSubmit } from './AdminSubmit'
|
||||
import { checkboxClass, hintClass, inputClass, quietLinkClass } from './styles'
|
||||
import { savePostType } from '~/lib/admin-actions'
|
||||
import { emptyAdminFormState } from '~/lib/admin-forms'
|
||||
import { adminPostTypesPath } from '~/lib/admin-routes'
|
||||
import { defaultPostTypeColor, maxPostTypeLabelLength, maxPostTypeSort } from '~/domain/post-type'
|
||||
import { slugify } from '~/domain/slug'
|
||||
|
||||
export type PostTypeFormValues = {
|
||||
id: string
|
||||
key: string
|
||||
labelDe: string
|
||||
labelEn: string
|
||||
color: string
|
||||
sort: number
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
export type PostTypeFormProps = {
|
||||
postType?: PostTypeFormValues
|
||||
locked?: boolean
|
||||
}
|
||||
|
||||
export function PostTypeForm({ postType, locked = false }: PostTypeFormProps) {
|
||||
const t = useTranslations('admin.postTypes')
|
||||
const errors = useTranslations('admin.errors')
|
||||
const [state, formAction] = useActionState(savePostType, emptyAdminFormState)
|
||||
const [labelDe, setLabelDe] = useState(postType?.labelDe ?? '')
|
||||
const [labelEn, setLabelEn] = useState(postType?.labelEn ?? '')
|
||||
const [key, setKey] = useState(postType?.key ?? '')
|
||||
const [keyTouched, setKeyTouched] = useState((postType?.key ?? '') !== '')
|
||||
const [color, setColor] = useState(postType?.color ?? defaultPostTypeColor)
|
||||
const [sort, setSort] = useState(String(postType?.sort ?? 0))
|
||||
const [isActive, setIsActive] = useState(postType?.isActive ?? true)
|
||||
|
||||
function fieldError(field: string): string | undefined {
|
||||
const value = state.fields?.[field]
|
||||
return value ? errors(value) : undefined
|
||||
}
|
||||
|
||||
return (
|
||||
<form action={formAction} className="flex flex-col gap-8">
|
||||
{postType ? <input type="hidden" name="id" value={postType.id} /> : null}
|
||||
|
||||
<AdminFormMessage state={state} />
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<AdminField id="labelDe" label={t('fields.labelDe')} error={fieldError('labelDe')}>
|
||||
<input
|
||||
id="labelDe"
|
||||
name="labelDe"
|
||||
value={labelDe}
|
||||
required
|
||||
maxLength={maxPostTypeLabelLength}
|
||||
autoComplete="off"
|
||||
onChange={event => {
|
||||
setLabelDe(event.target.value)
|
||||
|
||||
if (!keyTouched) {
|
||||
setKey(slugify(event.target.value))
|
||||
}
|
||||
}}
|
||||
className={inputClass}
|
||||
/>
|
||||
</AdminField>
|
||||
|
||||
<AdminField id="labelEn" label={t('fields.labelEn')} error={fieldError('labelEn')}>
|
||||
<input
|
||||
id="labelEn"
|
||||
name="labelEn"
|
||||
value={labelEn}
|
||||
maxLength={maxPostTypeLabelLength}
|
||||
autoComplete="off"
|
||||
onChange={event => setLabelEn(event.target.value)}
|
||||
className={inputClass}
|
||||
/>
|
||||
</AdminField>
|
||||
|
||||
<AdminField id="key" label={t('fields.key')} hint={t('hints.key')} error={fieldError('key')}>
|
||||
<input
|
||||
id="key"
|
||||
name="key"
|
||||
value={key}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
onChange={event => {
|
||||
setKeyTouched(true)
|
||||
setKey(event.target.value)
|
||||
}}
|
||||
className={`${inputClass} font-mono`}
|
||||
/>
|
||||
</AdminField>
|
||||
|
||||
<AdminColorField
|
||||
id="color"
|
||||
name="color"
|
||||
label={t('fields.color')}
|
||||
pickerLabel={t('fields.colorPicker')}
|
||||
value={color}
|
||||
onChange={setColor}
|
||||
hint={t('hints.color')}
|
||||
error={fieldError('color')}
|
||||
/>
|
||||
|
||||
<AdminField id="sort" label={t('fields.sort')} hint={t('hints.sort')} error={fieldError('sort')}>
|
||||
<input
|
||||
id="sort"
|
||||
name="sort"
|
||||
type="number"
|
||||
min={0}
|
||||
max={maxPostTypeSort}
|
||||
step={1}
|
||||
value={sort}
|
||||
onChange={event => setSort(event.target.value)}
|
||||
className={`${inputClass} font-mono tabular-nums`}
|
||||
/>
|
||||
</AdminField>
|
||||
|
||||
<div className="flex min-w-0 flex-col gap-2">
|
||||
<label htmlFor="isActive" className="flex items-center gap-3">
|
||||
<input
|
||||
id="isActive"
|
||||
name="isActive"
|
||||
type="checkbox"
|
||||
checked={isActive}
|
||||
onChange={event => setIsActive(event.target.checked)}
|
||||
className={checkboxClass}
|
||||
/>
|
||||
<span className="font-mono text-micro font-semibold uppercase tracking-label text-ink-3">
|
||||
{t('fields.isActive')}
|
||||
</span>
|
||||
</label>
|
||||
<span className={hintClass}>{locked ? t('hints.activeInUse') : t('hints.isActive')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-6">
|
||||
<AdminSubmit pendingLabel={t('saving')} icon={<TbDeviceFloppy className="size-4" />}>
|
||||
{postType ? t('save') : t('create')}
|
||||
</AdminSubmit>
|
||||
<Link href={adminPostTypesPath} className={quietLinkClass}>
|
||||
{t('back')}
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
'use client'
|
||||
|
||||
import { useActionState, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { TbDeviceFloppy } from 'react-icons/tb'
|
||||
import { AdminColorField } from './AdminColorField'
|
||||
import { AdminField } from './AdminField'
|
||||
import { AdminFormMessage } from './AdminFormMessage'
|
||||
import { AdminSubmit } from './AdminSubmit'
|
||||
import { checkboxClass, hintClass, inputClass, labelClass, quietLinkClass, selectClass, textareaClass } from './styles'
|
||||
import { saveProject } from '~/lib/admin-actions'
|
||||
import { emptyAdminFormState } from '~/lib/admin-forms'
|
||||
import { adminProjectsPath } from '~/lib/admin-routes'
|
||||
import { defaultColor } from '~/domain/color'
|
||||
import { slugify } from '~/domain/slug'
|
||||
|
||||
export type ProjectFormValues = {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
code: string
|
||||
description: string
|
||||
color: string
|
||||
brandId: string
|
||||
sort: number
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
export type ProjectFormProps = {
|
||||
brands: { id: string, name: string }[]
|
||||
project?: ProjectFormValues
|
||||
}
|
||||
|
||||
export function ProjectForm({ brands, project }: ProjectFormProps) {
|
||||
const t = useTranslations('admin.projects')
|
||||
const errors = useTranslations('admin.errors')
|
||||
const [state, formAction] = useActionState(saveProject, emptyAdminFormState)
|
||||
const [name, setName] = useState(project?.name ?? '')
|
||||
const [slug, setSlug] = useState(project?.slug ?? '')
|
||||
const [slugTouched, setSlugTouched] = useState((project?.slug ?? '') !== '')
|
||||
const [code, setCode] = useState(project?.code ?? '')
|
||||
const [description, setDescription] = useState(project?.description ?? '')
|
||||
const [color, setColor] = useState(project?.color ?? defaultColor)
|
||||
const [brandId, setBrandId] = useState(project?.brandId ?? brands[0]?.id ?? '')
|
||||
const [sort, setSort] = useState(String(project?.sort ?? 0))
|
||||
const [active, setActive] = useState(project?.isActive ?? true)
|
||||
|
||||
function fieldError(field: string): string | undefined {
|
||||
const key = state.fields?.[field]
|
||||
return key ? errors(key) : undefined
|
||||
}
|
||||
|
||||
function changeName(value: string) {
|
||||
setName(value)
|
||||
|
||||
if (!slugTouched) {
|
||||
setSlug(slugify(value))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form action={formAction} className="flex flex-col gap-8">
|
||||
{project ? <input type="hidden" name="id" value={project.id} /> : null}
|
||||
|
||||
<AdminFormMessage state={state} />
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<AdminField id="name" label={t('fields.name')} error={fieldError('name')}>
|
||||
<input
|
||||
id="name"
|
||||
name="name"
|
||||
value={name}
|
||||
required
|
||||
autoComplete="off"
|
||||
onChange={event => changeName(event.target.value)}
|
||||
className={inputClass}
|
||||
/>
|
||||
</AdminField>
|
||||
|
||||
<AdminField id="slug" label={t('fields.slug')} hint={t('hints.slug')} error={fieldError('slug')}>
|
||||
<input
|
||||
id="slug"
|
||||
name="slug"
|
||||
value={slug}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
onChange={event => {
|
||||
setSlugTouched(true)
|
||||
setSlug(event.target.value)
|
||||
}}
|
||||
className={`${inputClass} font-mono`}
|
||||
/>
|
||||
</AdminField>
|
||||
|
||||
<AdminField id="code" label={t('fields.code')} hint={t('hints.code')} error={fieldError('code')}>
|
||||
<input
|
||||
id="code"
|
||||
name="code"
|
||||
value={code}
|
||||
maxLength={4}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
onChange={event => setCode(event.target.value.toUpperCase())}
|
||||
className={`${inputClass} font-mono uppercase`}
|
||||
/>
|
||||
</AdminField>
|
||||
|
||||
<AdminField id="brandId" label={t('fields.brand')} error={fieldError('brandId')}>
|
||||
<select
|
||||
id="brandId"
|
||||
name="brandId"
|
||||
value={brandId}
|
||||
onChange={event => setBrandId(event.target.value)}
|
||||
className={selectClass}
|
||||
>
|
||||
<option value="">{t('fields.brandEmpty')}</option>
|
||||
{brands.map(brand => (
|
||||
<option key={brand.id} value={brand.id}>
|
||||
{brand.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</AdminField>
|
||||
|
||||
<AdminColorField
|
||||
id="color"
|
||||
name="color"
|
||||
label={t('fields.color')}
|
||||
pickerLabel={t('fields.colorPicker')}
|
||||
value={color}
|
||||
onChange={setColor}
|
||||
hint={t('hints.color')}
|
||||
error={fieldError('color')}
|
||||
/>
|
||||
|
||||
<AdminField id="sort" label={t('fields.sort')} hint={t('hints.sort')} error={fieldError('sort')}>
|
||||
<input
|
||||
id="sort"
|
||||
name="sort"
|
||||
type="number"
|
||||
min={0}
|
||||
max={9999}
|
||||
step={1}
|
||||
value={sort}
|
||||
onChange={event => setSort(event.target.value)}
|
||||
className={`${inputClass} font-mono tabular-nums`}
|
||||
/>
|
||||
</AdminField>
|
||||
|
||||
<AdminField
|
||||
id="description"
|
||||
label={t('fields.description')}
|
||||
hint={t('hints.description')}
|
||||
error={fieldError('description')}
|
||||
className="md:col-span-2"
|
||||
>
|
||||
<textarea
|
||||
id="description"
|
||||
name="description"
|
||||
rows={3}
|
||||
value={description}
|
||||
onChange={event => setDescription(event.target.value)}
|
||||
className={textareaClass}
|
||||
/>
|
||||
</AdminField>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 border-t border-rule pt-6">
|
||||
<span className="flex items-center gap-3">
|
||||
<input
|
||||
id="isActive"
|
||||
name="isActive"
|
||||
type="checkbox"
|
||||
checked={active}
|
||||
onChange={event => setActive(event.target.checked)}
|
||||
className={checkboxClass}
|
||||
/>
|
||||
<label htmlFor="isActive" className={labelClass}>
|
||||
{t('fields.active')}
|
||||
</label>
|
||||
</span>
|
||||
<span className={hintClass}>{t('hints.active')}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-6">
|
||||
<AdminSubmit pendingLabel={t('saving')} icon={<TbDeviceFloppy className="size-4" />}>
|
||||
{project ? t('save') : t('create')}
|
||||
</AdminSubmit>
|
||||
<Link href={adminProjectsPath} className={quietLinkClass}>
|
||||
{t('back')}
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { getTranslations } from 'next-intl/server'
|
||||
import { TbLogout2 } from 'react-icons/tb'
|
||||
import { signOut } from '~/lib/auth-actions'
|
||||
|
||||
export async function SignOutButton() {
|
||||
const t = await getTranslations('admin')
|
||||
|
||||
return (
|
||||
<form action={signOut}>
|
||||
<button
|
||||
type="submit"
|
||||
className="inline-flex cursor-pointer items-center gap-2 border border-rule bg-transparent px-3 py-2 font-mono text-micro font-semibold uppercase tracking-label text-ink-2 hover:border-signal hover:text-signal"
|
||||
>
|
||||
<TbLogout2 aria-hidden="true" className="size-4 shrink-0" />
|
||||
{t('signOut')}
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
'use client'
|
||||
|
||||
import { useActionState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { TbShieldCheck, TbShieldOff } from 'react-icons/tb'
|
||||
import { AdminSubmit } from './AdminSubmit'
|
||||
import { errorClass, hintClass } from './styles'
|
||||
import { setUserAdminFlag } from '~/lib/admin-actions'
|
||||
import { emptyAdminFormState } from '~/lib/admin-forms'
|
||||
|
||||
export type UserAdminToggleProps = {
|
||||
userId: string
|
||||
isAdmin: boolean
|
||||
isSelf: boolean
|
||||
}
|
||||
|
||||
export function UserAdminToggle({ userId, isAdmin, isSelf }: UserAdminToggleProps) {
|
||||
const t = useTranslations('admin.users')
|
||||
const messages = useTranslations('admin.messages')
|
||||
const [state, formAction] = useActionState(setUserAdminFlag, emptyAdminFormState)
|
||||
|
||||
return (
|
||||
<form action={formAction} className="flex flex-col gap-2">
|
||||
<input type="hidden" name="userId" value={userId} />
|
||||
<input type="hidden" name="isAdmin" value={isAdmin ? '' : 'on'} />
|
||||
|
||||
<span className="flex flex-wrap items-center gap-4">
|
||||
<AdminSubmit
|
||||
quiet
|
||||
disabled={isSelf}
|
||||
pendingLabel={t('saving')}
|
||||
icon={isAdmin ? <TbShieldOff className="size-4" /> : <TbShieldCheck className="size-4" />}
|
||||
>
|
||||
{isAdmin ? t('revokeAdmin') : t('grantAdmin')}
|
||||
</AdminSubmit>
|
||||
{isSelf ? <span className={hintClass}>{t('selfHint')}</span> : null}
|
||||
</span>
|
||||
|
||||
{state.status === 'error' && state.message ? (
|
||||
<span role="alert" className={errorClass}>
|
||||
{messages(state.message)}
|
||||
</span>
|
||||
) : null}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
'use client'
|
||||
|
||||
import { useActionState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { TbMinus, TbPlus } from 'react-icons/tb'
|
||||
import { AdminSubmit } from './AdminSubmit'
|
||||
import { errorClass } from './styles'
|
||||
import { grantModerator, revokeModerator } from '~/lib/admin-actions'
|
||||
import { emptyAdminFormState } from '~/lib/admin-forms'
|
||||
import { projectStyle, projectSurface } from '~/components/ui/Plate'
|
||||
|
||||
export type UserRoleRowProps = {
|
||||
userId: string
|
||||
projectId: string
|
||||
projectName: string
|
||||
projectCode: string
|
||||
color: string
|
||||
assigned: boolean
|
||||
implicit: boolean
|
||||
}
|
||||
|
||||
export function UserRoleRow({ userId, projectId, projectName, projectCode, color, assigned, implicit }: UserRoleRowProps) {
|
||||
const t = useTranslations('admin.users')
|
||||
const messages = useTranslations('admin.messages')
|
||||
const [state, formAction] = useActionState(assigned ? revokeModerator : grantModerator, emptyAdminFormState)
|
||||
|
||||
return (
|
||||
<li className="flex flex-wrap items-center gap-x-4 gap-y-2 border-b border-rule py-3 last:border-b-0">
|
||||
<span aria-hidden="true" style={projectStyle(color)} className={`size-2.5 shrink-0 ${projectSurface}`} />
|
||||
<span className="font-display text-base font-semibold text-ink">{projectName}</span>
|
||||
<span className="font-mono text-micro font-semibold uppercase tracking-mark text-ink-3">{projectCode}</span>
|
||||
<span aria-hidden="true" className="h-px min-w-6 flex-1 bg-rule" />
|
||||
|
||||
{implicit ? (
|
||||
<span className="font-mono text-micro uppercase tracking-mark text-ink-3">{t('implicit')}</span>
|
||||
) : null}
|
||||
|
||||
<form action={formAction} className="flex flex-col gap-2">
|
||||
<input type="hidden" name="userId" value={userId} />
|
||||
<input type="hidden" name="projectId" value={projectId} />
|
||||
<AdminSubmit
|
||||
quiet
|
||||
pendingLabel={t('saving')}
|
||||
icon={assigned ? <TbMinus className="size-4" /> : <TbPlus className="size-4" />}
|
||||
>
|
||||
{assigned ? t('revokeRole') : t('grantRole')}
|
||||
</AdminSubmit>
|
||||
{state.status === 'error' && state.message ? (
|
||||
<span role="alert" className={errorClass}>
|
||||
{messages(state.message)}
|
||||
</span>
|
||||
) : null}
|
||||
</form>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
export const labelClass = 'font-mono text-micro font-semibold uppercase tracking-label text-ink-3'
|
||||
|
||||
const fieldClass = 'w-full min-w-0 border-b border-rule bg-transparent px-1 py-2 font-body text-base text-ink placeholder:text-ink-3 focus:border-signal'
|
||||
|
||||
export const inputClass = fieldClass
|
||||
|
||||
export const selectClass = `cursor-pointer ${fieldClass}`
|
||||
|
||||
export const textareaClass = `resize-y ${fieldClass}`
|
||||
|
||||
export const checkboxClass = 'size-4 shrink-0 cursor-pointer accent-signal'
|
||||
|
||||
export const fileClass = 'min-w-0 cursor-pointer border border-dashed border-rule bg-transparent px-3 py-3 font-mono text-small text-ink-2 file:mr-4 file:cursor-pointer file:border-0 file:bg-ink file:px-3 file:py-1.5 file:font-mono file:text-micro file:font-semibold file:uppercase file:tracking-label file:text-paper hover:border-signal'
|
||||
|
||||
export const primaryButtonClass = 'inline-flex cursor-pointer items-center gap-2 bg-ink px-4 py-2.5 font-mono text-micro font-semibold uppercase tracking-label text-paper no-underline hover:bg-signal disabled:cursor-progress disabled:opacity-60'
|
||||
|
||||
export const ghostButtonClass = 'inline-flex cursor-pointer items-center gap-2 border border-rule bg-transparent px-3 py-2 font-mono text-micro font-semibold uppercase tracking-label text-ink-2 no-underline hover:border-signal hover:text-signal disabled:cursor-progress disabled:opacity-60'
|
||||
|
||||
export const quietLinkClass = 'font-mono text-micro font-semibold uppercase tracking-label text-ink-2 no-underline hover:text-signal'
|
||||
|
||||
export const headCellClass = 'whitespace-nowrap border-b border-rule pb-2 pr-6 text-left font-mono text-micro font-semibold uppercase tracking-label text-ink-3 last:pr-0'
|
||||
|
||||
export const cellClass = 'border-b border-rule py-3 pr-6 align-middle text-small text-ink-2 last:pr-0'
|
||||
|
||||
export const metaClass = 'font-mono text-micro uppercase tracking-mark text-ink-3 tabular-nums'
|
||||
|
||||
export const errorClass = 'font-mono text-small text-signal'
|
||||
|
||||
export const hintClass = 'text-small text-pretty text-ink-3'
|
||||
@@ -0,0 +1,81 @@
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Scroller } from '~/components/ui/Scroller'
|
||||
import { SectionLabel } from '~/components/ui/SectionLabel'
|
||||
import type { ArchiveMonth } from '~/data/repositories/archive'
|
||||
|
||||
export type ArchiveMonthsProps = {
|
||||
months: ArchiveMonth[]
|
||||
hrefFor: (month: ArchiveMonth) => Route
|
||||
activeYear?: number
|
||||
activeMonth?: number
|
||||
}
|
||||
|
||||
type YearGroup = {
|
||||
year: number
|
||||
months: ArchiveMonth[]
|
||||
}
|
||||
|
||||
function group(months: ArchiveMonth[]): YearGroup[] {
|
||||
const years: YearGroup[] = []
|
||||
|
||||
for (const month of months) {
|
||||
const last = years.at(-1)
|
||||
|
||||
if (last && last.year === month.year) {
|
||||
last.months.push(month)
|
||||
continue
|
||||
}
|
||||
|
||||
years.push({ year: month.year, months: [month] })
|
||||
}
|
||||
|
||||
return years
|
||||
}
|
||||
|
||||
export function ArchiveMonths({ months, hrefFor, activeYear, activeMonth }: ArchiveMonthsProps) {
|
||||
const t = useTranslations('archive')
|
||||
|
||||
if (months.length <= 1) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="flex flex-col gap-5 border-t border-rule py-10">
|
||||
<SectionLabel>{t('title')}</SectionLabel>
|
||||
{group(months).map(year => (
|
||||
<div key={year.year} className="flex flex-col gap-2 md:flex-row md:gap-6">
|
||||
<span className="font-mono text-small font-semibold text-ink-3 tabular-nums md:w-16 md:shrink-0">
|
||||
{year.year}
|
||||
</span>
|
||||
<Scroller className="min-w-0 max-w-full md:flex-1" options={{ overflow: { y: 'hidden' } }}>
|
||||
<div className="flex w-max items-center gap-1 pb-2">
|
||||
{year.months.map(month => {
|
||||
const active = month.year === activeYear && month.month === activeMonth
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={`${month.year}-${month.month}`}
|
||||
href={hrefFor(month)}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
className={`inline-flex items-baseline gap-2 border px-2 py-1 font-mono text-micro font-semibold uppercase tracking-chip no-underline ${
|
||||
active
|
||||
? 'border-ink bg-ink text-paper'
|
||||
: 'border-transparent text-ink-2 hover:border-rule hover:text-ink'
|
||||
}`}
|
||||
>
|
||||
{t(`months.${month.month}`)}
|
||||
<span className={`tabular-nums ${active ? 'text-paper/70' : 'text-ink-3'}`}>
|
||||
{month.postCount}
|
||||
</span>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</Scroller>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { TbArrowNarrowRight } from 'react-icons/tb'
|
||||
|
||||
export type EmptyNoticeProps = {
|
||||
children: ReactNode
|
||||
title?: ReactNode
|
||||
action?: ReactNode
|
||||
}
|
||||
|
||||
export function EmptyNotice({ children, title, action }: EmptyNoticeProps) {
|
||||
return (
|
||||
<div className="my-10 flex flex-col gap-3 border-l-2 border-rule bg-surface px-6 py-8">
|
||||
{title ? (
|
||||
<span className="font-display text-micro font-semibold uppercase tracking-label text-ink-3">
|
||||
{title}
|
||||
</span>
|
||||
) : null}
|
||||
<p className="m-0 max-w-read text-base text-pretty text-ink-2">{children}</p>
|
||||
{action ? (
|
||||
<span className="inline-flex items-center gap-2 font-mono text-small text-ink-2">
|
||||
<TbArrowNarrowRight aria-hidden="true" className="size-4 shrink-0 text-ink-3" />
|
||||
{action}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { EntryRow } from './EntryRow'
|
||||
import { SectionLabel } from '~/components/ui/SectionLabel'
|
||||
import type { PostListItem } from '~/data/repositories/posts'
|
||||
|
||||
export type EntryListProps = {
|
||||
items: PostListItem[]
|
||||
showProject?: boolean
|
||||
label?: ReactNode
|
||||
meta?: ReactNode
|
||||
highlight?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function EntryList({ items, showProject = true, label, meta, highlight, className }: EntryListProps) {
|
||||
if (items.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={`flex flex-col gap-3 ${className ?? ''}`}>
|
||||
{label ? <SectionLabel meta={meta}>{label}</SectionLabel> : null}
|
||||
<div className="flex flex-col">
|
||||
{items.map((item, index) => (
|
||||
<EntryRow key={item.id} item={item} index={index} showCode={showProject} highlight={highlight} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import Link from 'next/link'
|
||||
import type { CSSProperties } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { AuthorMark } from '~/components/ui/AuthorMark'
|
||||
import { Badge } from '~/components/ui/Badge'
|
||||
import { EntryDate } from '~/components/ui/EntryDate'
|
||||
import { Highlight } from '~/components/ui/Highlight'
|
||||
import { entryMark, entryNumber, projectStyle, projectSurface } from '~/components/ui/Plate'
|
||||
import { postPath } from '~/lib/routes'
|
||||
import type { PostListItem } from '~/data/repositories/posts'
|
||||
|
||||
export type EntryRowProps = {
|
||||
item: PostListItem
|
||||
index?: number
|
||||
showCode?: boolean
|
||||
highlight?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
const stagger = 40
|
||||
|
||||
const maxStagger = 12
|
||||
|
||||
export function EntryRow({ item, index = 0, showCode = true, highlight, className }: EntryRowProps) {
|
||||
const t = useTranslations('entry')
|
||||
const delay = `${Math.min(index, maxStagger) * stagger}ms`
|
||||
const style: CSSProperties = { ...projectStyle(item.projectColor), animationDelay: delay }
|
||||
const source = { code: item.projectCode, slug: item.projectSlug }
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={postPath(item.projectSlug, item.slug)}
|
||||
style={style}
|
||||
className={`group flex animate-rise items-stretch gap-4 border-b border-rule no-underline last:border-b-0 motion-reduce:animate-none ${className ?? ''}`}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`w-1 shrink-0 transition-all duration-120 group-hover:w-2 motion-reduce:transition-none ${projectSurface}`}
|
||||
/>
|
||||
<span className="flex min-w-0 flex-1 flex-wrap items-baseline gap-x-4 gap-y-1 py-3 pr-1 transition-transform duration-120 group-hover:translate-x-0.5 motion-reduce:transition-none motion-reduce:group-hover:translate-x-0 lg:grid lg:grid-cols-12 lg:gap-x-5">
|
||||
<span className="shrink-0 font-mono text-small font-medium uppercase tracking-mark text-ink-2 tabular-nums lg:col-span-2">
|
||||
<span aria-hidden="true">
|
||||
{showCode ? entryMark(source, item.number) : entryNumber(item.number)}
|
||||
</span>
|
||||
<span className="sr-only">{t('number', { number: item.number })}</span>
|
||||
</span>
|
||||
|
||||
<Badge type={item.type} className="shrink-0 lg:col-span-1" />
|
||||
|
||||
<span className="min-w-0 flex-1 basis-full font-display text-lead font-semibold leading-snug text-balance text-ink lg:col-span-6 lg:basis-auto xl:col-span-4">
|
||||
<Highlight text={item.title} query={highlight} />
|
||||
</span>
|
||||
|
||||
{item.teaser ? (
|
||||
<span className="hidden min-w-0 truncate text-small text-ink-2 xl:col-span-3 xl:block">
|
||||
{item.teaser}
|
||||
</span>
|
||||
) : (
|
||||
<span aria-hidden="true" className="hidden xl:col-span-3 xl:block" />
|
||||
)}
|
||||
|
||||
<span className="ml-auto flex shrink-0 items-center gap-2 font-mono text-small text-ink-3 lg:col-span-3 lg:ml-0 lg:justify-end xl:col-span-2">
|
||||
{item.publishAt ? <EntryDate date={item.publishAt} /> : null}
|
||||
{item.authorName ? <AuthorMark name={item.authorName} /> : null}
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import Link from 'next/link'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { AuthorMark } from '~/components/ui/AuthorMark'
|
||||
import { Badge } from '~/components/ui/Badge'
|
||||
import { Cover, type CoverMedia } from '~/components/ui/Cover'
|
||||
import { EntryDate } from '~/components/ui/EntryDate'
|
||||
import { Plate, entryMark, projectStyle, projectSurface } from '~/components/ui/Plate'
|
||||
import { Stamp } from '~/components/ui/Stamp'
|
||||
import { postPath, projectPath } from '~/lib/routes'
|
||||
import type { PostListItem } from '~/data/repositories/posts'
|
||||
|
||||
export type FeatureEntryProps = {
|
||||
item: PostListItem
|
||||
cover?: CoverMedia | null
|
||||
newest?: boolean
|
||||
showProject?: boolean
|
||||
as?: 'h1' | 'h2'
|
||||
className?: string
|
||||
}
|
||||
|
||||
const coverSizes = '(min-width: 64rem) 40rem, (min-width: 46rem) 90vw, 100vw'
|
||||
|
||||
export function FeatureEntry({
|
||||
item,
|
||||
cover,
|
||||
newest = false,
|
||||
showProject = true,
|
||||
as: Title = 'h2',
|
||||
className,
|
||||
}: FeatureEntryProps) {
|
||||
const t = useTranslations('entry')
|
||||
const source = { code: item.projectCode, slug: item.projectSlug }
|
||||
|
||||
return (
|
||||
<article
|
||||
style={projectStyle(item.projectColor)}
|
||||
className={`flex animate-rise flex-col overflow-hidden border border-rule bg-surface shadow-lift motion-reduce:animate-none ${className ?? ''}`}
|
||||
>
|
||||
<Cover
|
||||
media={cover}
|
||||
sizes={coverSizes}
|
||||
priority
|
||||
className="aspect-cover w-full border-b border-rule"
|
||||
fallback={<Plate project={source} color={item.projectColor} number={item.number} size="lead" className="w-full" />}
|
||||
/>
|
||||
|
||||
<div className="relative flex flex-1 flex-col gap-4 overflow-hidden p-6 md:p-8">
|
||||
|
||||
<div className="relative flex flex-wrap items-center gap-x-4 gap-y-2 font-mono text-micro text-ink-3">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<span aria-hidden="true" className={`size-2.5 shrink-0 ${projectSurface}`} />
|
||||
<span className="font-semibold uppercase tracking-mark text-ink-2 tabular-nums">
|
||||
{entryMark(source, item.number)}
|
||||
</span>
|
||||
</span>
|
||||
<Badge type={item.type} />
|
||||
{item.publishAt ? <EntryDate date={item.publishAt} long /> : null}
|
||||
{newest ? <Stamp className="ml-auto" /> : null}
|
||||
</div>
|
||||
|
||||
<Title className="relative text-hero font-bold text-balance">
|
||||
<Link href={postPath(item.projectSlug, item.slug)} className="text-ink no-underline">
|
||||
{item.title}
|
||||
</Link>
|
||||
</Title>
|
||||
|
||||
{item.teaser ? (
|
||||
<p className="relative m-0 max-w-read text-lead text-pretty text-ink-2">{item.teaser}</p>
|
||||
) : null}
|
||||
|
||||
<div className="relative mt-auto flex flex-wrap items-center gap-x-3 gap-y-2 pt-2 font-mono text-small text-ink-3">
|
||||
{item.authorName ? (
|
||||
<span className="inline-flex items-center gap-2 text-ink-2">
|
||||
<AuthorMark name={item.authorName} />
|
||||
{item.authorName}
|
||||
</span>
|
||||
) : null}
|
||||
{showProject ? (
|
||||
<>
|
||||
{item.authorName ? <span aria-hidden="true" className="text-rule">/</span> : null}
|
||||
<Link href={projectPath(item.projectSlug)} className="text-ink-2 no-underline hover:text-ink">
|
||||
{item.projectName}
|
||||
</Link>
|
||||
</>
|
||||
) : null}
|
||||
<span className="sr-only">{t('number', { number: item.number })}</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { useFormatter, useTranslations } from 'next-intl'
|
||||
import { SectionLabel } from '~/components/ui/SectionLabel'
|
||||
import { monthOnlyFormat, monthYearFormat } from '~/lib/dates'
|
||||
|
||||
export type FilterBarProps = {
|
||||
year?: number
|
||||
month?: number
|
||||
resetHref: Route
|
||||
}
|
||||
|
||||
export function periodLabel(
|
||||
format: ReturnType<typeof useFormatter>,
|
||||
year: number | undefined,
|
||||
month: number | undefined,
|
||||
): string | undefined {
|
||||
if (month !== undefined) {
|
||||
const reference = new Date(Date.UTC(year ?? new Date().getUTCFullYear(), month - 1, 1))
|
||||
|
||||
return format.dateTime(reference, year !== undefined ? monthYearFormat : monthOnlyFormat)
|
||||
}
|
||||
|
||||
return year !== undefined ? String(year) : undefined
|
||||
}
|
||||
|
||||
export function FilterBar({ year, month, resetHref }: FilterBarProps) {
|
||||
const t = useTranslations('archive')
|
||||
const format = useFormatter()
|
||||
const label = periodLabel(format, year, month)
|
||||
|
||||
if (label === undefined) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pt-8">
|
||||
<SectionLabel
|
||||
as="h2"
|
||||
meta={
|
||||
<Link href={resetHref} className="text-ink-2 no-underline hover:text-ink">
|
||||
{t('all')}
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
{t('filtered', { period: label })}
|
||||
</SectionLabel>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
export type MastheadProps = {
|
||||
title: ReactNode
|
||||
intro?: string | null
|
||||
eyebrow?: ReactNode
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
export function Masthead({ title, intro, eyebrow, children }: MastheadProps) {
|
||||
return (
|
||||
<section className="flex flex-col gap-4 pb-8 pt-12">
|
||||
{eyebrow ? <div>{eyebrow}</div> : null}
|
||||
<h1 className="text-hero font-bold text-balance">{title}</h1>
|
||||
{intro ? <p className="m-0 max-w-read text-lead text-pretty text-ink-2">{intro}</p> : null}
|
||||
{children ? <div className="grid gap-2 pt-2">{children}</div> : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { TbArrowLeft, TbArrowRight } from 'react-icons/tb'
|
||||
import { pageCount } from '~/lib/query'
|
||||
|
||||
export type PaginationProps = {
|
||||
page: number
|
||||
perPage: number
|
||||
total: number
|
||||
hrefFor: (page: number) => Route
|
||||
}
|
||||
|
||||
const shape = 'inline-flex items-center gap-2 font-mono text-micro font-semibold uppercase tracking-mark no-underline'
|
||||
|
||||
export function Pagination({ page, perPage, total, hrefFor }: PaginationProps) {
|
||||
const t = useTranslations('pagination')
|
||||
const pages = pageCount(total, perPage)
|
||||
|
||||
if (total === 0 || pages <= 1) {
|
||||
return null
|
||||
}
|
||||
|
||||
const from = (page - 1) * perPage + 1
|
||||
const to = Math.min(page * perPage, total)
|
||||
const hasPrevious = page > 1
|
||||
const hasNext = page < pages
|
||||
|
||||
return (
|
||||
<nav className="flex flex-wrap items-center gap-4 border-t border-rule py-6">
|
||||
{hasPrevious ? (
|
||||
<Link href={hrefFor(page - 1)} className={`${shape} text-ink-2 hover:text-ink`}>
|
||||
<TbArrowLeft aria-hidden="true" className="size-4" />
|
||||
{t('previous')}
|
||||
</Link>
|
||||
) : (
|
||||
<span aria-disabled="true" className={`${shape} text-ink-3`}>
|
||||
<TbArrowLeft aria-hidden="true" className="size-4" />
|
||||
{t('previous')}
|
||||
</span>
|
||||
)}
|
||||
<span className="mx-auto font-mono text-small text-ink-3 tabular-nums">
|
||||
{t('status', { from, to, total })}
|
||||
</span>
|
||||
{hasNext ? (
|
||||
<Link href={hrefFor(page + 1)} className={`${shape} text-ink-2 hover:text-ink`}>
|
||||
{t('next')}
|
||||
<TbArrowRight aria-hidden="true" className="size-4" />
|
||||
</Link>
|
||||
) : (
|
||||
<span aria-disabled="true" className={`${shape} text-ink-3`}>
|
||||
{t('next')}
|
||||
<TbArrowRight aria-hidden="true" className="size-4" />
|
||||
</span>
|
||||
)}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import Link from 'next/link'
|
||||
import { useFormatter, useLocale, useTranslations } from 'next-intl'
|
||||
import { TbArrowNarrowLeft } from 'react-icons/tb'
|
||||
import { EmptyNotice } from './EmptyNotice'
|
||||
import { BlockRenderer } from '~/components/blocks/BlockRenderer'
|
||||
import { Wrap } from '~/components/layout/Wrap'
|
||||
import { AuthorMark } from '~/components/ui/AuthorMark'
|
||||
import { Badge } from '~/components/ui/Badge'
|
||||
import { Cover } from '~/components/ui/Cover'
|
||||
import { EntryDate } from '~/components/ui/EntryDate'
|
||||
import { FieldRow } from '~/components/ui/FieldRow'
|
||||
import { entryMark, entryNumber, Plate, projectStyle, projectSurface } from '~/components/ui/Plate'
|
||||
import { SectionLabel } from '~/components/ui/SectionLabel'
|
||||
import { Watermark } from '~/components/ui/Watermark'
|
||||
import { postTypeLabel } from '~/domain/post-type'
|
||||
import { toCover } from '~/lib/cover'
|
||||
import { longDateTimeFormat } from '~/lib/dates'
|
||||
import { contentBlocks, readingMinutes } from '~/lib/post-content'
|
||||
import { projectPath } from '~/lib/routes'
|
||||
import type { ArchivePostDetail } from '~/data/repositories/archive'
|
||||
import type { Locale } from '~/i18n/config'
|
||||
|
||||
export type PostDetailProps = {
|
||||
detail: ArchivePostDetail
|
||||
}
|
||||
|
||||
const column = 'mx-auto w-full max-w-read'
|
||||
|
||||
const coverSizes = '(min-width: 63rem) 63rem, 100vw'
|
||||
|
||||
export function PostDetail({ detail }: PostDetailProps) {
|
||||
const t = useTranslations('post')
|
||||
const entry = useTranslations('entry')
|
||||
const locale = useLocale() as Locale
|
||||
const format = useFormatter()
|
||||
const { post, type, project, brand, blocks, media, cover } = detail
|
||||
const content = contentBlocks(blocks, post.teaser, post.coverMediaId)
|
||||
const minutes = readingMinutes(blocks, post.teaser)
|
||||
const caption = cover?.caption ?? undefined
|
||||
|
||||
return (
|
||||
<main id="content">
|
||||
<Wrap>
|
||||
<article style={projectStyle(project.color)} className="flex flex-col pb-16 pt-8">
|
||||
<figure className="m-0 flex flex-col gap-2">
|
||||
<Cover
|
||||
media={cover ? toCover(cover) : null}
|
||||
sizes={coverSizes}
|
||||
priority
|
||||
className="aspect-cover w-full border border-rule bg-surface shadow-lift"
|
||||
fallback={
|
||||
<Plate
|
||||
project={project}
|
||||
color={project.color}
|
||||
number={post.number}
|
||||
size="lead"
|
||||
className="w-full"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{caption ? (
|
||||
<figcaption className="font-mono text-micro text-ink-3">{caption}</figcaption>
|
||||
) : null}
|
||||
</figure>
|
||||
|
||||
<header className="relative mt-8 overflow-hidden">
|
||||
<Watermark number={post.number} className="-right-2 -top-6" />
|
||||
|
||||
<div className={`relative flex flex-wrap items-center gap-x-4 gap-y-3 ${column}`}>
|
||||
<span className="inline-flex items-center gap-2 font-mono text-micro">
|
||||
<span aria-hidden="true" className={`size-2.5 shrink-0 ${projectSurface}`} />
|
||||
<span className="font-semibold uppercase tracking-mark text-ink-2 tabular-nums">
|
||||
{entryMark(project, post.number)}
|
||||
</span>
|
||||
</span>
|
||||
<Link
|
||||
href={projectPath(project.slug)}
|
||||
className="font-mono text-micro uppercase tracking-mark text-ink-2 no-underline hover:text-ink"
|
||||
>
|
||||
{brand.name} / {project.name}
|
||||
</Link>
|
||||
<Badge type={type} className="ml-auto" />
|
||||
</div>
|
||||
|
||||
<h1 className={`relative mt-5 text-hero font-bold text-balance ${column}`}>{post.title}</h1>
|
||||
|
||||
<div
|
||||
className={`relative mt-4 flex flex-wrap items-center gap-x-3 gap-y-2 font-mono text-small text-ink-3 ${column}`}
|
||||
>
|
||||
{post.publishAt ? <EntryDate date={post.publishAt} long /> : null}
|
||||
{post.authorName ? (
|
||||
<>
|
||||
<span aria-hidden="true" className="text-rule">/</span>
|
||||
<span className="inline-flex items-center gap-2 text-ink-2">
|
||||
<AuthorMark name={post.authorName} />
|
||||
{post.authorName}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
<span aria-hidden="true" className="text-rule">/</span>
|
||||
<span className="tabular-nums">{t('readingTime', { minutes })}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{post.teaser ? (
|
||||
<p className={`m-0 mt-6 text-lead text-pretty text-ink-2 ${column}`}>{post.teaser}</p>
|
||||
) : null}
|
||||
|
||||
{content.length > 0 ? (
|
||||
<div className={`mt-10 ${column}`}>
|
||||
<BlockRenderer blocks={content} media={media} />
|
||||
</div>
|
||||
) : blocks.length === 0 && !post.teaser ? (
|
||||
<div className={`mt-10 ${column}`}>
|
||||
<EmptyNotice title={t('emptyTitle')}>{t('empty')}</EmptyNotice>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<footer className={`mt-14 border-t border-rule pt-8 ${column}`}>
|
||||
<SectionLabel as="h2" className="mb-5">{t('details')}</SectionLabel>
|
||||
<div className="grid gap-2">
|
||||
<FieldRow label={t('fields.project')}>{project.name}</FieldRow>
|
||||
{post.publishAt ? (
|
||||
<FieldRow label={t('fields.published')}>
|
||||
<time dateTime={post.publishAt.toISOString()}>
|
||||
{format.dateTime(post.publishAt, longDateTimeFormat)}
|
||||
</time>
|
||||
</FieldRow>
|
||||
) : null}
|
||||
<FieldRow label={t('fields.type')}>{postTypeLabel(type, locale)}</FieldRow>
|
||||
{post.authorName ? <FieldRow label={t('fields.author')}>{post.authorName}</FieldRow> : null}
|
||||
{media.length > 0 ? (
|
||||
<FieldRow label={t('fields.images')}>{entry('images', { count: media.length })}</FieldRow>
|
||||
) : null}
|
||||
<FieldRow label={t('fields.number')}>{entryNumber(post.number)}</FieldRow>
|
||||
</div>
|
||||
<div className="mt-8">
|
||||
<Link
|
||||
href={projectPath(project.slug)}
|
||||
className="inline-flex items-center gap-2 font-mono text-small text-ink-2 no-underline hover:text-ink"
|
||||
>
|
||||
<TbArrowNarrowLeft aria-hidden="true" className="size-4 shrink-0" />
|
||||
{t('back', { project: project.name })}
|
||||
</Link>
|
||||
</div>
|
||||
</footer>
|
||||
</article>
|
||||
</Wrap>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { TbArrowNarrowLeft } from 'react-icons/tb'
|
||||
import { ArchiveMonths } from './ArchiveMonths'
|
||||
import { EmptyNotice } from './EmptyNotice'
|
||||
import { EntryList } from './EntryList'
|
||||
import { FeatureEntry } from './FeatureEntry'
|
||||
import { FilterBar } from './FilterBar'
|
||||
import { Pagination } from './Pagination'
|
||||
import { SideEntries } from './SideEntries'
|
||||
import { TypeFilter } from './TypeFilter'
|
||||
import { ViewHeader } from './ViewHeader'
|
||||
import { Wrap } from '~/components/layout/Wrap'
|
||||
import { Plate } from '~/components/ui/Plate'
|
||||
import { monthPath, overviewPath, projectPath, yearPath } from '~/lib/routes'
|
||||
import { perPage } from '~/lib/query'
|
||||
import type { CoverMedia } from '~/components/ui/Cover'
|
||||
import type { ArchiveListResult, ArchiveMonth, ArchiveStats, PostTypeCount } from '~/data/repositories/archive'
|
||||
import type { ProjectView } from '~/lib/project-view'
|
||||
|
||||
export type ProjectArchiveProps = {
|
||||
view: ProjectView
|
||||
stats: ArchiveStats
|
||||
months: ArchiveMonth[]
|
||||
list: ArchiveListResult
|
||||
typeCounts: PostTypeCount[]
|
||||
page: number
|
||||
covers?: Map<string, CoverMedia>
|
||||
highest?: number
|
||||
year?: number
|
||||
month?: number
|
||||
type?: string
|
||||
}
|
||||
|
||||
const sideCount = 2
|
||||
|
||||
const sideThreshold = 4
|
||||
|
||||
export function ProjectArchive({
|
||||
view,
|
||||
stats,
|
||||
months,
|
||||
list,
|
||||
typeCounts,
|
||||
page,
|
||||
covers,
|
||||
highest,
|
||||
year,
|
||||
month,
|
||||
type,
|
||||
}: ProjectArchiveProps) {
|
||||
const t = useTranslations('overview')
|
||||
const archive = useTranslations('archive')
|
||||
const entry = useTranslations('entry')
|
||||
const { brand, project } = view
|
||||
const period = year !== undefined || month !== undefined
|
||||
const narrowed = period || type !== undefined
|
||||
const beyond = list.total > 0 && list.items.length === 0
|
||||
const emptyTitle = beyond ? t('emptyPageTitle') : type && list.total === 0 ? t('emptyTypeTitle') : period ? t('emptyFilteredTitle') : t('emptyTitle')
|
||||
const emptyText = beyond ? t('emptyPage') : type && list.total === 0 ? t('emptyType') : period ? t('emptyFiltered') : t('emptyProject')
|
||||
|
||||
const pathFor = (options: { page?: number, type?: string }): Route => {
|
||||
if (year !== undefined && month !== undefined) {
|
||||
return monthPath(project.slug, year, month, options)
|
||||
}
|
||||
|
||||
if (year !== undefined) {
|
||||
return yearPath(project.slug, year, options)
|
||||
}
|
||||
|
||||
return projectPath(project.slug, options)
|
||||
}
|
||||
|
||||
const lead = page === 1 && !narrowed ? list.items[0] : undefined
|
||||
const side = lead && list.items.length >= sideThreshold ? list.items.slice(1, 1 + sideCount) : []
|
||||
const rows = lead ? list.items.slice(1 + side.length) : list.items
|
||||
const updated = page === 1 ? list.items[0]?.publishAt ?? null : stats.to ?? null
|
||||
const typeTotal = typeCounts.reduce((sum, count) => sum + count.postCount, 0)
|
||||
|
||||
return (
|
||||
<main id="content">
|
||||
<Wrap>
|
||||
<ViewHeader
|
||||
title={project.name}
|
||||
intro={project.description}
|
||||
updated={updated}
|
||||
total={list.total}
|
||||
eyebrow={
|
||||
<>
|
||||
<span>{brand.name}</span>
|
||||
<Link
|
||||
href={overviewPath()}
|
||||
className="inline-flex items-center gap-2 text-ink-3 no-underline hover:text-ink"
|
||||
>
|
||||
<TbArrowNarrowLeft aria-hidden="true" className="size-4 shrink-0" />
|
||||
{t('allProjects')}
|
||||
</Link>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-x-5 gap-y-3">
|
||||
<Plate
|
||||
project={project}
|
||||
color={project.color}
|
||||
number={highest ?? project.postCount}
|
||||
showNumber={project.postCount > 0}
|
||||
size="row"
|
||||
/>
|
||||
<span className="font-mono text-small text-ink-3 tabular-nums">
|
||||
{archive('entries', { count: project.postCount })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<TypeFilter
|
||||
counts={typeCounts}
|
||||
total={typeTotal}
|
||||
active={type}
|
||||
hrefFor={target => pathFor({ type: target })}
|
||||
/>
|
||||
</ViewHeader>
|
||||
|
||||
<FilterBar year={year} month={month} resetHref={projectPath(project.slug)} />
|
||||
|
||||
{list.items.length === 0 ? (
|
||||
<EmptyNotice
|
||||
title={emptyTitle}
|
||||
action={
|
||||
<Link
|
||||
href={beyond ? pathFor({ type }) : narrowed ? projectPath(project.slug) : overviewPath()}
|
||||
className="font-mono text-small"
|
||||
>
|
||||
{beyond ? archive('firstPage') : narrowed ? archive('all') : t('allProjects')}
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
{emptyText}
|
||||
</EmptyNotice>
|
||||
) : (
|
||||
<div className="flex flex-col gap-10 pb-14 pt-6">
|
||||
{lead ? (
|
||||
<div className="flex flex-col gap-6 lg:flex-row lg:items-start">
|
||||
<FeatureEntry
|
||||
item={lead}
|
||||
cover={covers?.get(lead.id)}
|
||||
newest
|
||||
showProject={false}
|
||||
className="min-w-0 flex-1"
|
||||
/>
|
||||
<SideEntries items={side} covers={covers} className="lg:w-72 lg:shrink-0" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<EntryList
|
||||
items={rows}
|
||||
showProject={false}
|
||||
label={lead ? entry('older') : entry('latest')}
|
||||
/>
|
||||
|
||||
<Pagination
|
||||
page={page}
|
||||
perPage={perPage}
|
||||
total={list.total}
|
||||
hrefFor={target => pathFor({ page: target, type })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ArchiveMonths
|
||||
months={months}
|
||||
activeYear={year}
|
||||
activeMonth={month}
|
||||
hrefFor={item => monthPath(project.slug, item.year, item.month, { type })}
|
||||
/>
|
||||
</Wrap>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import Link from 'next/link'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Badge } from '~/components/ui/Badge'
|
||||
import { Cover, type CoverMedia } from '~/components/ui/Cover'
|
||||
import { EntryDate } from '~/components/ui/EntryDate'
|
||||
import { Plate, entryMark, projectStyle } from '~/components/ui/Plate'
|
||||
import { SectionLabel } from '~/components/ui/SectionLabel'
|
||||
import { postPath } from '~/lib/routes'
|
||||
import type { PostListItem } from '~/data/repositories/posts'
|
||||
|
||||
export type SideEntriesProps = {
|
||||
items: PostListItem[]
|
||||
covers?: Map<string, CoverMedia>
|
||||
className?: string
|
||||
}
|
||||
|
||||
const coverSizes = '(min-width: 64rem) 6rem, 20vw'
|
||||
|
||||
export function SideEntries({ items, covers, className }: SideEntriesProps) {
|
||||
const t = useTranslations('entry')
|
||||
|
||||
if (items.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className={`flex flex-col gap-3 ${className ?? ''}`}>
|
||||
<SectionLabel as="h3">{t('next')}</SectionLabel>
|
||||
<ul className="m-0 flex list-none flex-col gap-3 p-0">
|
||||
{items.map(item => {
|
||||
const source = { code: item.projectCode, slug: item.projectSlug }
|
||||
|
||||
return (
|
||||
<li key={item.id}>
|
||||
<Link
|
||||
href={postPath(item.projectSlug, item.slug)}
|
||||
style={projectStyle(item.projectColor)}
|
||||
className="group flex animate-rise gap-3 border border-rule bg-surface p-2.5 no-underline transition-colors duration-120 hover:border-ink-3 motion-reduce:animate-none motion-reduce:transition-none"
|
||||
>
|
||||
<Cover
|
||||
media={covers?.get(item.id)}
|
||||
sizes={coverSizes}
|
||||
className="aspect-video w-24 shrink-0"
|
||||
fallback={
|
||||
<Plate
|
||||
project={source}
|
||||
color={item.projectColor}
|
||||
number={item.number}
|
||||
size="card"
|
||||
className="w-full"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<span className="flex flex-wrap items-center gap-x-2 gap-y-1 font-mono text-micro text-ink-3">
|
||||
<span className="font-semibold uppercase tracking-mark text-ink-2 tabular-nums">
|
||||
{entryMark(source, item.number)}
|
||||
</span>
|
||||
<Badge type={item.type} />
|
||||
</span>
|
||||
<span className="font-display text-small font-semibold leading-snug text-balance text-ink">
|
||||
{item.title}
|
||||
</span>
|
||||
{item.teaser ? (
|
||||
<span className="line-clamp-2 text-micro leading-snug text-ink-2">{item.teaser}</span>
|
||||
) : null}
|
||||
{item.publishAt ? (
|
||||
<EntryDate date={item.publishAt} className="font-mono text-micro text-ink-3" />
|
||||
) : null}
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { useLocale, useTranslations } from 'next-intl'
|
||||
import { Scroller } from '~/components/ui/Scroller'
|
||||
import { postTypeLabel } from '~/domain/post-type'
|
||||
import type { PostTypeCount } from '~/data/repositories/archive'
|
||||
import type { Locale } from '~/i18n/config'
|
||||
|
||||
export type TypeFilterProps = {
|
||||
counts: PostTypeCount[]
|
||||
total: number
|
||||
active?: string
|
||||
hrefFor: (type?: string) => Route
|
||||
}
|
||||
|
||||
const shape = 'inline-flex items-baseline gap-2 rounded-sm border px-3 py-1.5 font-display text-micro font-semibold uppercase tracking-chip no-underline whitespace-nowrap transition-colors duration-120 motion-reduce:transition-none'
|
||||
|
||||
const tones = {
|
||||
on: 'border-ink bg-ink text-paper',
|
||||
off: 'border-rule bg-surface text-ink-2 hover:border-ink-3 hover:text-ink',
|
||||
}
|
||||
|
||||
export function TypeFilter({ counts, total, active, hrefFor }: TypeFilterProps) {
|
||||
const t = useTranslations('filter')
|
||||
const locale = useLocale() as Locale
|
||||
|
||||
if (counts.length <= 1) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-baseline gap-x-4 gap-y-2">
|
||||
<span className="font-mono text-micro font-semibold uppercase tracking-label text-ink-3">{t('type')}</span>
|
||||
<Scroller className="min-w-0 flex-1" options={{ overflow: { y: 'hidden' } }}>
|
||||
<div className="flex w-max items-center gap-1.5 pb-1">
|
||||
<Link
|
||||
href={hrefFor()}
|
||||
aria-current={active === undefined ? 'page' : undefined}
|
||||
className={`${shape} ${active === undefined ? tones.on : tones.off}`}
|
||||
>
|
||||
{t('allTypes')}
|
||||
<span className={`tabular-nums ${active === undefined ? 'text-paper/70' : 'text-ink-3'}`}>
|
||||
{total}
|
||||
</span>
|
||||
</Link>
|
||||
{counts.map(entry => {
|
||||
const on = entry.type.key === active
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={entry.type.id}
|
||||
href={hrefFor(entry.type.key)}
|
||||
aria-current={on ? 'page' : undefined}
|
||||
className={`${shape} ${on ? tones.on : tones.off}`}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
style={{ background: entry.type.color }}
|
||||
className="size-2 shrink-0 self-center"
|
||||
/>
|
||||
{postTypeLabel(entry.type, locale)}
|
||||
<span className={`tabular-nums ${on ? 'text-paper/70' : 'text-ink-3'}`}>
|
||||
{entry.postCount}
|
||||
</span>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</Scroller>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { useFormatter, useTranslations } from 'next-intl'
|
||||
import { dateFormat } from '~/lib/dates'
|
||||
|
||||
export type ViewHeaderProps = {
|
||||
title: ReactNode
|
||||
intro?: string | null
|
||||
eyebrow?: ReactNode
|
||||
updated?: Date | null
|
||||
total?: number
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
export function ViewHeader({ title, intro, eyebrow, updated, total, children }: ViewHeaderProps) {
|
||||
const t = useTranslations('overview')
|
||||
const archive = useTranslations('archive')
|
||||
const format = useFormatter()
|
||||
|
||||
return (
|
||||
<header className="flex flex-col gap-4 border-b border-rule pb-5 pt-8">
|
||||
{eyebrow ? (
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 font-mono text-micro uppercase tracking-label text-ink-3">
|
||||
{eyebrow}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-x-6 gap-y-2">
|
||||
<h1 className="text-title font-bold text-balance">{title}</h1>
|
||||
<p className="m-0 flex flex-wrap items-baseline gap-x-3 gap-y-1 font-mono text-small text-ink-3 tabular-nums">
|
||||
{updated ? <span>{t('updated', { date: format.dateTime(updated, dateFormat) })}</span> : null}
|
||||
{updated && total !== undefined ? <span aria-hidden="true" className="text-rule">/</span> : null}
|
||||
{total !== undefined ? <span>{archive('entries', { count: total })}</span> : null}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{intro ? <p className="m-0 max-w-read text-pretty text-ink-2">{intro}</p> : null}
|
||||
|
||||
{children}
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { BeforeAfterSlider } from './BeforeAfterSlider'
|
||||
import { MediaFrame } from './MediaFrame'
|
||||
import { readMedia, readString } from './data'
|
||||
import { toCover } from '~/lib/cover'
|
||||
import type { BlockProps } from './types'
|
||||
|
||||
export function BeforeAfterBlock({ data, media }: BlockProps) {
|
||||
const t = useTranslations('block')
|
||||
const before = readMedia(media, readString(data, 'beforeMediaId', 'before_media_id', 'before'))
|
||||
const after = readMedia(media, readString(data, 'afterMediaId', 'after_media_id', 'after'))
|
||||
|
||||
if (before === undefined && after === undefined) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (before === undefined || after === undefined) {
|
||||
const single = before ?? after
|
||||
|
||||
return single ? <MediaFrame item={single} label={before ? t('before') : t('after')} /> : null
|
||||
}
|
||||
|
||||
return (
|
||||
<BeforeAfterSlider
|
||||
before={toCover(before)}
|
||||
after={toCover(after)}
|
||||
beforeAlt={before.alt ?? ''}
|
||||
afterAlt={after.alt ?? ''}
|
||||
caption={after.caption ?? before.caption}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
'use client'
|
||||
|
||||
import { useId, useState, type CSSProperties } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Cover, type CoverMedia } from '~/components/ui/Cover'
|
||||
|
||||
export type BeforeAfterSliderProps = {
|
||||
before: CoverMedia
|
||||
after: CoverMedia
|
||||
beforeAlt: string
|
||||
afterAlt: string
|
||||
caption?: string | null
|
||||
}
|
||||
|
||||
const sizes = '(min-width: 63rem) 38rem, 92vw'
|
||||
|
||||
const start = 50
|
||||
|
||||
export function BeforeAfterSlider({ before, after, beforeAlt, afterAlt, caption }: BeforeAfterSliderProps) {
|
||||
const t = useTranslations('block')
|
||||
const [position, setPosition] = useState(start)
|
||||
const inputId = useId()
|
||||
const clip: CSSProperties = { clipPath: `inset(0 ${100 - position}% 0 0)` }
|
||||
const handle: CSSProperties = { left: `${position}%` }
|
||||
|
||||
return (
|
||||
<figure className="m-0 flex flex-col gap-3">
|
||||
<div className="relative aspect-shot w-full overflow-hidden border border-rule bg-surface">
|
||||
<Cover
|
||||
media={after}
|
||||
sizes={sizes}
|
||||
alt={afterAlt}
|
||||
className="absolute inset-0 size-full"
|
||||
fallback={<span className="flex size-full bg-surface" />}
|
||||
/>
|
||||
<span style={clip} className="absolute inset-0 block">
|
||||
<Cover
|
||||
media={before}
|
||||
sizes={sizes}
|
||||
alt={beforeAlt}
|
||||
className="absolute inset-0 size-full"
|
||||
fallback={<span className="flex size-full bg-surface" />}
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
style={handle}
|
||||
className="pointer-events-none absolute inset-y-0 w-0.5 -translate-x-1/2 bg-paper"
|
||||
/>
|
||||
<span className="pointer-events-none absolute left-2 top-2 bg-ink/70 px-2 py-1 font-mono text-micro uppercase tracking-chip text-paper">
|
||||
{t('before')}
|
||||
</span>
|
||||
<span className="pointer-events-none absolute right-2 top-2 bg-ink/70 px-2 py-1 font-mono text-micro uppercase tracking-chip text-paper">
|
||||
{t('after')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<label htmlFor={inputId} className="sr-only">
|
||||
{t('compare')}
|
||||
</label>
|
||||
<input
|
||||
id={inputId}
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
value={position}
|
||||
onChange={event => setPosition(Number(event.target.value))}
|
||||
aria-valuetext={t('comparePosition', { percent: position })}
|
||||
className="w-full cursor-pointer accent-signal"
|
||||
/>
|
||||
|
||||
{caption ? <figcaption className="font-mono text-micro text-ink-3">{caption}</figcaption> : null}
|
||||
</figure>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { findBlockComponent } from './registry'
|
||||
import type { Block, Media } from '~/data/schema'
|
||||
|
||||
export type BlockRendererProps = {
|
||||
blocks: Block[]
|
||||
media: Media[]
|
||||
}
|
||||
|
||||
export function BlockRenderer({ blocks, media }: BlockRendererProps) {
|
||||
const index = new Map(media.map(item => [item.id, item]))
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
{blocks.map(block => {
|
||||
const Component = findBlockComponent(block.type)
|
||||
|
||||
if (!Component) {
|
||||
console.warn(`logbuch: unbekannter Blocktyp ${block.type} in Block ${block.id}`)
|
||||
return null
|
||||
}
|
||||
|
||||
return <Component key={block.id} data={block.data} media={index} />
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { TbAlertTriangle, TbInfoCircle } from 'react-icons/tb'
|
||||
import { readString } from './data'
|
||||
import type { BlockProps } from './types'
|
||||
|
||||
export function CalloutBlock({ data }: BlockProps) {
|
||||
const text = readString(data, 'text', 'body')
|
||||
|
||||
if (text === undefined) {
|
||||
return null
|
||||
}
|
||||
|
||||
const tone = readString(data, 'tone', 'variant')
|
||||
const warning = tone === 'warning' || tone === 'breaking' || tone === 'danger'
|
||||
const Icon = warning ? TbAlertTriangle : TbInfoCircle
|
||||
const title = readString(data, 'title', 'label')
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex items-start gap-3 border border-rule border-l-2 bg-surface px-4 py-3 ${
|
||||
warning ? 'border-l-signal' : 'border-l-link'
|
||||
}`}
|
||||
>
|
||||
<Icon aria-hidden="true" className={`mt-1 size-4 shrink-0 ${warning ? 'text-signal' : 'text-link'}`} />
|
||||
<span className="flex flex-col gap-1">
|
||||
{title ? <span className="font-display text-small text-ink">{title}</span> : null}
|
||||
<span className="text-ink-2">{text}</span>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Scroller } from '~/components/ui/Scroller'
|
||||
import { readString } from './data'
|
||||
import type { BlockProps } from './types'
|
||||
|
||||
export function CodeBlock({ data }: BlockProps) {
|
||||
const code = readString(data, 'code', 'text')
|
||||
const language = readString(data, 'language', 'lang')
|
||||
|
||||
if (code === undefined) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden border border-rule bg-surface">
|
||||
{language ? (
|
||||
<div className="border-b border-rule px-4 py-2 font-mono text-micro uppercase tracking-chip text-ink-3">
|
||||
{language}
|
||||
</div>
|
||||
) : null}
|
||||
<Scroller className="max-h-96">
|
||||
<pre className="m-0 w-max min-w-full px-4 py-3 font-mono text-small leading-relaxed text-ink">
|
||||
<code>{code}</code>
|
||||
</pre>
|
||||
</Scroller>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { GalleryFrames, type GalleryPicture } from './GalleryFrames'
|
||||
import { readMediaList, readStrings } from './data'
|
||||
import { toCover } from '~/lib/cover'
|
||||
import type { BlockProps } from './types'
|
||||
|
||||
export function GalleryBlock({ data, media }: BlockProps) {
|
||||
const items = readMediaList(media, readStrings(data, 'mediaIds', 'media_ids', 'items'))
|
||||
|
||||
if (items.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const pictures: GalleryPicture[] = items.map(item => ({
|
||||
id: item.id,
|
||||
media: toCover(item),
|
||||
alt: item.alt ?? '',
|
||||
caption: item.caption,
|
||||
filename: item.originalFilename,
|
||||
}))
|
||||
|
||||
return <GalleryFrames items={pictures} />
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState, type KeyboardEvent } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { TbChevronLeft, TbChevronRight, TbX } from 'react-icons/tb'
|
||||
import { Cover, type CoverMedia } from '~/components/ui/Cover'
|
||||
|
||||
export type GalleryPicture = {
|
||||
id: string
|
||||
media: CoverMedia
|
||||
alt: string
|
||||
caption: string | null
|
||||
filename: string
|
||||
}
|
||||
|
||||
export type GalleryFramesProps = {
|
||||
items: GalleryPicture[]
|
||||
}
|
||||
|
||||
const thumbSizes = '(min-width: 63rem) 12rem, 45vw'
|
||||
|
||||
const fullSizes = '100vw'
|
||||
|
||||
const focusable = 'button:not([disabled])'
|
||||
|
||||
export function GalleryFrames({ items }: GalleryFramesProps) {
|
||||
const t = useTranslations('block')
|
||||
const [index, setIndex] = useState<number | undefined>(undefined)
|
||||
const dialogRef = useRef<HTMLDivElement>(null)
|
||||
const closeRef = useRef<HTMLButtonElement>(null)
|
||||
const openerRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
const step = useCallback((delta: number) => {
|
||||
setIndex(current => {
|
||||
if (current === undefined || items.length === 0) {
|
||||
return current
|
||||
}
|
||||
|
||||
return (current + delta + items.length) % items.length
|
||||
})
|
||||
}, [items.length])
|
||||
|
||||
const close = useCallback(() => {
|
||||
setIndex(undefined)
|
||||
openerRef.current?.focus()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (index === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
closeRef.current?.focus()
|
||||
|
||||
const onKey = (event: globalThis.KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
close()
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowRight') {
|
||||
event.preventDefault()
|
||||
step(1)
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowLeft') {
|
||||
event.preventDefault()
|
||||
step(-1)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', onKey)
|
||||
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [close, index, step])
|
||||
|
||||
const keepFocus = useCallback((event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key !== 'Tab' || !dialogRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
const nodes = [...dialogRef.current.querySelectorAll<HTMLElement>(focusable)]
|
||||
const first = nodes[0]
|
||||
const last = nodes.at(-1)
|
||||
|
||||
if (!first || !last) {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault()
|
||||
last.focus()
|
||||
return
|
||||
}
|
||||
|
||||
if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault()
|
||||
first.focus()
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (items.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const current = index === undefined ? undefined : items[index]
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<ul className="m-0 grid list-none grid-cols-2 gap-2 p-0 md:grid-cols-3">
|
||||
{items.map((item, position) => (
|
||||
<li key={item.id} className="flex">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${t('open')} ${t('position', { index: position + 1, total: items.length })}`}
|
||||
onClick={event => {
|
||||
openerRef.current = event.currentTarget
|
||||
setIndex(position)
|
||||
}}
|
||||
className="group flex w-full cursor-pointer flex-col gap-1 border-0 bg-transparent p-0 text-left"
|
||||
>
|
||||
<Cover
|
||||
media={item.media}
|
||||
sizes={thumbSizes}
|
||||
alt={item.alt}
|
||||
className="aspect-shot w-full border border-rule bg-surface transition-colors duration-120 group-hover:border-ink-3 motion-reduce:transition-none"
|
||||
fallback={
|
||||
<span className="flex flex-1 items-center justify-center border border-rule bg-surface px-3 py-5 font-mono text-micro text-ink-3">
|
||||
{item.filename}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
{item.caption ? (
|
||||
<span className="line-clamp-1 font-mono text-micro text-ink-3">{item.caption}</span>
|
||||
) : null}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{current ? (
|
||||
<div
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t('gallery')}
|
||||
onKeyDown={keepFocus}
|
||||
className="fixed inset-0 z-40 flex flex-col gap-3 bg-ink/90 p-4 md:p-8"
|
||||
>
|
||||
<div className="flex items-center gap-4 font-mono text-micro uppercase tracking-chip text-paper">
|
||||
<span className="tabular-nums">
|
||||
{t('position', { index: (index ?? 0) + 1, total: items.length })}
|
||||
</span>
|
||||
<button
|
||||
ref={closeRef}
|
||||
type="button"
|
||||
onClick={close}
|
||||
aria-label={t('close')}
|
||||
className="ml-auto inline-flex cursor-pointer items-center justify-center border border-paper/40 p-2 text-paper hover:border-paper"
|
||||
>
|
||||
<TbX aria-hidden="true" className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1 items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => step(-1)}
|
||||
aria-label={t('previous')}
|
||||
className="inline-flex shrink-0 cursor-pointer items-center justify-center border border-paper/40 p-2 text-paper hover:border-paper"
|
||||
>
|
||||
<TbChevronLeft aria-hidden="true" className="size-5" />
|
||||
</button>
|
||||
<Cover
|
||||
media={current.media}
|
||||
sizes={fullSizes}
|
||||
alt={current.alt}
|
||||
fit="contain"
|
||||
className="h-full min-w-0 flex-1"
|
||||
fallback={
|
||||
<span className="flex flex-1 items-center justify-center font-mono text-micro text-paper">
|
||||
{current.filename}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => step(1)}
|
||||
aria-label={t('next')}
|
||||
className="inline-flex shrink-0 cursor-pointer items-center justify-center border border-paper/40 p-2 text-paper hover:border-paper"
|
||||
>
|
||||
<TbChevronRight aria-hidden="true" className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{current.caption ? (
|
||||
<p className="m-0 text-center font-mono text-micro text-paper/80">{current.caption}</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { MediaFrame } from './MediaFrame'
|
||||
import { readMedia, readString } from './data'
|
||||
import type { BlockProps } from './types'
|
||||
|
||||
export function ImageBlock({ data, media }: BlockProps) {
|
||||
const item = readMedia(media, readString(data, 'mediaId', 'media_id', 'id'))
|
||||
|
||||
if (item === undefined) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <MediaFrame item={item} ratio="wide" />
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { TbArrowUpRight } from 'react-icons/tb'
|
||||
import { readString } from './data'
|
||||
import type { BlockProps } from './types'
|
||||
|
||||
export function LinkBlock({ data }: BlockProps) {
|
||||
const url = readString(data, 'url', 'href')
|
||||
|
||||
if (url === undefined) {
|
||||
return null
|
||||
}
|
||||
|
||||
const label = readString(data, 'label', 'title') ?? url
|
||||
const description = readString(data, 'description', 'teaser')
|
||||
|
||||
return (
|
||||
<a
|
||||
href={url}
|
||||
rel="noreferrer noopener"
|
||||
className="flex items-start gap-3 border border-rule bg-surface px-4 py-3 no-underline hover:border-ink-3"
|
||||
>
|
||||
<TbArrowUpRight aria-hidden="true" className="mt-1 size-4 shrink-0 text-ink-3" />
|
||||
<span className="flex flex-col gap-1">
|
||||
<span className="font-display text-small text-ink">{label}</span>
|
||||
{description ? <span className="text-small text-ink-2">{description}</span> : null}
|
||||
<span className="font-mono text-micro text-ink-3">{url}</span>
|
||||
</span>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Cover } from '~/components/ui/Cover'
|
||||
import { toCover } from '~/lib/cover'
|
||||
import type { Media } from '~/data/schema'
|
||||
|
||||
export type MediaFrameProps = {
|
||||
item: Media
|
||||
ratio?: 'wide' | 'shot'
|
||||
label?: string
|
||||
sizes?: string
|
||||
priority?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
const ratios = {
|
||||
wide: 'aspect-cover',
|
||||
shot: 'aspect-shot',
|
||||
}
|
||||
|
||||
const defaultSizes = '(min-width: 63rem) 38rem, 92vw'
|
||||
|
||||
export function MediaFrame({ item, ratio = 'shot', label, sizes, priority = false, className }: MediaFrameProps) {
|
||||
const caption = item.caption ?? undefined
|
||||
|
||||
return (
|
||||
<figure className={`m-0 flex min-w-0 flex-1 flex-col gap-2 ${className ?? ''}`}>
|
||||
<Cover
|
||||
media={toCover(item)}
|
||||
sizes={sizes ?? defaultSizes}
|
||||
alt={item.alt ?? ''}
|
||||
priority={priority}
|
||||
className={`w-full border border-rule bg-surface ${ratios[ratio]}`}
|
||||
fallback={
|
||||
<span className="flex flex-1 items-center justify-center border border-rule bg-surface px-4 py-6 font-mono text-micro text-ink-3">
|
||||
{item.originalFilename}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
{label || caption ? (
|
||||
<figcaption className="font-mono text-micro text-ink-3">
|
||||
{label ? <span className="uppercase tracking-chip text-ink-2">{label}</span> : null}
|
||||
{label && caption ? <span aria-hidden="true"> / </span> : null}
|
||||
{caption}
|
||||
</figcaption>
|
||||
) : null}
|
||||
</figure>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { readString } from './data'
|
||||
import type { BlockProps } from './types'
|
||||
|
||||
export function QuoteBlock({ data }: BlockProps) {
|
||||
const text = readString(data, 'text', 'quote')
|
||||
const source = readString(data, 'source', 'author', 'cite')
|
||||
|
||||
if (text === undefined) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<blockquote className="m-0 border-l-2 border-signal pl-5">
|
||||
<p className="m-0 text-lead text-ink">{text}</p>
|
||||
{source ? <cite className="mt-2 block font-mono text-micro not-italic text-ink-3">{source}</cite> : null}
|
||||
</blockquote>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { readString } from './data'
|
||||
import type { BlockProps } from './types'
|
||||
|
||||
export function TextBlock({ data }: BlockProps) {
|
||||
const text = readString(data, 'text', 'body', 'content')
|
||||
|
||||
if (text === undefined) {
|
||||
return null
|
||||
}
|
||||
|
||||
const paragraphs = text.split(/\n{2,}/).map(part => part.trim()).filter(part => part !== '')
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{paragraphs.map((paragraph, index) => (
|
||||
<p key={index} className="m-0 text-base text-ink">
|
||||
{paragraph}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { TbPlayerPlay } from 'react-icons/tb'
|
||||
import { MediaFrame } from './MediaFrame'
|
||||
import { readMedia, readString } from './data'
|
||||
import { mediaSrc } from '~/lib/media-url'
|
||||
import type { BlockProps } from './types'
|
||||
|
||||
export function VideoBlock({ data, media }: BlockProps) {
|
||||
const t = useTranslations('block')
|
||||
const item = readMedia(media, readString(data, 'mediaId', 'media_id'))
|
||||
const url = readString(data, 'url', 'href')
|
||||
|
||||
if (item && item.kind === 'video') {
|
||||
return (
|
||||
<figure className="m-0 flex flex-col gap-2">
|
||||
<video
|
||||
controls
|
||||
preload="metadata"
|
||||
src={mediaSrc(item)}
|
||||
aria-label={item.alt ?? t('video')}
|
||||
className="aspect-shot w-full border border-rule bg-surface"
|
||||
/>
|
||||
{item.caption ? (
|
||||
<figcaption className="font-mono text-micro text-ink-3">{item.caption}</figcaption>
|
||||
) : null}
|
||||
</figure>
|
||||
)
|
||||
}
|
||||
|
||||
if (item) {
|
||||
return <MediaFrame item={item} ratio="wide" label={t('video')} />
|
||||
}
|
||||
|
||||
if (url === undefined) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
href={url}
|
||||
rel="noreferrer noopener"
|
||||
target="_blank"
|
||||
className="inline-flex items-center gap-3 self-start border border-rule bg-surface px-4 py-3 font-display text-small no-underline hover:border-ink-3"
|
||||
>
|
||||
<TbPlayerPlay aria-hidden="true" className="size-5 text-signal" />
|
||||
<span>{readString(data, 'title', 'label') ?? t('video')}</span>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { Media } from '~/data/schema'
|
||||
|
||||
export type BlockData = Record<string, unknown>
|
||||
|
||||
export function readString(data: BlockData, ...keys: string[]): string | undefined {
|
||||
for (const key of keys) {
|
||||
const value = data[key]
|
||||
|
||||
if (typeof value === 'string' && value.trim() !== '') {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function readStrings(data: BlockData, ...keys: string[]): string[] {
|
||||
for (const key of keys) {
|
||||
const value = data[key]
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.filter((entry): entry is string => typeof entry === 'string' && entry.trim() !== '')
|
||||
}
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
export function readMedia(media: Map<string, Media>, id: string | undefined): Media | undefined {
|
||||
return id === undefined ? undefined : media.get(id)
|
||||
}
|
||||
|
||||
export function readMediaList(media: Map<string, Media>, ids: string[]): Media[] {
|
||||
return ids.map(id => media.get(id)).filter((entry): entry is Media => entry !== undefined)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user