From de115936f1879f4db57ec0a5fc3bb38a6d4cdd24 Mon Sep 17 00:00:00 2001 From: Obrempong-1 Date: Tue, 20 Oct 2026 03:06:46 +0000 Subject: [PATCH 1/2] fixes --- app/admin/EntryForm.tsx | 130 +++++- app/admin/MarkdownEditor.tsx | 506 ----------------------- app/admin/RichEditor.tsx | 394 ++++++++++++++++++ app/contribute/ContributeForm.tsx | 4 +- app/globals.css | 100 +++++ lib/admin/collections.ts | 128 ++++++ lib/admin/markdown-blocks.ts | 57 --- lib/admin/tiptap-document.ts | 224 ++++++++++ lib/markdownComponents.tsx | 42 +- package-lock.json | 658 ++++++++++++++++++++++++++++++ package.json | 4 + 11 files changed, 1662 insertions(+), 585 deletions(-) delete mode 100644 app/admin/MarkdownEditor.tsx create mode 100644 app/admin/RichEditor.tsx delete mode 100644 lib/admin/markdown-blocks.ts create mode 100644 lib/admin/tiptap-document.ts diff --git a/app/admin/EntryForm.tsx b/app/admin/EntryForm.tsx index c83d1f7..f8cb79b 100644 --- a/app/admin/EntryForm.tsx +++ b/app/admin/EntryForm.tsx @@ -3,10 +3,10 @@ import { useState } from 'react'; import { useRouter } from 'next/navigation'; import { AlertCircle, Check, Lock, RotateCcw, Save, Trash2, X } from 'lucide-react'; import StarSpinner from '@/components/ui/StarSpinner'; -import type { CollectionSpec, FieldSpec } from '@/lib/admin/collections'; +import type { CollectionSpec, FieldSection, FieldSpec } from '@/lib/admin/collections'; import type { EntryDetail, FieldValue, RefOption } from '@/lib/admin/entries'; import ImagePicker from './ImagePicker'; -import MarkdownEditor from './MarkdownEditor'; +import RichEditor from './RichEditor'; import LocationPicker from './LocationPicker'; import { buildSlug } from '@/lib/admin/slug'; @@ -40,6 +40,28 @@ function blankValues(collection: CollectionSpec): Record { return values; } +function isEmpty(value: FieldValue): boolean { + if (value === null || value === undefined) return true; + if (typeof value === 'string') return value.trim() === ''; + if (Array.isArray(value)) return value.length === 0; + return false; +} + +function formSections(collection: CollectionSpec): FieldSection[] { + if (!collection.sections || collection.sections.length === 0) { + return [{ title: '', fields: collection.fields.map((field) => field.id) }]; + } + + const placed = new Set(collection.sections.flatMap((section) => section.fields)); + const leftover = collection.fields + .filter((field) => !placed.has(field.id)) + .map((field) => field.id); + + return leftover.length > 0 + ? [...collection.sections, { title: 'Everything else', fields: leftover }] + : collection.sections; +} + const EntryForm = ({ collection, detail, refOptions }: EntryFormProps) => { const [values, setValues] = useState(detail.values); const [urls, setUrls] = useState(detail.assetUrls); @@ -49,6 +71,7 @@ const EntryForm = ({ collection, detail, refOptions }: EntryFormProps) => { const [saved, setSaved] = useState(false); const [confirmingDelete, setConfirmingDelete] = useState(false); const [deleting, setDeleting] = useState(false); + const [missing, setMissing] = useState([]); const router = useRouter(); const slugSources = collection.slugFrom ?? (collection.titleField ? [collection.titleField] : []); @@ -67,6 +90,7 @@ const EntryForm = ({ collection, detail, refOptions }: EntryFormProps) => { const set = (id: string, value: FieldValue) => { setSaved(false); + setMissing((current) => (current.includes(id) ? current.filter((item) => item !== id) : current)); setValues((current) => { const next = { ...current, [id]: value }; if (!slugTouched && slugSources.includes(id)) { @@ -110,6 +134,26 @@ const EntryForm = ({ collection, detail, refOptions }: EntryFormProps) => { }; const handleSave = async () => { + const empties = collection.fields + .filter((field) => field.required && field.id !== 'slug' && isEmpty(values[field.id])) + .map((field) => field.id); + + if (empties.length > 0) { + const first = collection.fields.find((field) => field.id === empties[0]); + setMissing(empties); + setSaved(false); + setError( + empties.length === 1 + ? `Please fill in "${first?.label}" before saving.` + : `${empties.length} boxes still need filling in. The first is "${first?.label}".` + ); + document + .getElementById(`field-${empties[0]}`) + ?.scrollIntoView({ behavior: 'smooth', block: 'center' }); + return; + } + + setMissing([]); setSaving(true); setError(''); @@ -224,18 +268,20 @@ const EntryForm = ({ collection, detail, refOptions }: EntryFormProps) => { return (

- This one has to be edited in Contentful + This one can only be changed in Contentful

- It contains {locked}, which this simple editor cannot show without - breaking it. Everything else on this page still saves normally. + It has something in it ({locked}) that this editor cannot show, and + showing it wrongly would break it. The editor here handles headings, + bold, italics, lists, quotes, links, lines and photos. Your changes to + everything else on this page still save normally.

); } return ( - set(field.id, next)} rows={field.id === 'blogContent' ? 18 : 8} @@ -249,7 +295,7 @@ const EntryForm = ({ collection, detail, refOptions }: EntryFormProps) => { if (field.kind === 'markdown') { return ( - set(field.id, next)} rows={8} @@ -417,18 +463,68 @@ const EntryForm = ({ collection, detail, refOptions }: EntryFormProps) => { ); }; - return ( -
- {collection.fields.map((field) => ( -
- - {field.hint &&

{field.hint}

} + const byId = new Map(collection.fields.map((field) => [field.id, field])); + + const renderLabelled = (field: FieldSpec) => { + const notFilled = missing.includes(field.id); + + return ( +
+ + {field.hint &&

{field.hint}

} +
{renderField(field)}
- ))} + {notFilled && ( +

+ This one is needed before you can save. +

+ )} +
+ ); + }; + + return ( +
+ {formSections(collection).map((section, index) => { + const fields = section.fields + .map((id) => byId.get(id)) + .filter((field): field is FieldSpec => Boolean(field)); + if (fields.length === 0) return null; + + return ( +
+ {section.title && ( +
+

+ {section.title} +

+ {section.intro && ( +

{section.intro}

+ )} +
+ )} + {fields.map(renderLabelled)} +
+ ); + })}
diff --git a/app/admin/MarkdownEditor.tsx b/app/admin/MarkdownEditor.tsx deleted file mode 100644 index e060e7b..0000000 --- a/app/admin/MarkdownEditor.tsx +++ /dev/null @@ -1,506 +0,0 @@ -'use client'; -import { useEffect, useRef, useState } from 'react'; -import NextImage from 'next/image'; -import { - Bold, - Italic, - Heading2, - Heading3, - List, - ListOrdered, - Quote, - Link2, - Minus, - Eye, - Pencil, - ImagePlus, - ChevronUp, - ChevronDown, - X, -} from 'lucide-react'; -import { MAX_ORIGINAL_BYTES, resizeForHero } from '@/lib/admin/resize'; -import { ASSET_LINE, IMAGE_LINE, type Block, nextKey, parseBlocks, serializeBlocks } from '@/lib/admin/markdown-blocks'; - -const thumbUrl = (url: string) => (url.startsWith('//') ? `https:${url}` : url); - -interface MarkdownEditorProps { - value: string; - onChange: (value: string) => void; - rows?: number; - assetUrls: Record; - onAssetUploaded: (id: string, url: string) => void; - onError: (message: string) => void; - allowImages?: boolean; - uploadPath?: string; -} - -type Action = - | { kind: 'wrap'; before: string; after: string; placeholder: string } - | { kind: 'line'; prefix: string; placeholder: string } - | { kind: 'numbered'; placeholder: string } - | { kind: 'link' } - | { kind: 'divider' }; - -const TOOLS: { label: string; Icon: typeof Bold; action: Action }[] = [ - { label: 'Bold', Icon: Bold, action: { kind: 'wrap', before: '**', after: '**', placeholder: 'bold text' } }, - { label: 'Italic', Icon: Italic, action: { kind: 'wrap', before: '_', after: '_', placeholder: 'italic text' } }, - { label: 'Big heading', Icon: Heading2, action: { kind: 'line', prefix: '## ', placeholder: 'Heading' } }, - { label: 'Small heading', Icon: Heading3, action: { kind: 'line', prefix: '### ', placeholder: 'Heading' } }, - { label: 'Bullet list', Icon: List, action: { kind: 'line', prefix: '- ', placeholder: 'List item' } }, - { label: 'Numbered list', Icon: ListOrdered, action: { kind: 'numbered', placeholder: 'List item' } }, - { label: 'Quote', Icon: Quote, action: { kind: 'line', prefix: '> ', placeholder: 'Quote' } }, - { label: 'Link', Icon: Link2, action: { kind: 'link' } }, - { label: 'Divider', Icon: Minus, action: { kind: 'divider' } }, -]; - -const MarkdownEditor = ({ - value, - onChange, - rows = 12, - assetUrls, - onAssetUploaded, - onError, - allowImages = true, - uploadPath = '/api/admin/upload', -}: MarkdownEditorProps) => { - const [blocks, setBlocks] = useState(() => parseBlocks(value)); - const [preview, setPreview] = useState(false); - const [uploading, setUploading] = useState(false); - const [activeBlock, setActiveBlock] = useState(0); - - const emitted = useRef(value); - const areas = useRef(new Map()); - - useEffect(() => { - if (value === emitted.current) return; - emitted.current = value; - setBlocks(parseBlocks(value)); - }, [value]); - - const commit = (next: Block[]) => { - setBlocks(next); - const markdown = serializeBlocks(next); - emitted.current = markdown; - onChange(markdown); - }; - - const setText = (key: number, text: string) => { - commit(blocks.map((block) => (block.kind === 'text' && block.key === key ? { ...block, text } : block))); - }; - - const moveBlock = (index: number, direction: -1 | 1) => { - const target = index + direction; - if (target < 0 || target >= blocks.length) return; - const next = [...blocks]; - [next[index], next[target]] = [next[target], next[index]]; - commit(next); - }; - - const removeBlock = (index: number) => { - const next = blocks.filter((_, i) => i !== index); - commit(next.length > 0 ? next : [{ key: nextKey(), kind: 'text', text: '' }]); - }; - - const apply = (action: Action) => { - const block = blocks[activeBlock] ?? blocks.find((candidate) => candidate.kind === 'text'); - if (!block || block.kind !== 'text') return; - - const element = areas.current.get(block.key); - const text = block.text; - const start = element ? element.selectionStart : text.length; - const end = element ? element.selectionEnd : text.length; - const selected = text.slice(start, end); - - let replacement = ''; - let cursorStart = start; - let cursorEnd = end; - - if (action.kind === 'wrap') { - const inner = selected || action.placeholder; - replacement = `${action.before}${inner}${action.after}`; - cursorStart = start + action.before.length; - cursorEnd = cursorStart + inner.length; - } else if (action.kind === 'line' || action.kind === 'numbered') { - const lineStart = text.lastIndexOf('\n', start - 1) + 1; - const body = selected || action.placeholder; - const prefixed = body - .split('\n') - .map((line, index) => - action.kind === 'numbered' ? `${index + 1}. ${line}` : `${action.prefix}${line}` - ) - .join('\n'); - const needsBreak = lineStart === start && start > 0 && text[start - 1] !== '\n'; - replacement = `${needsBreak ? '\n' : ''}${prefixed}`; - cursorStart = start + replacement.length - body.length; - cursorEnd = cursorStart + body.length; - } else if (action.kind === 'link') { - const inner = selected || 'link text'; - replacement = `[${inner}](https://)`; - cursorStart = start + inner.length + 4; - cursorEnd = cursorStart + 8; - } else { - const needsBreak = start > 0 && text[start - 1] !== '\n'; - replacement = `${needsBreak ? '\n' : ''}\n---\n`; - cursorStart = start + replacement.length; - cursorEnd = cursorStart; - } - - setText(block.key, text.slice(0, start) + replacement + text.slice(end)); - - requestAnimationFrame(() => { - const area = areas.current.get(block.key); - if (!area) return; - area.focus(); - area.setSelectionRange(cursorStart, cursorEnd); - }); - }; - - const addImage = async (file: File | null | undefined) => { - if (!file) return; - onError(''); - - if (!file.type.startsWith('image/')) { - onError(`"${file.name}" is not a photo.`); - return; - } - if (file.size > MAX_ORIGINAL_BYTES) { - onError(`"${file.name}" is too big. Please pick a smaller photo.`); - return; - } - - setUploading(true); - try { - const resized = await resizeForHero(file); - const body = new FormData(); - body.append('file', resized); - - const res = await fetch(uploadPath, { method: 'POST', body }); - if (!res.ok) { - const payload = await res.json().catch(() => ({})); - throw new Error(payload.message ?? 'That photo could not be added.'); - } - - const asset = await res.json(); - onAssetUploaded(asset.id, asset.url); - - const imageBlock: Block = { key: nextKey(), kind: 'image', id: asset.id }; - const active = blocks[activeBlock]; - const next = [...blocks]; - - if (active && active.kind === 'text') { - const area = areas.current.get(active.key); - const caret = area ? area.selectionStart : active.text.length; - next.splice( - activeBlock, - 1, - { key: active.key, kind: 'text', text: active.text.slice(0, caret) }, - imageBlock, - { key: nextKey(), kind: 'text', text: active.text.slice(caret) } - ); - } else { - const at = Math.min(activeBlock + 1, blocks.length); - next.splice(at, 0, imageBlock); - if (!next.some((block, index) => index > at && block.kind === 'text')) { - next.push({ key: nextKey(), kind: 'text', text: '' }); - } - } - commit(next); - } catch (error) { - onError(error instanceof Error ? error.message : 'That photo could not be added.'); - } - setUploading(false); - }; - - return ( -
-
- {TOOLS.map(({ label, Icon, action }) => ( - - ))} - - {allowImages && ( - - )} - - {uploading && Adding photo...} - - -
- - {preview ? ( -
- -
- ) : ( -
- {value.includes('\\') && ( -

- A backslash before a character (like \\*) just means “show - this character as-is”. Leave them in place and they will not appear on the website. -

- )} - {blocks.map((block, index) => - block.kind === 'text' ? ( -