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