story-research-zapwall/components/MarkdownEditorTwoColumns.tsx
2026-01-10 10:50:47 +01:00

380 lines
12 KiB
TypeScript

import { useState } from 'react'
import type { MediaRef, Page } from '@/types/nostr'
import { uploadNip95Media } from '@/lib/nip95'
import { t } from '@/lib/i18n'
interface MarkdownEditorTwoColumnsProps {
value: string
onChange: (value: string) => void
pages?: Page[]
onPagesChange?: (pages: Page[]) => void
onMediaAdd?: (media: MediaRef) => void
onBannerChange?: (url: string) => void
}
export function MarkdownEditorTwoColumns(props: MarkdownEditorTwoColumnsProps): React.ReactElement {
const [uploading, setUploading] = useState(false)
const [error, setError] = useState<string | null>(null)
const pages = props.pages ?? []
const pagesHandlers = createPagesHandlers({ pages, onPagesChange: props.onPagesChange })
const handleImageUpload = createImageUploadHandler({
setError,
setUploading,
onMediaAdd: props.onMediaAdd,
onBannerChange: props.onBannerChange,
onSetPageImageUrl: pagesHandlers.setPageContent,
})
return (
<div className="space-y-4">
<MarkdownToolbar
onFileSelected={(file) => {
void handleImageUpload({ file })
}}
uploading={uploading}
error={error}
{...(props.onPagesChange ? { onAddPage: pagesHandlers.addPage } : {})}
/>
<div className="grid grid-cols-2 gap-4">
<EditorColumn value={props.value} onChange={props.onChange} />
<PreviewColumn value={props.value} />
</div>
{props.onPagesChange && (
<PagesManager
pages={pages}
onPageContentChange={pagesHandlers.setPageContent}
onPageTypeChange={pagesHandlers.setPageType}
onRemovePage={pagesHandlers.removePage}
onImageUpload={async (file, pageNumber) => {
await handleImageUpload({ file, pageNumber })
}}
/>
)}
</div>
)
}
function MarkdownToolbar({
onFileSelected,
uploading,
error,
onAddPage,
}: {
onFileSelected: (file: File) => void
uploading: boolean
error: string | null
onAddPage?: (type: 'markdown' | 'image') => void
}): React.ReactElement {
return (
<div className="flex items-center gap-2 flex-wrap">
<ToolbarUploadButton onFileSelected={onFileSelected} />
<ToolbarAddPageButtons onAddPage={onAddPage} />
<ToolbarStatus uploading={uploading} error={error} />
</div>
)
}
function MarkdownPreview({ value }: { value: string }): React.ReactElement {
return (
<div className="prose max-w-none border rounded p-3 bg-white h-96 overflow-y-auto whitespace-pre-wrap">
{value || <span className="text-gray-400">{t('markdown.preview.empty')}</span>}
</div>
)
}
function PagesManager({
pages,
onPageContentChange,
onPageTypeChange,
onRemovePage,
onImageUpload,
}: {
pages: Page[]
onPageContentChange: (pageNumber: number, content: string) => void
onPageTypeChange: (pageNumber: number, type: 'markdown' | 'image') => void
onRemovePage: (pageNumber: number) => void
onImageUpload: (file: File, pageNumber: number) => Promise<void>
}): React.ReactElement {
if (pages.length === 0) {
return <div className="text-sm text-gray-500">{t('page.empty')}</div>
}
return (
<div className="space-y-4">
<h3 className="text-lg font-semibold">{t('page.title')}</h3>
{pages.map((page) => (
<PageEditor
key={page.number}
page={page}
onContentChange={(content) => onPageContentChange(page.number, content)}
onTypeChange={(type) => onPageTypeChange(page.number, type)}
onRemove={() => onRemovePage(page.number)}
onImageUpload={async (file) => {
await onImageUpload(file, page.number)
}}
/>
))}
</div>
)
}
function PageEditor({
page,
onContentChange,
onTypeChange,
onRemove,
onImageUpload,
}: {
page: Page
onContentChange: (content: string) => void
onTypeChange: (type: 'markdown' | 'image') => void
onRemove: () => void
onImageUpload: (file: File) => Promise<void>
}): React.ReactElement {
return (
<div className="border rounded-lg p-4 space-y-3">
<PageEditorHeader page={page} onTypeChange={onTypeChange} onRemove={onRemove} />
<PageEditorBody page={page} onContentChange={onContentChange} onImageUpload={onImageUpload} />
</div>
)
}
function EditorColumn(params: { value: string; onChange: (value: string) => void }): React.ReactElement {
return (
<div className="space-y-2">
<label className="block text-sm font-semibold text-gray-800">{t('markdown.editor')}</label>
<textarea
className="w-full border rounded p-3 h-96 font-mono text-sm"
value={params.value}
onChange={(e) => params.onChange(e.target.value)}
placeholder={t('markdown.placeholder')}
/>
</div>
)
}
function PreviewColumn(params: { value: string }): React.ReactElement {
return (
<div className="space-y-2">
<label className="block text-sm font-semibold text-gray-800">{t('markdown.preview')}</label>
<MarkdownPreview value={params.value} />
</div>
)
}
function ToolbarUploadButton(params: { onFileSelected: (file: File) => void }): React.ReactElement {
return (
<label className="px-3 py-1 text-sm rounded bg-blue-600 text-white cursor-pointer hover:bg-blue-700">
{t('markdown.upload.media')}
<input
type="file"
accept=".png,.jpg,.jpeg,.webp"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) {
params.onFileSelected(file)
}
}}
/>
</label>
)
}
function ToolbarAddPageButtons(params: { onAddPage: ((type: 'markdown' | 'image') => void) | undefined }): React.ReactElement | null {
if (!params.onAddPage) {
return null
}
return (
<>
<button
type="button"
className="px-3 py-1 text-sm rounded bg-green-600 text-white hover:bg-green-700"
onClick={() => params.onAddPage?.('markdown')}
>
{t('page.add.markdown')}
</button>
<button
type="button"
className="px-3 py-1 text-sm rounded bg-purple-600 text-white hover:bg-purple-700"
onClick={() => params.onAddPage?.('image')}
>
{t('page.add.image')}
</button>
</>
)
}
function ToolbarStatus(params: { uploading: boolean; error: string | null }): React.ReactElement | null {
if (!params.uploading && !params.error) {
return null
}
return (
<>
{params.uploading ? <span className="text-sm text-gray-500">{t('markdown.upload.uploading')}</span> : null}
{params.error ? <span className="text-sm text-red-600">{params.error}</span> : null}
</>
)
}
function PageEditorHeader(params: {
page: Page
onTypeChange: (type: 'markdown' | 'image') => void
onRemove: () => void
}): React.ReactElement {
return (
<div className="flex items-center justify-between">
<h4 className="font-semibold">
{t('page.number', { number: params.page.number })} - {t(`page.type.${params.page.type}`)}
</h4>
<div className="flex items-center gap-2">
<select
value={params.page.type}
onChange={(e) => params.onTypeChange(e.target.value as 'markdown' | 'image')}
className="text-sm border rounded px-2 py-1"
>
<option value="markdown">{t('page.type.markdown')}</option>
<option value="image">{t('page.type.image')}</option>
</select>
<button
type="button"
className="px-2 py-1 text-sm rounded bg-red-600 text-white hover:bg-red-700"
onClick={params.onRemove}
>
{t('page.remove')}
</button>
</div>
</div>
)
}
function PageEditorBody(params: {
page: Page
onContentChange: (content: string) => void
onImageUpload: (file: File) => Promise<void>
}): React.ReactElement {
if (params.page.type === 'markdown') {
return (
<textarea
className="w-full border rounded p-2 h-48 font-mono text-sm"
value={params.page.content}
onChange={(e) => params.onContentChange(e.target.value)}
placeholder={t('page.markdown.placeholder')}
/>
)
}
return <PageEditorImageBody page={params.page} onContentChange={params.onContentChange} onImageUpload={params.onImageUpload} />
}
function PageEditorImageBody(params: {
page: Page
onContentChange: (content: string) => void
onImageUpload: (file: File) => Promise<void>
}): React.ReactElement {
return (
<div className="space-y-2">
{params.page.content ? (
<div className="relative">
<img src={params.page.content} alt={t('page.image.alt', { number: params.page.number })} className="max-w-full h-auto rounded" />
<button
type="button"
className="absolute top-2 right-2 px-2 py-1 text-sm rounded bg-red-600 text-white hover:bg-red-700"
onClick={() => params.onContentChange('')}
>
{t('page.image.remove')}
</button>
</div>
) : (
<PageImageUploadButton onFileSelected={params.onImageUpload} />
)}
</div>
)
}
function PageImageUploadButton(params: { onFileSelected: (file: File) => Promise<void> }): React.ReactElement {
return (
<label className="block px-3 py-2 text-sm rounded bg-blue-600 text-white cursor-pointer hover:bg-blue-700 text-center">
{t('page.image.upload')}
<input
type="file"
accept=".png,.jpg,.jpeg,.webp"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) {
void params.onFileSelected(file)
}
}}
/>
</label>
)
}
function createPagesHandlers(params: {
pages: Page[]
onPagesChange: ((pages: Page[]) => void) | undefined
}): { addPage: (type: 'markdown' | 'image') => void; setPageContent: (pageNumber: number, content: string) => void; setPageType: (pageNumber: number, type: 'markdown' | 'image') => void; removePage: (pageNumber: number) => void } {
const update = (next: Page[]): void => {
params.onPagesChange?.(next)
}
return {
addPage: (type) => {
if (!params.onPagesChange) {
return
}
const newPage: Page = { number: params.pages.length + 1, type, content: '' }
update([...params.pages, newPage])
},
setPageContent: (pageNumber, content) => {
if (!params.onPagesChange) {
return
}
update(params.pages.map((p) => (p.number === pageNumber ? { ...p, content } : p)))
},
setPageType: (pageNumber, type) => {
if (!params.onPagesChange) {
return
}
update(params.pages.map((p) => (p.number === pageNumber ? { ...p, type, content: '' } : p)))
},
removePage: (pageNumber) => {
if (!params.onPagesChange) {
return
}
update(params.pages.filter((p) => p.number !== pageNumber).map((p, idx) => ({ ...p, number: idx + 1 })))
},
}
}
function createImageUploadHandler(params: {
setError: (value: string | null) => void
setUploading: (value: boolean) => void
onMediaAdd: ((media: MediaRef) => void) | undefined
onBannerChange: ((url: string) => void) | undefined
onSetPageImageUrl: (pageNumber: number, url: string) => void
}): (args: { file: File; pageNumber?: number }) => Promise<void> {
return async (args): Promise<void> => {
params.setError(null)
params.setUploading(true)
try {
const media = await uploadNip95Media(args.file)
if (media.type !== 'image') {
return
}
if (args.pageNumber !== undefined) {
params.onSetPageImageUrl(args.pageNumber, media.url)
} else {
params.onBannerChange?.(media.url)
}
params.onMediaAdd?.(media)
} catch (e) {
params.setError(e instanceof Error ? e.message : t('upload.error.failed'))
} finally {
params.setUploading(false)
}
}
}