diff --git a/Dockerfile b/Dockerfile index a5f98e8..9bd248a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/docs/api.md b/docs/api.md index 7885ac0..9cf463e 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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=" \ + -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/... diff --git a/src/app/admin/api/page.tsx b/src/app/admin/api/page.tsx index 4494e89..197723f 100644 --- a/src/app/admin/api/page.tsx +++ b/src/app/admin/api/page.tsx @@ -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=" \\\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'} /> diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index e00b426..e181a5c 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -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}`} /> {project.name} diff --git a/src/app/api/v1/posts/[slug]/route.ts b/src/app/api/v1/posts/[slug]/route.ts index 391001b..4274eb8 100644 --- a/src/app/api/v1/posts/[slug]/route.ts +++ b/src/app/api/v1/posts/[slug]/route.ts @@ -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 }> }) { diff --git a/src/components/admin/ClientForm.tsx b/src/components/admin/ClientForm.tsx index 442e29a..cb1b1de 100644 --- a/src/components/admin/ClientForm.tsx +++ b/src/components/admin/ClientForm.tsx @@ -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) { - + - + - + diff --git a/src/components/admin/EntryBlockCard.tsx b/src/components/admin/EntryBlockCard.tsx index e891658..68dbcac 100644 --- a/src/components/admin/EntryBlockCard.tsx +++ b/src/components/admin/EntryBlockCard.tsx @@ -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({ /> - + diff --git a/src/components/admin/EntryEditor.tsx b/src/components/admin/EntryEditor.tsx index 1e4f622..76841d6 100644 --- a/src/components/admin/EntryEditor.tsx +++ b/src/components/admin/EntryEditor.tsx @@ -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} > - + - + - + diff --git a/src/components/admin/EntryFilters.tsx b/src/components/admin/EntryFilters.tsx index b9a3179..cbdb6c9 100644 --- a/src/components/admin/EntryFilters.tsx +++ b/src/components/admin/EntryFilters.tsx @@ -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 >
- + - {postStatuses.map(item => ( ))} - + - {types.map(item => ( ))} - + diff --git a/src/components/admin/ProjectForm.tsx b/src/components/admin/ProjectForm.tsx index 4622db6..ef85996 100644 --- a/src/components/admin/ProjectForm.tsx +++ b/src/components/admin/ProjectForm.tsx @@ -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) { - + + +export function Select({ className, children, ...rest }: SelectProps) { + return ( + + + + ) +} diff --git a/src/components/admin/SignOutButton.tsx b/src/components/admin/SignOutButton.tsx index 86cfffa..4a711ad 100644 --- a/src/components/admin/SignOutButton.tsx +++ b/src/components/admin/SignOutButton.tsx @@ -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 ( -
- diff --git a/src/components/admin/styles.ts b/src/components/admin/styles.ts index 09778c3..bdeda04 100644 --- a/src/components/admin/styles.ts +++ b/src/components/admin/styles.ts @@ -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' diff --git a/src/components/layout/AdminNav.tsx b/src/components/layout/AdminNav.tsx index c251878..3bdb472 100644 --- a/src/components/layout/AdminNav.tsx +++ b/src/components/layout/AdminNav.tsx @@ -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) { -
- - {t('signedInAs', { name: viewer.name.trim() === '' ? viewer.email : viewer.name })} - -
) diff --git a/src/components/layout/SiteNav.tsx b/src/components/layout/SiteNav.tsx index 772fc90..5603748 100644 --- a/src/components/layout/SiteNav.tsx +++ b/src/components/layout/SiteNav.tsx @@ -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) { -
- {viewer ? ( - - {admin('signedInAs', { name: viewer.name.trim() === '' ? viewer.email : viewer.name })} +
+
+ + {viewer ? (viewer.name.trim() === '' ? viewer.email : viewer.name) : null} - ) : null} + +
{canOpenAdmin(viewer) ? ( - + ) : null} {viewer ? : null} -
) diff --git a/src/components/ui/ThemeToggle.tsx b/src/components/ui/ThemeToggle.tsx index 7a76d40..7d361b4 100644 --- a/src/components/ui/ThemeToggle.tsx +++ b/src/components/ui/ThemeToggle.tsx @@ -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 ?? ''}`} >