Files
logbuch/src/components/admin/EntryBlockCard.tsx
T
Matthias G 7a594eeacf 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
2026-08-01 11:48:48 +02:00

351 lines
10 KiB
TypeScript

'use client'
import { useId } from 'react'
import { useTranslations } from 'next-intl'
import { TbArrowDown, TbArrowUp, TbGripVertical, TbTrash } from 'react-icons/tb'
import { AdminField } from './AdminField'
import { EntryMediaPicker } from './EntryMediaPicker'
import { inputClass, textareaClass } from './styles'
import { Select } from './Select'
import type { BlockType, BlockValues } from '~/domain/blocks'
import type { EntryMedia } from '~/lib/entry-types'
export type EntryBlockCardProps = {
type: BlockType
data: BlockValues
index: number
total: number
media: EntryMedia[]
accept: string
dragging: boolean
onChange: (data: BlockValues) => void
onRemove: () => void
onMove: (to: number) => void
onDragStart: () => void
onDragEnd: () => void
onDropOn: () => void
upload: (files: File[]) => Promise<string[]>
}
function text(data: BlockValues, key: string): string {
const value = data[key]
return typeof value === 'string' ? value : ''
}
function list(data: BlockValues, key: string): string[] {
const value = data[key]
return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : []
}
export function EntryBlockCard({
type,
data,
index,
total,
media,
accept,
dragging,
onChange,
onRemove,
onMove,
onDragStart,
onDragEnd,
onDropOn,
upload,
}: EntryBlockCardProps) {
const t = useTranslations('admin.entries')
const id = useId()
function set(key: string, value: unknown) {
onChange({ ...data, [key]: value })
}
function pick(key: string, ids: string[]) {
set(key, ids[0] ?? '')
}
async function uploadInto(key: string, files: File[]) {
const ids = await upload(files)
if (ids.length > 0) {
set(key, ids[0])
}
}
async function uploadIntoList(key: string, files: File[]) {
const ids = await upload(files)
if (ids.length > 0) {
set(key, [...list(data, key), ...ids])
}
}
return (
<li
onDragOver={event => event.preventDefault()}
onDrop={event => {
if (event.dataTransfer.files.length > 0) {
return
}
event.preventDefault()
event.stopPropagation()
onDropOn()
}}
className={`flex flex-col gap-4 border bg-surface px-5 py-4 ${dragging ? 'border-signal' : 'border-rule'}`}
>
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
<span
draggable
onDragStart={onDragStart}
onDragEnd={onDragEnd}
aria-label={t('actions.drag')}
className="flex cursor-grab items-center text-ink-3 hover:text-ink"
>
<TbGripVertical aria-hidden="true" className="size-4" />
</span>
<span className="font-mono text-micro font-semibold uppercase tracking-label text-ink-2">
{t(`blocks.${type}`)}
</span>
<span aria-hidden="true" className="h-px min-w-6 flex-1 bg-rule" />
<button
type="button"
disabled={index === 0}
onClick={() => onMove(index - 1)}
aria-label={t('actions.up')}
className="cursor-pointer border-0 bg-transparent p-0 text-ink-3 hover:text-ink disabled:cursor-not-allowed disabled:opacity-40"
>
<TbArrowUp aria-hidden="true" className="size-4" />
</button>
<button
type="button"
disabled={index === total - 1}
onClick={() => onMove(index + 1)}
aria-label={t('actions.down')}
className="cursor-pointer border-0 bg-transparent p-0 text-ink-3 hover:text-ink disabled:cursor-not-allowed disabled:opacity-40"
>
<TbArrowDown aria-hidden="true" className="size-4" />
</button>
<button
type="button"
onClick={onRemove}
aria-label={t('actions.remove')}
className="cursor-pointer border-0 bg-transparent p-0 text-ink-3 hover:text-signal"
>
<TbTrash aria-hidden="true" className="size-4" />
</button>
</div>
{type === 'text' ? (
<AdminField id={`${id}-text`} label={t('blockFields.text')}>
<textarea
id={`${id}-text`}
rows={6}
value={text(data, 'text')}
placeholder={t('blockFields.textPlaceholder')}
onChange={event => set('text', event.target.value)}
className={textareaClass}
/>
</AdminField>
) : null}
{type === 'image' ? (
<EntryMediaPicker
label={t('blockFields.image')}
media={media}
selected={text(data, 'mediaId') === '' ? [] : [text(data, 'mediaId')]}
accept={accept}
onSelect={ids => pick('mediaId', ids)}
onUpload={files => void uploadInto('mediaId', files)}
/>
) : null}
{type === 'gallery' ? (
<EntryMediaPicker
label={t('blockFields.gallery')}
media={media}
selected={list(data, 'mediaIds')}
multiple
accept={accept}
onSelect={ids => set('mediaIds', ids)}
onUpload={files => void uploadIntoList('mediaIds', files)}
hint={t('blockFields.galleryHint')}
/>
) : null}
{type === 'before_after' ? (
<div className="grid gap-5 md:grid-cols-2">
<EntryMediaPicker
label={t('blockFields.before')}
media={media}
selected={text(data, 'beforeMediaId') === '' ? [] : [text(data, 'beforeMediaId')]}
accept={accept}
onSelect={ids => pick('beforeMediaId', ids)}
onUpload={files => void uploadInto('beforeMediaId', files)}
/>
<EntryMediaPicker
label={t('blockFields.after')}
media={media}
selected={text(data, 'afterMediaId') === '' ? [] : [text(data, 'afterMediaId')]}
accept={accept}
onSelect={ids => pick('afterMediaId', ids)}
onUpload={files => void uploadInto('afterMediaId', files)}
/>
</div>
) : null}
{type === 'video' ? (
<div className="flex flex-col gap-5">
<EntryMediaPicker
label={t('blockFields.video')}
media={media}
selected={text(data, 'mediaId') === '' ? [] : [text(data, 'mediaId')]}
accept={accept}
onSelect={ids => pick('mediaId', ids)}
onUpload={files => void uploadInto('mediaId', files)}
hint={t('blockFields.videoHint')}
/>
<div className="grid gap-5 md:grid-cols-2">
<AdminField id={`${id}-url`} label={t('blockFields.url')}>
<input
id={`${id}-url`}
type="url"
value={text(data, 'url')}
onChange={event => set('url', event.target.value)}
className={`${inputClass} font-mono`}
/>
</AdminField>
<AdminField id={`${id}-title`} label={t('blockFields.label')}>
<input
id={`${id}-title`}
type="text"
value={text(data, 'title')}
onChange={event => set('title', event.target.value)}
className={inputClass}
/>
</AdminField>
</div>
</div>
) : null}
{type === 'quote' ? (
<div className="flex flex-col gap-5">
<AdminField id={`${id}-quote`} label={t('blockFields.quote')}>
<textarea
id={`${id}-quote`}
rows={3}
value={text(data, 'text')}
onChange={event => set('text', event.target.value)}
className={textareaClass}
/>
</AdminField>
<AdminField id={`${id}-source`} label={t('blockFields.source')}>
<input
id={`${id}-source`}
type="text"
value={text(data, 'source')}
onChange={event => set('source', event.target.value)}
className={inputClass}
/>
</AdminField>
</div>
) : null}
{type === 'code' ? (
<div className="flex flex-col gap-5">
<AdminField id={`${id}-language`} label={t('blockFields.language')}>
<input
id={`${id}-language`}
type="text"
value={text(data, 'language')}
onChange={event => set('language', event.target.value)}
className={`${inputClass} font-mono`}
/>
</AdminField>
<AdminField id={`${id}-code`} label={t('blockFields.code')}>
<textarea
id={`${id}-code`}
rows={8}
spellCheck={false}
value={text(data, 'code')}
onChange={event => set('code', event.target.value)}
className={`${textareaClass} font-mono text-small`}
/>
</AdminField>
</div>
) : null}
{type === 'link' ? (
<div className="flex flex-col gap-5">
<div className="grid gap-5 md:grid-cols-2">
<AdminField id={`${id}-linkurl`} label={t('blockFields.url')}>
<input
id={`${id}-linkurl`}
type="url"
value={text(data, 'url')}
onChange={event => set('url', event.target.value)}
className={`${inputClass} font-mono`}
/>
</AdminField>
<AdminField id={`${id}-linklabel`} label={t('blockFields.label')}>
<input
id={`${id}-linklabel`}
type="text"
value={text(data, 'label')}
onChange={event => set('label', event.target.value)}
className={inputClass}
/>
</AdminField>
</div>
<AdminField id={`${id}-linkdescription`} label={t('blockFields.description')}>
<input
id={`${id}-linkdescription`}
type="text"
value={text(data, 'description')}
onChange={event => set('description', event.target.value)}
className={inputClass}
/>
</AdminField>
</div>
) : null}
{type === 'callout' ? (
<div className="flex flex-col gap-5">
<div className="grid gap-5 md:grid-cols-2">
<AdminField id={`${id}-callouttitle`} label={t('blockFields.label')}>
<input
id={`${id}-callouttitle`}
type="text"
value={text(data, 'title')}
onChange={event => set('title', event.target.value)}
className={inputClass}
/>
</AdminField>
<AdminField id={`${id}-tone`} label={t('blockFields.tone')}>
<Select
id={`${id}-tone`}
value={text(data, 'tone') === 'warning' ? 'warning' : 'info'}
onChange={event => set('tone', event.target.value)}
>
<option value="info">{t('blockFields.toneInfo')}</option>
<option value="warning">{t('blockFields.toneWarning')}</option>
</Select>
</AdminField>
</div>
<AdminField id={`${id}-callouttext`} label={t('blockFields.text')}>
<textarea
id={`${id}-callouttext`}
rows={3}
value={text(data, 'text')}
onChange={event => set('text', event.target.value)}
className={textareaClass}
/>
</AdminField>
</div>
) : null}
</li>
)
}