295 lines
9.0 KiB
TypeScript
295 lines
9.0 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({
|
|
value,
|
|
onChange,
|
|
pages = [],
|
|
onPagesChange,
|
|
onMediaAdd,
|
|
onBannerChange,
|
|
}: MarkdownEditorTwoColumnsProps): React.ReactElement {
|
|
const [uploading, setUploading] = useState(false)
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
const handleAddPage = (type: 'markdown' | 'image'): void => {
|
|
if (!onPagesChange) {
|
|
return
|
|
}
|
|
const newPage: Page = {
|
|
number: pages.length + 1,
|
|
type,
|
|
content: type === 'markdown' ? '' : '',
|
|
}
|
|
onPagesChange([...pages, newPage])
|
|
}
|
|
|
|
const handlePageContentChange = (pageNumber: number, content: string): void => {
|
|
if (!onPagesChange) {
|
|
return
|
|
}
|
|
const updatedPages = pages.map((p) => (p.number === pageNumber ? { ...p, content } : p))
|
|
onPagesChange(updatedPages)
|
|
}
|
|
|
|
const handlePageTypeChange = (pageNumber: number, type: 'markdown' | 'image'): void => {
|
|
if (!onPagesChange) {
|
|
return
|
|
}
|
|
const updatedPages = pages.map((p) => (p.number === pageNumber ? { ...p, type, content: '' } : p))
|
|
onPagesChange(updatedPages)
|
|
}
|
|
|
|
const handleRemovePage = (pageNumber: number): void => {
|
|
if (!onPagesChange) {
|
|
return
|
|
}
|
|
const updatedPages = pages
|
|
.filter((p) => p.number !== pageNumber)
|
|
.map((p, index) => ({ ...p, number: index + 1 }))
|
|
onPagesChange(updatedPages)
|
|
}
|
|
|
|
const handleImageUpload = async (file: File, pageNumber?: number): Promise<void> => {
|
|
setError(null)
|
|
setUploading(true)
|
|
try {
|
|
const media = await uploadNip95Media(file)
|
|
if (media.type === 'image') {
|
|
if (pageNumber !== undefined && onPagesChange) {
|
|
handlePageContentChange(pageNumber, media.url)
|
|
} else {
|
|
onBannerChange?.(media.url)
|
|
}
|
|
onMediaAdd?.(media)
|
|
}
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : t('upload.error.failed'))
|
|
} finally {
|
|
setUploading(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<MarkdownToolbar
|
|
onFileSelected={(file) => {
|
|
void handleImageUpload(file)
|
|
}}
|
|
uploading={uploading}
|
|
error={error}
|
|
{...(onPagesChange ? { onAddPage: handleAddPage } : {})}
|
|
/>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<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={value}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
placeholder={t('markdown.placeholder')}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<label className="block text-sm font-semibold text-gray-800">{t('markdown.preview')}</label>
|
|
<MarkdownPreview value={value} />
|
|
</div>
|
|
</div>
|
|
{onPagesChange && (
|
|
<PagesManager
|
|
pages={pages}
|
|
onPageContentChange={handlePageContentChange}
|
|
onPageTypeChange={handlePageTypeChange}
|
|
onRemovePage={handleRemovePage}
|
|
onImageUpload={handleImageUpload}
|
|
/>
|
|
)}
|
|
</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">
|
|
<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) {
|
|
onFileSelected(file)
|
|
}
|
|
}}
|
|
/>
|
|
</label>
|
|
{onAddPage && (
|
|
<>
|
|
<button
|
|
type="button"
|
|
className="px-3 py-1 text-sm rounded bg-green-600 text-white hover:bg-green-700"
|
|
onClick={() => 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={() => onAddPage('image')}
|
|
>
|
|
{t('page.add.image')}
|
|
</button>
|
|
</>
|
|
)}
|
|
{uploading && <span className="text-sm text-gray-500">{t('markdown.upload.uploading')}</span>}
|
|
{error && <span className="text-sm text-red-600">{error}</span>}
|
|
</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">
|
|
<div className="flex items-center justify-between">
|
|
<h4 className="font-semibold">
|
|
{t('page.number', { number: page.number })} - {t(`page.type.${page.type}`)}
|
|
</h4>
|
|
<div className="flex items-center gap-2">
|
|
<select
|
|
value={page.type}
|
|
onChange={(e) => 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={onRemove}
|
|
>
|
|
{t('page.remove')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{page.type === 'markdown' ? (
|
|
<textarea
|
|
className="w-full border rounded p-2 h-48 font-mono text-sm"
|
|
value={page.content}
|
|
onChange={(e) => onContentChange(e.target.value)}
|
|
placeholder={t('page.markdown.placeholder')}
|
|
/>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{page.content ? (
|
|
<div className="relative">
|
|
<img src={page.content} alt={t('page.image.alt', { number: 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={() => onContentChange('')}
|
|
>
|
|
{t('page.image.remove')}
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<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 onImageUpload(file)
|
|
}
|
|
}}
|
|
/>
|
|
</label>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|