diff --git a/Dockerfile b/Dockerfile
index 317384f..a5f98e8 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -12,12 +12,18 @@ COPY --from=deps /app/node_modules ./node_modules
COPY . .
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
WORKDIR /app
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/
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
diff --git a/next.config.ts b/next.config.ts
index 5954a8c..029f584 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -1,11 +1,22 @@
import type { NextConfig } from 'next'
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 = {
output: 'standalone',
outputFileTracingIncludes: {
'/api/v1/style-guide': ['./docs/style-guide.md'],
'/': ['./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,
experimental: {
diff --git a/src/app/admin/api/files/openapi/route.ts b/src/app/admin/api/files/openapi/route.ts
new file mode 100644
index 0000000..d62173d
--- /dev/null
+++ b/src/app/admin/api/files/openapi/route.ts
@@ -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)
+}
diff --git a/src/app/admin/api/files/style-guide/route.ts b/src/app/admin/api/files/style-guide/route.ts
new file mode 100644
index 0000000..94fb4c7
--- /dev/null
+++ b/src/app/admin/api/files/style-guide/route.ts
@@ -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)
+}
diff --git a/src/app/admin/api/page.tsx b/src/app/admin/api/page.tsx
index 84268f1..4494e89 100644
--- a/src/app/admin/api/page.tsx
+++ b/src/app/admin/api/page.tsx
@@ -1,15 +1,17 @@
import type { Metadata } from 'next'
+import { headers } from 'next/headers'
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 { CopyButton } from '~/components/ui/CopyButton'
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'
+import { adminApiFilePath, adminClientsPath } from '~/lib/admin-routes'
export const metadata: Metadata = { title: 'API - Logbuch' }
@@ -35,6 +37,12 @@ export default async function AdminApiPage() {
await requireAdminArea()
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 (
@@ -70,25 +78,39 @@ export default async function AdminApiPage() {
- Beide Adressen verlangen ein Token. In Postman oder Bruno trägst du es unter Authorization als
- Bearer ein.
+ Angemeldet als Admin lädst du beide Dateien direkt herunter. Über die Schnittstelle verlangen
+ sie ein Token, in Postman oder Bruno unter Authorization als Bearer.
+
+ {files.map(file => (
+
+
+
+ {file.label}
+
+ {file.url}
+
+
+
+ ))}
+
diff --git a/src/app/api/v1/openapi.json/route.ts b/src/app/api/v1/openapi.json/route.ts
index a69e005..b889afd 100644
--- a/src/app/api/v1/openapi.json/route.ts
+++ b/src/app/api/v1/openapi.json/route.ts
@@ -1,5 +1,5 @@
import { resolveClient } from '~/lib/api-auth'
-import { openApiDocument } from '~/lib/openapi'
+import { openApiResponse } from '~/lib/api-docs'
import { problem } from '~/lib/problem'
export async function GET(request: Request) {
@@ -10,12 +10,6 @@ export async function GET(request: Request) {
}
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"',
- },
- })
+ return openApiResponse(process.env.BETTER_AUTH_URL ?? url.origin)
}
diff --git a/src/app/api/v1/style-guide/route.ts b/src/app/api/v1/style-guide/route.ts
index 121567c..407b82c 100644
--- a/src/app/api/v1/style-guide/route.ts
+++ b/src/app/api/v1/style-guide/route.ts
@@ -1,6 +1,5 @@
-import { readFile } from 'node:fs/promises'
-import { join } from 'node:path'
import { resolveClient } from '~/lib/api-auth'
+import { readStyleGuide, styleGuideResponse } from '~/lib/api-docs'
import { problem } from '~/lib/problem'
export async function GET(request: Request) {
@@ -10,13 +9,11 @@ export async function GET(request: Request) {
return problem(401, 'unauthorized')
}
- try {
- const text = await readFile(join(process.cwd(), 'docs', 'style-guide.md'), 'utf8')
+ const text = await readStyleGuide()
- return new Response(text, {
- headers: { 'content-type': 'text/markdown; charset=utf-8' },
- })
- } catch {
+ if (text === null) {
return problem(500, 'style_guide_missing', 'docs/style-guide.md fehlt.')
}
+
+ return styleGuideResponse(text)
}
diff --git a/src/app/icon.svg b/src/app/icon.svg
new file mode 100644
index 0000000..e79352c
--- /dev/null
+++ b/src/app/icon.svg
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
diff --git a/src/components/layout/Wordmark.tsx b/src/components/layout/Wordmark.tsx
index af90c6b..2546ef3 100644
--- a/src/components/layout/Wordmark.tsx
+++ b/src/components/layout/Wordmark.tsx
@@ -1,5 +1,6 @@
import Link from 'next/link'
import { useTranslations } from 'next-intl'
+import { TbAnchor } from 'react-icons/tb'
import { entryNumber } from '~/components/ui/Plate'
import { overviewPath } from '~/lib/routes'
@@ -15,19 +16,24 @@ export function Wordmark({ number, className }: WordmarkProps) {
return (
-
- {app('name')}
+
+
- {number !== undefined ? (
-
- {entryNumber(number)}
+
+
+ {app('name')}
- ) : null}
+ {number !== undefined ? (
+
+ {entryNumber(number)}
+
+ ) : null}
+
)
}
diff --git a/src/components/ui/CopyButton.tsx b/src/components/ui/CopyButton.tsx
new file mode 100644
index 0000000..29424ca
--- /dev/null
+++ b/src/components/ui/CopyButton.tsx
@@ -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 (
+
+ {copied ? (
+
+ ) : (
+
+ )}
+ {copied ? doneLabel : label}
+
+ )
+}
diff --git a/src/lib/admin-routes.ts b/src/lib/admin-routes.ts
index 89917d3..abdaa9d 100644
--- a/src/lib/admin-routes.ts
+++ b/src/lib/admin-routes.ts
@@ -12,6 +12,10 @@ export const adminClientsPath = '/admin/clients' as Route
export const adminPostTypesPath = '/admin/post-types' 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 {
return `/admin/post-types/${encodeURIComponent(id)}` as Route
}
diff --git a/src/lib/api-docs.ts b/src/lib/api-docs.ts
new file mode 100644
index 0000000..6b5ba01
--- /dev/null
+++ b/src/lib/api-docs.ts
@@ -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 {
+ 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 = { 'content-type': 'text/markdown; charset=utf-8' }
+
+ if (download) {
+ headers['content-disposition'] = 'attachment; filename="logbuch-redaktionsleitfaden.md"'
+ }
+
+ return new Response(text, { headers })
+}