Fix sharp in container, add admin downloads and anchor mark
- ship libvips with the runtime image so POST /api/v1/media stops failing - admin downloads openapi.json and the style guide via session, no token - copy rows for both API addresses - anchor mark in the wordmark and a favicon
This commit is contained in:
@@ -12,12 +12,18 @@ COPY --from=deps /app/node_modules ./node_modules
|
|||||||
COPY . .
|
COPY . .
|
||||||
RUN pnpm build
|
RUN pnpm build
|
||||||
|
|
||||||
|
FROM base AS sharp
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
RUN mkdir -p /out && cp -RL node_modules/.pnpm/@img+* /out/
|
||||||
|
|
||||||
FROM base AS runner
|
FROM base AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ENV NODE_ENV=production
|
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/
|
||||||
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
|
||||||
|
|||||||
@@ -1,11 +1,22 @@
|
|||||||
import type { NextConfig } from 'next'
|
import type { NextConfig } from 'next'
|
||||||
import createNextIntlPlugin from 'next-intl/plugin'
|
import createNextIntlPlugin from 'next-intl/plugin'
|
||||||
|
|
||||||
|
const sharpNative = [
|
||||||
|
'./node_modules/.pnpm/@img+sharp-libvips-*/**',
|
||||||
|
'./node_modules/.pnpm/@img+sharp-linux*/**',
|
||||||
|
'./node_modules/@img/**',
|
||||||
|
]
|
||||||
|
|
||||||
const config: NextConfig = {
|
const config: NextConfig = {
|
||||||
output: 'standalone',
|
output: 'standalone',
|
||||||
outputFileTracingIncludes: {
|
outputFileTracingIncludes: {
|
||||||
'/api/v1/style-guide': ['./docs/style-guide.md'],
|
'/api/v1/style-guide': ['./docs/style-guide.md'],
|
||||||
'/': ['./node_modules/drizzle-orm/**'],
|
'/': ['./node_modules/drizzle-orm/**'],
|
||||||
|
'/api/v1/media': sharpNative,
|
||||||
|
'/admin/media': sharpNative,
|
||||||
|
'/admin/media/[slug]': sharpNative,
|
||||||
|
'/admin/entries/[id]': sharpNative,
|
||||||
|
'/admin/entries/new': sharpNative,
|
||||||
},
|
},
|
||||||
typedRoutes: true,
|
typedRoutes: true,
|
||||||
experimental: {
|
experimental: {
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { openApiResponse } from '~/lib/api-docs'
|
||||||
|
import { requireAdminArea } from '~/lib/auth-guards'
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
await requireAdminArea()
|
||||||
|
|
||||||
|
const url = new URL(request.url)
|
||||||
|
|
||||||
|
return openApiResponse(process.env.BETTER_AUTH_URL ?? url.origin)
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { readStyleGuide, styleGuideResponse } from '~/lib/api-docs'
|
||||||
|
import { requireAdminArea } from '~/lib/auth-guards'
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
await requireAdminArea()
|
||||||
|
|
||||||
|
const text = await readStyleGuide()
|
||||||
|
|
||||||
|
if (text === null) {
|
||||||
|
return new Response('docs/style-guide.md fehlt.', { status: 500 })
|
||||||
|
}
|
||||||
|
|
||||||
|
return styleGuideResponse(text, true)
|
||||||
|
}
|
||||||
@@ -1,15 +1,17 @@
|
|||||||
import type { Metadata } from 'next'
|
import type { Metadata } from 'next'
|
||||||
|
import { headers } from 'next/headers'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { TbBook, TbDownload } from 'react-icons/tb'
|
import { TbBook, TbDownload } from 'react-icons/tb'
|
||||||
import { AdminHeading } from '~/components/admin/AdminHeading'
|
import { AdminHeading } from '~/components/admin/AdminHeading'
|
||||||
import { ApiEndpoint } from '~/components/admin/ApiEndpoint'
|
import { ApiEndpoint } from '~/components/admin/ApiEndpoint'
|
||||||
import { cellClass, headCellClass } from '~/components/admin/styles'
|
import { cellClass, headCellClass } from '~/components/admin/styles'
|
||||||
|
import { CopyButton } from '~/components/ui/CopyButton'
|
||||||
import { SectionLabel } from '~/components/ui/SectionLabel'
|
import { SectionLabel } from '~/components/ui/SectionLabel'
|
||||||
import { Scroller } from '~/components/ui/Scroller'
|
import { Scroller } from '~/components/ui/Scroller'
|
||||||
import { blockTypes } from '~/domain/blocks'
|
import { blockTypes } from '~/domain/blocks'
|
||||||
import { listActivePostTypes } from '~/data/repositories/post-types'
|
import { listActivePostTypes } from '~/data/repositories/post-types'
|
||||||
import { requireAdminArea } from '~/lib/auth-guards'
|
import { requireAdminArea } from '~/lib/auth-guards'
|
||||||
import { adminClientsPath } from '~/lib/admin-routes'
|
import { adminApiFilePath, adminClientsPath } from '~/lib/admin-routes'
|
||||||
|
|
||||||
export const metadata: Metadata = { title: 'API - Logbuch' }
|
export const metadata: Metadata = { title: 'API - Logbuch' }
|
||||||
|
|
||||||
@@ -35,6 +37,12 @@ export default async function AdminApiPage() {
|
|||||||
await requireAdminArea()
|
await requireAdminArea()
|
||||||
|
|
||||||
const types = await listActivePostTypes()
|
const types = await listActivePostTypes()
|
||||||
|
const host = (await headers()).get('host')
|
||||||
|
const origin = process.env.BETTER_AUTH_URL ?? (host ? `https://${host}` : '')
|
||||||
|
const files = [
|
||||||
|
{ label: 'OpenAPI', url: `${origin}/api/v1/openapi.json` },
|
||||||
|
{ label: 'Leitfaden', url: `${origin}/api/v1/style-guide` },
|
||||||
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-12 pt-12">
|
<div className="flex flex-col gap-12 pt-12">
|
||||||
@@ -70,25 +78,39 @@ export default async function AdminApiPage() {
|
|||||||
</p>
|
</p>
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
<a
|
<a
|
||||||
href="/api/v1/openapi.json"
|
href={adminApiFilePath('openapi')}
|
||||||
download
|
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"
|
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" />
|
<TbDownload aria-hidden="true" className="size-4" />
|
||||||
openapi.json
|
openapi.json laden
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href="/api/v1/style-guide"
|
href={adminApiFilePath('style-guide')}
|
||||||
|
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"
|
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" />
|
<TbBook aria-hidden="true" className="size-4" />
|
||||||
Redaktionsleitfaden
|
Leitfaden laden
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<p className="m-0 text-small">
|
<p className="m-0 text-small">
|
||||||
Beide Adressen verlangen ein Token. In Postman oder Bruno trägst du es unter Authorization als
|
Angemeldet als Admin lädst du beide Dateien direkt herunter. Über die Schnittstelle verlangen
|
||||||
Bearer ein.
|
sie ein Token, in Postman oder Bruno unter Authorization als Bearer.
|
||||||
</p>
|
</p>
|
||||||
|
<div className="flex flex-col divide-y divide-rule border-y border-rule">
|
||||||
|
{files.map(file => (
|
||||||
|
<div key={file.url} className="flex flex-wrap items-center justify-between gap-3 py-3">
|
||||||
|
<span className="flex min-w-0 flex-col gap-1">
|
||||||
|
<span className="font-display text-micro font-semibold uppercase tracking-label text-ink-3">
|
||||||
|
{file.label}
|
||||||
|
</span>
|
||||||
|
<code className="block break-all font-mono text-small text-ink">{file.url}</code>
|
||||||
|
</span>
|
||||||
|
<CopyButton value={file.url} label="Adresse kopieren" doneLabel="Kopiert" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { resolveClient } from '~/lib/api-auth'
|
import { resolveClient } from '~/lib/api-auth'
|
||||||
import { openApiDocument } from '~/lib/openapi'
|
import { openApiResponse } from '~/lib/api-docs'
|
||||||
import { problem } from '~/lib/problem'
|
import { problem } from '~/lib/problem'
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
@@ -10,12 +10,6 @@ export async function GET(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const url = new URL(request.url)
|
const url = new URL(request.url)
|
||||||
const document = openApiDocument(process.env.BETTER_AUTH_URL ?? url.origin)
|
|
||||||
|
|
||||||
return new Response(JSON.stringify(document, null, '\t'), {
|
return openApiResponse(process.env.BETTER_AUTH_URL ?? url.origin)
|
||||||
headers: {
|
|
||||||
'content-type': 'application/json; charset=utf-8',
|
|
||||||
'content-disposition': 'attachment; filename="logbuch-openapi.json"',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { readFile } from 'node:fs/promises'
|
|
||||||
import { join } from 'node:path'
|
|
||||||
import { resolveClient } from '~/lib/api-auth'
|
import { resolveClient } from '~/lib/api-auth'
|
||||||
|
import { readStyleGuide, styleGuideResponse } from '~/lib/api-docs'
|
||||||
import { problem } from '~/lib/problem'
|
import { problem } from '~/lib/problem'
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
@@ -10,13 +9,11 @@ export async function GET(request: Request) {
|
|||||||
return problem(401, 'unauthorized')
|
return problem(401, 'unauthorized')
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const text = await readStyleGuide()
|
||||||
const text = await readFile(join(process.cwd(), 'docs', 'style-guide.md'), 'utf8')
|
|
||||||
|
|
||||||
return new Response(text, {
|
if (text === null) {
|
||||||
headers: { 'content-type': 'text/markdown; charset=utf-8' },
|
|
||||||
})
|
|
||||||
} catch {
|
|
||||||
return problem(500, 'style_guide_missing', 'docs/style-guide.md fehlt.')
|
return problem(500, 'style_guide_missing', 'docs/style-guide.md fehlt.')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return styleGuideResponse(text)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
|
||||||
|
<rect width="32" height="32" fill="#14161a" />
|
||||||
|
<g
|
||||||
|
transform="translate(4 4)"
|
||||||
|
fill="none"
|
||||||
|
stroke="#f2f1ec"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M12 9v12m-8 -8a8 8 0 0 0 16 0m1 0h-2m-14 0h-2" />
|
||||||
|
<path d="M9 6a3 3 0 1 0 6 0a3 3 0 1 0 -6 0" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 394 B |
@@ -1,5 +1,6 @@
|
|||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useTranslations } from 'next-intl'
|
import { useTranslations } from 'next-intl'
|
||||||
|
import { TbAnchor } from 'react-icons/tb'
|
||||||
import { entryNumber } from '~/components/ui/Plate'
|
import { entryNumber } from '~/components/ui/Plate'
|
||||||
import { overviewPath } from '~/lib/routes'
|
import { overviewPath } from '~/lib/routes'
|
||||||
|
|
||||||
@@ -15,19 +16,24 @@ export function Wordmark({ number, className }: WordmarkProps) {
|
|||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={overviewPath()}
|
href={overviewPath()}
|
||||||
className={`inline-flex items-baseline gap-2 no-underline ${className ?? ''}`}
|
className={`inline-flex items-center gap-2.5 no-underline ${className ?? ''}`}
|
||||||
>
|
>
|
||||||
<span className="font-display text-lead font-bold uppercase leading-none tracking-tight text-ink">
|
<span className="flex size-7 shrink-0 items-center justify-center bg-ink text-paper">
|
||||||
{app('name')}
|
<TbAnchor aria-hidden="true" className="size-4" />
|
||||||
</span>
|
</span>
|
||||||
{number !== undefined ? (
|
<span className="inline-flex items-baseline gap-2">
|
||||||
<span
|
<span className="font-display text-lead font-bold uppercase leading-none tracking-tight text-ink">
|
||||||
title={nav('state', { number: entryNumber(number) })}
|
{app('name')}
|
||||||
className="font-mono text-micro leading-none text-ink-3 tabular-nums"
|
|
||||||
>
|
|
||||||
{entryNumber(number)}
|
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
{number !== undefined ? (
|
||||||
|
<span
|
||||||
|
title={nav('state', { number: entryNumber(number) })}
|
||||||
|
className="font-mono text-micro leading-none text-ink-3 tabular-nums"
|
||||||
|
>
|
||||||
|
{entryNumber(number)}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { TbCheck, TbCopy } from 'react-icons/tb'
|
||||||
|
import { ghostButtonClass } from '~/components/admin/styles'
|
||||||
|
|
||||||
|
export type CopyButtonProps = {
|
||||||
|
value: string
|
||||||
|
label: string
|
||||||
|
doneLabel: string
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CopyButton({ value, label, doneLabel, className }: CopyButtonProps) {
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!copied) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const timer = setTimeout(() => setCopied(false), 2000)
|
||||||
|
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}, [copied])
|
||||||
|
|
||||||
|
async function copy() {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(value)
|
||||||
|
setCopied(true)
|
||||||
|
} catch {
|
||||||
|
setCopied(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button type="button" onClick={copy} className={`${ghostButtonClass} ${className ?? ''}`}>
|
||||||
|
{copied ? (
|
||||||
|
<TbCheck aria-hidden="true" className="size-4 shrink-0" />
|
||||||
|
) : (
|
||||||
|
<TbCopy aria-hidden="true" className="size-4 shrink-0" />
|
||||||
|
)}
|
||||||
|
{copied ? doneLabel : label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -12,6 +12,10 @@ export const adminClientsPath = '/admin/clients' as Route
|
|||||||
export const adminPostTypesPath = '/admin/post-types' as Route
|
export const adminPostTypesPath = '/admin/post-types' as Route
|
||||||
export const adminNewPostTypePath = '/admin/post-types/new' as Route
|
export const adminNewPostTypePath = '/admin/post-types/new' as Route
|
||||||
|
|
||||||
|
export function adminApiFilePath(file: 'openapi' | 'style-guide'): Route {
|
||||||
|
return `/admin/api/files/${file}` 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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { readFile } from 'node:fs/promises'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { openApiDocument } from './openapi'
|
||||||
|
|
||||||
|
export function openApiBody(origin: string): string {
|
||||||
|
return JSON.stringify(openApiDocument(origin), null, '\t')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openApiResponse(origin: string): Response {
|
||||||
|
return new Response(openApiBody(origin), {
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json; charset=utf-8',
|
||||||
|
'content-disposition': 'attachment; filename="logbuch-openapi.json"',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readStyleGuide(): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
return await readFile(join(process.cwd(), 'docs', 'style-guide.md'), 'utf8')
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function styleGuideResponse(text: string, download = false): Response {
|
||||||
|
const headers: Record<string, string> = { 'content-type': 'text/markdown; charset=utf-8' }
|
||||||
|
|
||||||
|
if (download) {
|
||||||
|
headers['content-disposition'] = 'attachment; filename="logbuch-redaktionsleitfaden.md"'
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(text, { headers })
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user