7a594eeacf
- 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
351 lines
10 KiB
TypeScript
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>
|
|
)
|
|
}
|