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:
+5
-1
@@ -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
@@ -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/...
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 }> }) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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')}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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')}>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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' },
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user