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 FROM node:24-alpine AS base
RUN corepack enable && corepack prepare pnpm@latest --activate 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 RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ 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/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/drizzle ./drizzle COPY --from=builder --chown=nextjs:nodejs /app/drizzle ./drizzle
COPY --from=builder --chown=nextjs:nodejs /app/docs/style-guide.md ./docs/style-guide.md 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 ```bash
curl -s -X POST -H "Authorization: Bearer $TOKEN" \ curl -s -X POST -H "Authorization: Bearer $TOKEN" \
-F "file=@screenshot.png" \ -F "file=@screenshot.png" \
-F "projectId=<projekt-id>" \ -F "project=trakk" \
-F "alt=Spaltenmenü mit den neuen Einträgen" \ -F "alt=Spaltenmenü mit den neuen Einträgen" \
http://localhost:4700/api/v1/media 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. Grenzen: nur Bildformate, höchstens 15 MB.
### GET /api/v1/media/file/... ### GET /api/v1/media/file/...
+1 -1
View File
@@ -262,7 +262,7 @@ export default async function AdminApiPage() {
path="/api/v1/media" path="/api/v1/media"
auth="write" auth="write"
summary="Lädt ein Bild hoch und erzeugt WebP-Varianten. Nur Bildformate, höchstens 15 MB." 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> </section>
+3 -2
View File
@@ -16,13 +16,14 @@ import {
adminClientsPath, adminClientsPath,
adminNewEntryPath, adminNewEntryPath,
adminNewProjectPath, adminNewProjectPath,
adminProjectEntriesPath,
adminProjectPath, adminProjectPath,
adminProjectsPath, adminProjectsPath,
adminUsersPath, adminUsersPath,
} from '~/lib/admin-routes' } from '~/lib/admin-routes'
import { isAdmin } from '~/lib/auth-access' import { isAdmin } from '~/lib/auth-access'
import { requireAdminArea } from '~/lib/auth-guards' import { requireAdminArea } from '~/lib/auth-guards'
import { adminMediaPath, adminMediaProjectPath } from '~/lib/auth-routes' import { adminMediaPath } from '~/lib/auth-routes'
export default async function AdminPage() { export default async function AdminPage() {
const viewer = await requireAdminArea() const viewer = await requireAdminArea()
@@ -116,7 +117,7 @@ export default async function AdminPage() {
className={`size-2.5 shrink-0 ${projectSurface}`} className={`size-2.5 shrink-0 ${projectSurface}`}
/> />
<Link <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" className="flex items-center gap-2 font-display text-base font-semibold text-ink no-underline hover:text-signal"
> >
{project.name} {project.name}
+11 -3
View File
@@ -1,5 +1,5 @@
import { z } from 'zod' import { z } from 'zod'
import { findPost } from '~/data/repositories/posts' import { findPost, findPostIdBySlug } from '~/data/repositories/posts'
import { resolveClient } from '~/lib/api-auth' import { resolveClient } from '~/lib/api-auth'
import { apiDeleteEntry, apiReadEntry, apiUpdateEntry } from '~/lib/api-entries' import { apiDeleteEntry, apiReadEntry, apiUpdateEntry } from '~/lib/api-entries'
import { entryProblem } from '~/lib/api-problem' 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) 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 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 }> }) { 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 { AdminField } from './AdminField'
import { AdminFormMessage } from './AdminFormMessage' import { AdminFormMessage } from './AdminFormMessage'
import { AdminSubmit } from './AdminSubmit' 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 { createApiClient } from '~/lib/admin-actions'
import { emptyAdminFormState } from '~/lib/admin-forms' import { emptyAdminFormState } from '~/lib/admin-forms'
@@ -84,12 +85,11 @@ export function ClientForm({ projects }: ClientFormProps) {
</AdminField> </AdminField>
<AdminField id="projectId" label={t('fields.project')} hint={t('hints.project')} error={fieldError('projectId')}> <AdminField id="projectId" label={t('fields.project')} hint={t('hints.project')} error={fieldError('projectId')}>
<select <Select
id="projectId" id="projectId"
name="projectId" name="projectId"
value={projectId} value={projectId}
onChange={event => setProjectId(event.target.value)} onChange={event => setProjectId(event.target.value)}
className={selectClass}
> >
<option value="">{t('allProjects')}</option> <option value="">{t('allProjects')}</option>
{projects.map(project => ( {projects.map(project => (
@@ -97,39 +97,37 @@ export function ClientForm({ projects }: ClientFormProps) {
{project.name} {project.name}
</option> </option>
))} ))}
</select> </Select>
</AdminField> </AdminField>
<AdminField id="mode" label={t('fields.mode')} hint={t('hints.mode')} error={fieldError('mode')}> <AdminField id="mode" label={t('fields.mode')} hint={t('hints.mode')} error={fieldError('mode')}>
<select <Select
id="mode" id="mode"
name="mode" name="mode"
value={mode} value={mode}
onChange={event => setMode(event.target.value)} onChange={event => setMode(event.target.value)}
className={selectClass}
> >
{modes.map(value => ( {modes.map(value => (
<option key={value} value={value}> <option key={value} value={value}>
{t(`modes.${value}`)} {t(`modes.${value}`)}
</option> </option>
))} ))}
</select> </Select>
</AdminField> </AdminField>
<AdminField id="scope" label={t('fields.scope')} hint={t('hints.scope')} error={fieldError('scope')}> <AdminField id="scope" label={t('fields.scope')} hint={t('hints.scope')} error={fieldError('scope')}>
<select <Select
id="scope" id="scope"
name="scope" name="scope"
value={scope} value={scope}
onChange={event => setScope(event.target.value)} onChange={event => setScope(event.target.value)}
className={selectClass}
> >
{scopes.map(value => ( {scopes.map(value => (
<option key={value} value={value}> <option key={value} value={value}>
{t(`scopes.${value}`)} {t(`scopes.${value}`)}
</option> </option>
))} ))}
</select> </Select>
</AdminField> </AdminField>
</div> </div>
+4 -4
View File
@@ -5,7 +5,8 @@ import { useTranslations } from 'next-intl'
import { TbArrowDown, TbArrowUp, TbGripVertical, TbTrash } from 'react-icons/tb' import { TbArrowDown, TbArrowUp, TbGripVertical, TbTrash } from 'react-icons/tb'
import { AdminField } from './AdminField' import { AdminField } from './AdminField'
import { EntryMediaPicker } from './EntryMediaPicker' 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 { BlockType, BlockValues } from '~/domain/blocks'
import type { EntryMedia } from '~/lib/entry-types' import type { EntryMedia } from '~/lib/entry-types'
@@ -323,15 +324,14 @@ export function EntryBlockCard({
/> />
</AdminField> </AdminField>
<AdminField id={`${id}-tone`} label={t('blockFields.tone')}> <AdminField id={`${id}-tone`} label={t('blockFields.tone')}>
<select <Select
id={`${id}-tone`} id={`${id}-tone`}
value={text(data, 'tone') === 'warning' ? 'warning' : 'info'} value={text(data, 'tone') === 'warning' ? 'warning' : 'info'}
onChange={event => set('tone', event.target.value)} onChange={event => set('tone', event.target.value)}
className={selectClass}
> >
<option value="info">{t('blockFields.toneInfo')}</option> <option value="info">{t('blockFields.toneInfo')}</option>
<option value="warning">{t('blockFields.toneWarning')}</option> <option value="warning">{t('blockFields.toneWarning')}</option>
</select> </Select>
</AdminField> </AdminField>
</div> </div>
<AdminField id={`${id}-callouttext`} label={t('blockFields.text')}> <AdminField id={`${id}-callouttext`} label={t('blockFields.text')}>
+7 -10
View File
@@ -30,9 +30,9 @@ import {
metaClass, metaClass,
primaryButtonClass, primaryButtonClass,
quietLinkClass, quietLinkClass,
selectClass,
textareaClass, textareaClass,
} from './styles' } from './styles'
import { Select } from './Select'
import { blockTypes, emptyBlockData, isBlockEmpty, type BlockType, type BlockValues } from '~/domain/blocks' import { blockTypes, emptyBlockData, isBlockEmpty, type BlockType, type BlockValues } from '~/domain/blocks'
import { checkPublish } from '~/domain/publish-checks' import { checkPublish } from '~/domain/publish-checks'
import { defaultPostTypeColor } from '~/domain/post-type' import { defaultPostTypeColor } from '~/domain/post-type'
@@ -678,48 +678,45 @@ export function EntryEditor({ projects, types, media: initialMedia, accept, defa
label={t('fields.project')} label={t('fields.project')}
hint={entry ? t('hints.projectMove') : undefined} hint={entry ? t('hints.projectMove') : undefined}
> >
<select <Select
id="entry-project" id="entry-project"
value={form.projectId} value={form.projectId}
onChange={event => changeProject(event.target.value)} onChange={event => changeProject(event.target.value)}
className={selectClass}
> >
{projects.map(item => ( {projects.map(item => (
<option key={item.id} value={item.id}> <option key={item.id} value={item.id}>
{item.name} {item.name}
</option> </option>
))} ))}
</select> </Select>
</AdminField> </AdminField>
<AdminField id="entry-type" label={t('fields.type')}> <AdminField id="entry-type" label={t('fields.type')}>
<select <Select
id="entry-type" id="entry-type"
value={form.typeId} value={form.typeId}
onChange={event => update({ typeId: event.target.value })} onChange={event => update({ typeId: event.target.value })}
className={selectClass}
> >
{types.map(item => ( {types.map(item => (
<option key={item.id} value={item.id}> <option key={item.id} value={item.id}>
{item.label} {item.label}
</option> </option>
))} ))}
</select> </Select>
</AdminField> </AdminField>
<AdminField id="entry-audience" label={t('fields.audience')} hint={t('hints.audience')}> <AdminField id="entry-audience" label={t('fields.audience')} hint={t('hints.audience')}>
<select <Select
id="entry-audience" id="entry-audience"
value={form.audience} value={form.audience}
onChange={event => update({ audience: event.target.value as Audience })} onChange={event => update({ audience: event.target.value as Audience })}
className={selectClass}
> >
{audiences.map(item => ( {audiences.map(item => (
<option key={item} value={item}> <option key={item} value={item}>
{t(`audience.${item}`)} {t(`audience.${item}`)}
</option> </option>
))} ))}
</select> </Select>
</AdminField> </AdminField>
</div> </div>
+8 -8
View File
@@ -5,7 +5,8 @@ import Link from 'next/link'
import { useTranslations } from 'next-intl' import { useTranslations } from 'next-intl'
import { TbSearch } from 'react-icons/tb' import { TbSearch } from 'react-icons/tb'
import { AdminField } from './AdminField' 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 { postStatuses } from '~/domain/post-status'
import { adminEntriesPath } from '~/lib/admin-routes' import { adminEntriesPath } from '~/lib/admin-routes'
import type { EntryTypeOption } from '~/lib/entry-types' 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"> <div className="grid gap-5 md:grid-cols-4">
<AdminField id="filter-project" label={t('project')}> <AdminField id="filter-project" label={t('project')}>
<select <Select
id="filter-project" id="filter-project"
name="project" name="project"
defaultValue={project} defaultValue={project}
onChange={submit} onChange={submit}
className={selectClass}
> >
<option value="">{t('allProjects')}</option> <option value="">{t('allProjects')}</option>
{projects.map(item => ( {projects.map(item => (
@@ -50,29 +50,29 @@ export function EntryFilters({ projects, types, project, status, type, search, f
{item.name} {item.name}
</option> </option>
))} ))}
</select> </Select>
</AdminField> </AdminField>
<AdminField id="filter-status" label={t('status')}> <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> <option value="">{t('allStatus')}</option>
{postStatuses.map(item => ( {postStatuses.map(item => (
<option key={item} value={item}> <option key={item} value={item}>
{states(item)} {states(item)}
</option> </option>
))} ))}
</select> </Select>
</AdminField> </AdminField>
<AdminField id="filter-type" label={t('type')}> <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> <option value="">{t('allTypes')}</option>
{types.map(item => ( {types.map(item => (
<option key={item.id} value={item.key}> <option key={item.id} value={item.key}>
{item.label} {item.label}
</option> </option>
))} ))}
</select> </Select>
</AdminField> </AdminField>
<AdminField id="filter-search" label={t('search')}> <AdminField id="filter-search" label={t('search')}>
+4 -4
View File
@@ -8,7 +8,8 @@ import { AdminColorField } from './AdminColorField'
import { AdminField } from './AdminField' import { AdminField } from './AdminField'
import { AdminFormMessage } from './AdminFormMessage' import { AdminFormMessage } from './AdminFormMessage'
import { AdminSubmit } from './AdminSubmit' 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 { saveProject } from '~/lib/admin-actions'
import { emptyAdminFormState } from '~/lib/admin-forms' import { emptyAdminFormState } from '~/lib/admin-forms'
import { adminProjectsPath } from '~/lib/admin-routes' import { adminProjectsPath } from '~/lib/admin-routes'
@@ -107,12 +108,11 @@ export function ProjectForm({ brands, project }: ProjectFormProps) {
</AdminField> </AdminField>
<AdminField id="brandId" label={t('fields.brand')} error={fieldError('brandId')}> <AdminField id="brandId" label={t('fields.brand')} error={fieldError('brandId')}>
<select <Select
id="brandId" id="brandId"
name="brandId" name="brandId"
value={brandId} value={brandId}
onChange={event => setBrandId(event.target.value)} onChange={event => setBrandId(event.target.value)}
className={selectClass}
> >
<option value="">{t('fields.brandEmpty')}</option> <option value="">{t('fields.brandEmpty')}</option>
{brands.map(brand => ( {brands.map(brand => (
@@ -120,7 +120,7 @@ export function ProjectForm({ brands, project }: ProjectFormProps) {
{brand.name} {brand.name}
</option> </option>
))} ))}
</select> </Select>
</AdminField> </AdminField>
<AdminColorField <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 { getTranslations } from 'next-intl/server'
import { TbLogout2 } from 'react-icons/tb' import { TbLogout2 } from 'react-icons/tb'
import { footerLinkClass } from './styles'
import { signOut } from '~/lib/auth-actions' import { signOut } from '~/lib/auth-actions'
export async function SignOutButton() { export async function SignOutButton() {
const t = await getTranslations('admin') const t = await getTranslations('admin')
return ( return (
<form action={signOut}> <form action={signOut} className="w-full">
<button <button type="submit" className={`${footerLinkClass} hover:text-signal`}>
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" /> <TbLogout2 aria-hidden="true" className="size-4 shrink-0" />
{t('signOut')} {t('signOut')}
</button> </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 inputClass = fieldClass
export const selectClass = `cursor-pointer ${fieldClass}` export const selectClass = `cursor-pointer appearance-none pr-7 ${fieldClass}`
export const textareaClass = `resize-y ${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 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 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 cellClass = 'border-b border-rule py-3 pr-6 align-middle text-small text-ink-2 last:pr-0'
+12 -7
View File
@@ -1,3 +1,4 @@
import Link from 'next/link'
import { getTranslations } from 'next-intl/server' import { getTranslations } from 'next-intl/server'
import { TbAddressBook, TbArrowNarrowLeft, TbCode, TbKey, TbLayoutGrid, TbNotebook, TbPhoto, TbStack2, TbTags, TbUsers } from 'react-icons/tb' import { TbAddressBook, TbArrowNarrowLeft, TbCode, TbKey, TbLayoutGrid, TbNotebook, TbPhoto, TbStack2, TbTags, TbUsers } from 'react-icons/tb'
import { NavLink } from './NavLink' import { NavLink } from './NavLink'
@@ -5,6 +6,7 @@ import { Wordmark } from './Wordmark'
import { Scroller } from '~/components/ui/Scroller' import { Scroller } from '~/components/ui/Scroller'
import { ThemeToggle } from '~/components/ui/ThemeToggle' import { ThemeToggle } from '~/components/ui/ThemeToggle'
import { SignOutButton } from '~/components/admin/SignOutButton' import { SignOutButton } from '~/components/admin/SignOutButton'
import { footerLinkClass } from '~/components/admin/styles'
import { canOpenAdmin, isAdmin, type Viewer } from '~/lib/auth-access' import { canOpenAdmin, isAdmin, type Viewer } from '~/lib/auth-access'
import { adminMediaPath, adminPath } from '~/lib/auth-routes' import { adminMediaPath, adminPath } from '~/lib/auth-routes'
import { import {
@@ -93,15 +95,18 @@ export async function AdminNav({ viewer, number }: AdminNavProps) {
</Scroller> </Scroller>
</nav> </nav>
<div className="flex flex-col gap-2 border-t border-rule px-4 py-4"> <div className="flex flex-col gap-0.5 border-t border-rule px-3 py-3">
<span className="px-1 font-mono text-micro text-ink-3"> <div className="flex items-center justify-between gap-2 px-2.5 pb-1">
{t('signedInAs', { name: viewer.name.trim() === '' ? viewer.email : viewer.name })} <span className="truncate font-mono text-micro text-ink-3">
</span> {viewer.name.trim() === '' ? viewer.email : viewer.name}
<NavLink href={overviewPath()} icon={<TbArrowNarrowLeft aria-hidden="true" className="size-4" />}> </span>
<ThemeToggle showLabel={false} />
</div>
<Link href={overviewPath()} className={footerLinkClass}>
<TbArrowNarrowLeft aria-hidden="true" className="size-4 shrink-0" />
{nav('toArchive')} {nav('toArchive')}
</NavLink> </Link>
<SignOutButton /> <SignOutButton />
<ThemeToggle className="w-full justify-center" />
</div> </div>
</div> </div>
) )
+11 -12
View File
@@ -1,4 +1,5 @@
import Form from 'next/form' import Form from 'next/form'
import Link from 'next/link'
import { getTranslations } from 'next-intl/server' import { getTranslations } from 'next-intl/server'
import { TbArchive, TbPencil, TbSearch, TbStack2 } from 'react-icons/tb' import { TbArchive, TbPencil, TbSearch, TbStack2 } from 'react-icons/tb'
import { NavLink } from './NavLink' import { NavLink } from './NavLink'
@@ -6,6 +7,7 @@ import { Wordmark } from './Wordmark'
import { Scroller } from '~/components/ui/Scroller' import { Scroller } from '~/components/ui/Scroller'
import { ThemeToggle } from '~/components/ui/ThemeToggle' import { ThemeToggle } from '~/components/ui/ThemeToggle'
import { SignOutButton } from '~/components/admin/SignOutButton' import { SignOutButton } from '~/components/admin/SignOutButton'
import { footerLinkClass } from '~/components/admin/styles'
import { listBrandsWithProjects } from '~/data/repositories/archive' import { listBrandsWithProjects } from '~/data/repositories/archive'
import { adminPath } from '~/lib/auth-routes' import { adminPath } from '~/lib/auth-routes'
import { archivePath, overviewPath, projectPath, searchPath } from '~/lib/routes' import { archivePath, overviewPath, projectPath, searchPath } from '~/lib/routes'
@@ -90,23 +92,20 @@ export async function SiteNav({ scope, number, viewer }: SiteNavProps) {
</Scroller> </Scroller>
</nav> </nav>
<div className="flex flex-col gap-2 border-t border-rule px-4 py-4"> <div className="flex flex-col gap-0.5 border-t border-rule px-3 py-3">
{viewer ? ( <div className="flex items-center justify-between gap-2 px-2.5 pb-1">
<span className="px-1 font-mono text-micro text-ink-3"> <span className="truncate font-mono text-micro text-ink-3">
{admin('signedInAs', { name: viewer.name.trim() === '' ? viewer.email : viewer.name })} {viewer ? (viewer.name.trim() === '' ? viewer.email : viewer.name) : null}
</span> </span>
) : null} <ThemeToggle showLabel={false} />
</div>
{canOpenAdmin(viewer) ? ( {canOpenAdmin(viewer) ? (
<NavLink <Link href={adminPath} className={footerLinkClass}>
href={adminPath} <TbPencil aria-hidden="true" className="size-4 shrink-0" />
path={adminPath}
icon={<TbPencil aria-hidden="true" className="size-4" />}
>
{t('admin')} {t('admin')}
</NavLink> </Link>
) : null} ) : null}
{viewer ? <SignOutButton /> : null} {viewer ? <SignOutButton /> : null}
<ThemeToggle className="w-full justify-center" />
</div> </div>
</div> </div>
) )
+1 -1
View File
@@ -57,7 +57,7 @@ export function ThemeToggle({ className, showLabel = true }: ThemeToggleProps) {
onClick={cycle} onClick={cycle}
title={t('label')} title={t('label')}
aria-label={`${t('label')}: ${t(mode)}`} 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" /> <Icon aria-hidden="true" className="size-4" />
{showLabel ? <span>{t(mode)}</span> : null} {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) } 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> { export async function findPost(slug: string, scope: ViewerScope, projectId?: string): Promise<PostDetail | undefined> {
const parts: SQL[] = [ const parts: SQL[] = [
eq(posts.slug, slug), 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 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 { export function adminPostTypePath(id: string): Route {
return `/admin/post-types/${encodeURIComponent(id)}` as 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': { 'multipart/form-data': {
schema: { schema: {
type: 'object', type: 'object',
required: ['file', 'projectId'], required: ['file', 'project'],
properties: { properties: {
file: { type: 'string', format: 'binary' }, file: { type: 'string', format: 'binary' },
projectId: { type: 'string', format: 'uuid' }, project: {
type: 'string',
description: 'Slug des Projekts. Bei projektgebundenem Token weglassbar.',
},
alt: { type: 'string' }, alt: { type: 'string' },
caption: { type: 'string' }, caption: { type: 'string' },
}, },