Fix container build for sharp, slug lookup and select fields

- build the runtime image so libvips lands next to the sharp binary
- GET /api/v1/posts/:slug finds drafts by slug for write clients
- media upload documented with project slug instead of a project id
- own select control instead of the browser default
- quieter sidebar footer, project name leads to its entries
This commit is contained in:
Matthias G
2026-08-01 11:48:48 +02:00
parent 58c4cb12a8
commit 7a594eeacf
19 changed files with 119 additions and 72 deletions
+5 -1
View File
@@ -1,3 +1,4 @@
# syntax=docker/dockerfile:1
FROM node:24-alpine AS base
RUN corepack enable && corepack prepare pnpm@latest --activate
@@ -23,7 +24,10 @@ ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=sharp --chown=nextjs:nodejs /out ./node_modules/.pnpm/
RUN --mount=from=sharp,source=/out,target=/tmp/img \
rm -rf node_modules/.pnpm/@img+* \
&& cp -R /tmp/img/. node_modules/.pnpm/ \
&& chown -R nextjs:nodejs node_modules/.pnpm
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/drizzle ./drizzle
COPY --from=builder --chown=nextjs:nodejs /app/docs/style-guide.md ./docs/style-guide.md
+3 -1
View File
@@ -114,11 +114,13 @@ Lädt ein Bild hoch, erzeugt WebP-Varianten und legt einen Datensatz an. Braucht
```bash
curl -s -X POST -H "Authorization: Bearer $TOKEN" \
-F "file=@screenshot.png" \
-F "projectId=<projekt-id>" \
-F "project=trakk" \
-F "alt=Spaltenmenü mit den neuen Einträgen" \
http://localhost:4700/api/v1/media
```
`project` ist der Slug des Projekts. Bei einem projektgebundenen Token darf das Feld wegbleiben.
Grenzen: nur Bildformate, höchstens 15 MB.
### GET /api/v1/media/file/...
+1 -1
View File
@@ -262,7 +262,7 @@ export default async function AdminApiPage() {
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'}
example={'curl -s -X POST -H "Authorization: Bearer $TOKEN" \\\n -F "file=@screenshot.png" \\\n -F "project=trakk" \\\n -F "alt=Spaltenmenü mit den neuen Einträgen" \\\n http://localhost:4700/api/v1/media'}
/>
</section>
+3 -2
View File
@@ -16,13 +16,14 @@ import {
adminClientsPath,
adminNewEntryPath,
adminNewProjectPath,
adminProjectEntriesPath,
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'
import { adminMediaPath } from '~/lib/auth-routes'
export default async function AdminPage() {
const viewer = await requireAdminArea()
@@ -116,7 +117,7 @@ export default async function AdminPage() {
className={`size-2.5 shrink-0 ${projectSurface}`}
/>
<Link
href={adminMediaProjectPath(project.slug)}
href={adminProjectEntriesPath(project.slug)}
className="flex items-center gap-2 font-display text-base font-semibold text-ink no-underline hover:text-signal"
>
{project.name}
+11 -3
View File
@@ -1,5 +1,5 @@
import { z } from 'zod'
import { findPost } from '~/data/repositories/posts'
import { findPost, findPostIdBySlug } from '~/data/repositories/posts'
import { resolveClient } from '~/lib/api-auth'
import { apiDeleteEntry, apiReadEntry, apiUpdateEntry } from '~/lib/api-entries'
import { entryProblem } from '~/lib/api-problem'
@@ -33,11 +33,19 @@ export async function GET(request: Request, context: { params: Promise<{ slug: s
const post = await findPost(slug, client.scope, client.projectId ?? undefined)
if (!post) {
if (post) {
return Response.json(post)
}
const id = await findPostIdBySlug(slug)
if (!id) {
return problem(404, 'post_not_found')
}
return Response.json(post)
const result = await apiReadEntry(client, id)
return result.ok ? Response.json(result.value) : entryProblem(result.error)
}
export async function PATCH(request: Request, context: { params: Promise<{ slug: string }> }) {
+8 -10
View File
@@ -6,7 +6,8 @@ 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 { ghostButtonClass, hintClass, inputClass, labelClass } from './styles'
import { Select } from './Select'
import { createApiClient } from '~/lib/admin-actions'
import { emptyAdminFormState } from '~/lib/admin-forms'
@@ -84,12 +85,11 @@ export function ClientForm({ projects }: ClientFormProps) {
</AdminField>
<AdminField id="projectId" label={t('fields.project')} hint={t('hints.project')} error={fieldError('projectId')}>
<select
<Select
id="projectId"
name="projectId"
value={projectId}
onChange={event => setProjectId(event.target.value)}
className={selectClass}
>
<option value="">{t('allProjects')}</option>
{projects.map(project => (
@@ -97,39 +97,37 @@ export function ClientForm({ projects }: ClientFormProps) {
{project.name}
</option>
))}
</select>
</Select>
</AdminField>
<AdminField id="mode" label={t('fields.mode')} hint={t('hints.mode')} error={fieldError('mode')}>
<select
<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>
</Select>
</AdminField>
<AdminField id="scope" label={t('fields.scope')} hint={t('hints.scope')} error={fieldError('scope')}>
<select
<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>
</Select>
</AdminField>
</div>
+4 -4
View File
@@ -5,7 +5,8 @@ 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 { inputClass, textareaClass } from './styles'
import { Select } from './Select'
import type { BlockType, BlockValues } from '~/domain/blocks'
import type { EntryMedia } from '~/lib/entry-types'
@@ -323,15 +324,14 @@ export function EntryBlockCard({
/>
</AdminField>
<AdminField id={`${id}-tone`} label={t('blockFields.tone')}>
<select
<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>
</Select>
</AdminField>
</div>
<AdminField id={`${id}-callouttext`} label={t('blockFields.text')}>
+7 -10
View File
@@ -30,9 +30,9 @@ import {
metaClass,
primaryButtonClass,
quietLinkClass,
selectClass,
textareaClass,
} from './styles'
import { Select } from './Select'
import { blockTypes, emptyBlockData, isBlockEmpty, type BlockType, type BlockValues } from '~/domain/blocks'
import { checkPublish } from '~/domain/publish-checks'
import { defaultPostTypeColor } from '~/domain/post-type'
@@ -678,48 +678,45 @@ export function EntryEditor({ projects, types, media: initialMedia, accept, defa
label={t('fields.project')}
hint={entry ? t('hints.projectMove') : undefined}
>
<select
<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>
</Select>
</AdminField>
<AdminField id="entry-type" label={t('fields.type')}>
<select
<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>
</Select>
</AdminField>
<AdminField id="entry-audience" label={t('fields.audience')} hint={t('hints.audience')}>
<select
<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>
</Select>
</AdminField>
</div>
+8 -8
View File
@@ -5,7 +5,8 @@ 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 { ghostButtonClass, inputClass, quietLinkClass } from './styles'
import { Select } from './Select'
import { postStatuses } from '~/domain/post-status'
import { adminEntriesPath } from '~/lib/admin-routes'
import type { EntryTypeOption } from '~/lib/entry-types'
@@ -37,12 +38,11 @@ export function EntryFilters({ projects, types, project, status, type, search, f
>
<div className="grid gap-5 md:grid-cols-4">
<AdminField id="filter-project" label={t('project')}>
<select
<Select
id="filter-project"
name="project"
defaultValue={project}
onChange={submit}
className={selectClass}
>
<option value="">{t('allProjects')}</option>
{projects.map(item => (
@@ -50,29 +50,29 @@ export function EntryFilters({ projects, types, project, status, type, search, f
{item.name}
</option>
))}
</select>
</Select>
</AdminField>
<AdminField id="filter-status" label={t('status')}>
<select id="filter-status" name="status" defaultValue={status} onChange={submit} className={selectClass}>
<Select id="filter-status" name="status" defaultValue={status} onChange={submit}>
<option value="">{t('allStatus')}</option>
{postStatuses.map(item => (
<option key={item} value={item}>
{states(item)}
</option>
))}
</select>
</Select>
</AdminField>
<AdminField id="filter-type" label={t('type')}>
<select id="filter-type" name="type" defaultValue={type} onChange={submit} className={selectClass}>
<Select id="filter-type" name="type" defaultValue={type} onChange={submit}>
<option value="">{t('allTypes')}</option>
{types.map(item => (
<option key={item.id} value={item.key}>
{item.label}
</option>
))}
</select>
</Select>
</AdminField>
<AdminField id="filter-search" label={t('search')}>
+4 -4
View File
@@ -8,7 +8,8 @@ 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 { checkboxClass, hintClass, inputClass, labelClass, quietLinkClass, textareaClass } from './styles'
import { Select } from './Select'
import { saveProject } from '~/lib/admin-actions'
import { emptyAdminFormState } from '~/lib/admin-forms'
import { adminProjectsPath } from '~/lib/admin-routes'
@@ -107,12 +108,11 @@ export function ProjectForm({ brands, project }: ProjectFormProps) {
</AdminField>
<AdminField id="brandId" label={t('fields.brand')} error={fieldError('brandId')}>
<select
<Select
id="brandId"
name="brandId"
value={brandId}
onChange={event => setBrandId(event.target.value)}
className={selectClass}
>
<option value="">{t('fields.brandEmpty')}</option>
{brands.map(brand => (
@@ -120,7 +120,7 @@ export function ProjectForm({ brands, project }: ProjectFormProps) {
{brand.name}
</option>
))}
</select>
</Select>
</AdminField>
<AdminColorField
+16
View File
@@ -0,0 +1,16 @@
import type { SelectHTMLAttributes } from 'react'
import { TbChevronDown } from 'react-icons/tb'
import { selectClass } from './styles'
export type SelectProps = SelectHTMLAttributes<HTMLSelectElement>
export function Select({ className, children, ...rest }: SelectProps) {
return (
<span className="relative flex min-w-0 items-center">
<select {...rest} className={`${selectClass} ${className ?? ''}`}>
{children}
</select>
<TbChevronDown aria-hidden="true" className="pointer-events-none absolute right-1 size-4 shrink-0 text-ink-3" />
</span>
)
}
+3 -5
View File
@@ -1,16 +1,14 @@
import { getTranslations } from 'next-intl/server'
import { TbLogout2 } from 'react-icons/tb'
import { footerLinkClass } from './styles'
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"
>
<form action={signOut} className="w-full">
<button type="submit" className={`${footerLinkClass} hover:text-signal`}>
<TbLogout2 aria-hidden="true" className="size-4 shrink-0" />
{t('signOut')}
</button>
+3 -1
View File
@@ -4,7 +4,7 @@ const fieldClass = 'w-full min-w-0 border-b border-rule bg-transparent px-1 py-2
export const inputClass = fieldClass
export const selectClass = `cursor-pointer ${fieldClass}`
export const selectClass = `cursor-pointer appearance-none pr-7 ${fieldClass}`
export const textareaClass = `resize-y ${fieldClass}`
@@ -18,6 +18,8 @@ export const ghostButtonClass = 'inline-flex cursor-pointer items-center gap-2 b
export const quietLinkClass = 'font-mono text-micro font-semibold uppercase tracking-label text-ink-2 no-underline hover:text-signal'
export const footerLinkClass = 'flex w-full cursor-pointer items-center gap-2 whitespace-nowrap bg-transparent px-2.5 py-2 font-mono text-micro font-semibold uppercase tracking-label text-ink-2 no-underline hover:bg-paper hover:text-ink'
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'
+11 -6
View File
@@ -1,3 +1,4 @@
import Link from 'next/link'
import { getTranslations } from 'next-intl/server'
import { TbAddressBook, TbArrowNarrowLeft, TbCode, TbKey, TbLayoutGrid, TbNotebook, TbPhoto, TbStack2, TbTags, TbUsers } from 'react-icons/tb'
import { NavLink } from './NavLink'
@@ -5,6 +6,7 @@ import { Wordmark } from './Wordmark'
import { Scroller } from '~/components/ui/Scroller'
import { ThemeToggle } from '~/components/ui/ThemeToggle'
import { SignOutButton } from '~/components/admin/SignOutButton'
import { footerLinkClass } from '~/components/admin/styles'
import { canOpenAdmin, isAdmin, type Viewer } from '~/lib/auth-access'
import { adminMediaPath, adminPath } from '~/lib/auth-routes'
import {
@@ -93,15 +95,18 @@ export async function AdminNav({ viewer, number }: AdminNavProps) {
</Scroller>
</nav>
<div className="flex flex-col gap-2 border-t border-rule px-4 py-4">
<span className="px-1 font-mono text-micro text-ink-3">
{t('signedInAs', { name: viewer.name.trim() === '' ? viewer.email : viewer.name })}
<div className="flex flex-col gap-0.5 border-t border-rule px-3 py-3">
<div className="flex items-center justify-between gap-2 px-2.5 pb-1">
<span className="truncate font-mono text-micro text-ink-3">
{viewer.name.trim() === '' ? viewer.email : viewer.name}
</span>
<NavLink href={overviewPath()} icon={<TbArrowNarrowLeft aria-hidden="true" className="size-4" />}>
<ThemeToggle showLabel={false} />
</div>
<Link href={overviewPath()} className={footerLinkClass}>
<TbArrowNarrowLeft aria-hidden="true" className="size-4 shrink-0" />
{nav('toArchive')}
</NavLink>
</Link>
<SignOutButton />
<ThemeToggle className="w-full justify-center" />
</div>
</div>
)
+11 -12
View File
@@ -1,4 +1,5 @@
import Form from 'next/form'
import Link from 'next/link'
import { getTranslations } from 'next-intl/server'
import { TbArchive, TbPencil, TbSearch, TbStack2 } from 'react-icons/tb'
import { NavLink } from './NavLink'
@@ -6,6 +7,7 @@ import { Wordmark } from './Wordmark'
import { Scroller } from '~/components/ui/Scroller'
import { ThemeToggle } from '~/components/ui/ThemeToggle'
import { SignOutButton } from '~/components/admin/SignOutButton'
import { footerLinkClass } from '~/components/admin/styles'
import { listBrandsWithProjects } from '~/data/repositories/archive'
import { adminPath } from '~/lib/auth-routes'
import { archivePath, overviewPath, projectPath, searchPath } from '~/lib/routes'
@@ -90,23 +92,20 @@ export async function SiteNav({ scope, number, viewer }: SiteNavProps) {
</Scroller>
</nav>
<div className="flex flex-col gap-2 border-t border-rule px-4 py-4">
{viewer ? (
<span className="px-1 font-mono text-micro text-ink-3">
{admin('signedInAs', { name: viewer.name.trim() === '' ? viewer.email : viewer.name })}
<div className="flex flex-col gap-0.5 border-t border-rule px-3 py-3">
<div className="flex items-center justify-between gap-2 px-2.5 pb-1">
<span className="truncate font-mono text-micro text-ink-3">
{viewer ? (viewer.name.trim() === '' ? viewer.email : viewer.name) : null}
</span>
) : null}
<ThemeToggle showLabel={false} />
</div>
{canOpenAdmin(viewer) ? (
<NavLink
href={adminPath}
path={adminPath}
icon={<TbPencil aria-hidden="true" className="size-4" />}
>
<Link href={adminPath} className={footerLinkClass}>
<TbPencil aria-hidden="true" className="size-4 shrink-0" />
{t('admin')}
</NavLink>
</Link>
) : null}
{viewer ? <SignOutButton /> : null}
<ThemeToggle className="w-full justify-center" />
</div>
</div>
)
+1 -1
View File
@@ -57,7 +57,7 @@ export function ThemeToggle({ className, showLabel = true }: ThemeToggleProps) {
onClick={cycle}
title={t('label')}
aria-label={`${t('label')}: ${t(mode)}`}
className={`inline-flex cursor-pointer items-center gap-2 rounded-sm border border-rule bg-surface px-3 py-2 font-display text-micro uppercase tracking-chip text-ink hover:border-ink-3 ${className ?? ''}`}
className={`inline-flex cursor-pointer items-center gap-2 bg-transparent p-1 font-mono text-micro font-semibold uppercase tracking-label text-ink-3 hover:text-ink ${className ?? ''}`}
>
<Icon aria-hidden="true" className="size-4" />
{showLabel ? <span>{t(mode)}</span> : null}
+10
View File
@@ -142,6 +142,16 @@ export async function listPosts(query: PostQuery): Promise<PostListResult> {
return { items, total: Number(totals?.value ?? 0) }
}
export async function findPostIdBySlug(slug: string): Promise<string | undefined> {
const rows = await db
.select({ id: posts.id })
.from(posts)
.where(eq(posts.slug, slug))
.limit(1)
return rows[0]?.id
}
export async function findPost(slug: string, scope: ViewerScope, projectId?: string): Promise<PostDetail | undefined> {
const parts: SQL[] = [
eq(posts.slug, slug),
+4
View File
@@ -16,6 +16,10 @@ export function adminApiFilePath(file: 'openapi' | 'style-guide'): Route {
return `/admin/api/files/${file}` as Route
}
export function adminProjectEntriesPath(slug: string): Route {
return `${adminEntriesPath}?project=${encodeURIComponent(slug)}` as Route
}
export function adminPostTypePath(id: string): Route {
return `/admin/post-types/${encodeURIComponent(id)}` as Route
}
+5 -2
View File
@@ -315,10 +315,13 @@ export function openApiDocument(baseUrl: string) {
'multipart/form-data': {
schema: {
type: 'object',
required: ['file', 'projectId'],
required: ['file', 'project'],
properties: {
file: { type: 'string', format: 'binary' },
projectId: { type: 'string', format: 'uuid' },
project: {
type: 'string',
description: 'Slug des Projekts. Bei projektgebundenem Token weglassbar.',
},
alt: { type: 'string' },
caption: { type: 'string' },
},