story-research-zapwall/components/MarkdownEditor.tsx
2026-01-15 00:53:53 +01:00

122 lines
3.4 KiB
TypeScript

import { useState } from 'react'
import { Button, Card, Textarea } from './ui'
import type { MediaRef } from '@/types/nostr'
import { uploadNip95Media } from '@/lib/nip95'
import { t } from '@/lib/i18n'
interface MarkdownEditorProps {
value: string
onChange: (value: string) => void
onMediaAdd?: (media: MediaRef) => void
onBannerChange?: (url: string) => void
}
export function MarkdownEditor(props: MarkdownEditorProps): React.ReactElement {
return <MarkdownEditorInner {...props} />
}
function MarkdownEditorInner({ value, onChange, onMediaAdd, onBannerChange }: MarkdownEditorProps): React.ReactElement {
const [uploading, setUploading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [preview, setPreview] = useState(false)
return (
<div className="space-y-3">
<MarkdownToolbar
preview={preview}
onTogglePreview={() => setPreview((p) => !p)}
onFileSelected={(file) => {
const handlers = {
setError,
setUploading,
...(onMediaAdd ? { onMediaAdd } : {}),
...(onBannerChange ? { onBannerChange } : {}),
}
void handleUpload(file, handlers)
}}
uploading={uploading}
error={error}
/>
{preview ? (
<MarkdownPreview value={value} />
) : (
<Textarea
value={value}
onChange={(e) => onChange(e.target.value)}
className="h-64"
/>
)}
</div>
)
}
function MarkdownToolbar({
preview,
onTogglePreview,
onFileSelected,
uploading,
error,
}: {
preview: boolean
onTogglePreview: () => void
onFileSelected: (file: File) => void
uploading: boolean
error: string | null
}): React.ReactElement {
return (
<div className="flex items-center gap-2">
<Button type="button" variant="secondary" size="small" onClick={onTogglePreview} className="px-3 py-1 text-sm rounded bg-gray-200">
{preview ? t('upload.edit') : t('upload.preview')}
</Button>
<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,.mp4,.webm,.mov,.qt"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) {
onFileSelected(file)
}
}}
/>
</label>
{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 (
<Card variant="default" className="prose max-w-none bg-white whitespace-pre-wrap">
{value}
</Card>
)
}
async function handleUpload(
file: File,
handlers: {
setError: (error: string | null) => void
setUploading: (uploading: boolean) => void
onMediaAdd?: (media: MediaRef) => void
onBannerChange?: (url: string) => void
}
): Promise<void> {
handlers.setError(null)
handlers.setUploading(true)
try {
const media = await uploadNip95Media(file)
handlers.onMediaAdd?.(media)
if (media.type === 'image') {
handlers.onBannerChange?.(media.url)
}
} catch (e) {
handlers.setError(e instanceof Error ? e.message : t('upload.error.failed'))
} finally {
handlers.setUploading(false)
}
}