- Fix unused function warnings by renaming to _unusedExtractTags - Fix type errors in nostrTagSystem.ts for includes() calls - Fix type errors in reviews.ts for filter kinds array - Fix ArrayBuffer type errors in articleEncryption.ts - Remove unused imports (DecryptionKey, decryptArticleContent, extractTagsFromEvent) - All TypeScript checks now pass without disabling any controls
251 lines
7.0 KiB
TypeScript
251 lines
7.0 KiB
TypeScript
import React from 'react'
|
|
import type { ArticleDraft } from '@/lib/articlePublisher'
|
|
import type { ArticleCategory } from '@/types/nostr'
|
|
import { ArticleField } from './ArticleField'
|
|
import { ArticleFormButtons } from './ArticleFormButtons'
|
|
import { CategorySelect } from './CategorySelect'
|
|
import { MarkdownEditor } from './MarkdownEditor'
|
|
import type { MediaRef } from '@/types/nostr'
|
|
|
|
interface ArticleEditorFormProps {
|
|
draft: ArticleDraft
|
|
onDraftChange: (draft: ArticleDraft) => void
|
|
onSubmit: (e: React.FormEvent) => void
|
|
loading: boolean
|
|
error: string | null
|
|
onCancel?: () => void
|
|
seriesOptions?: { id: string; title: string }[] | undefined
|
|
onSelectSeries?: ((seriesId: string | undefined) => void) | undefined
|
|
}
|
|
|
|
function CategoryField({
|
|
value,
|
|
onChange,
|
|
}: {
|
|
value: ArticleDraft['category']
|
|
onChange: (value: import('@/types/nostr').ArticleCategory | undefined) => void
|
|
}) {
|
|
return (
|
|
<CategorySelect
|
|
id="category"
|
|
label="Catégorie"
|
|
{...(value ? { value } : {})}
|
|
onChange={onChange}
|
|
required
|
|
helpText="Sélectionnez la catégorie de votre article"
|
|
/>
|
|
)
|
|
}
|
|
|
|
function ErrorAlert({ error }: { error: string | null }) {
|
|
if (!error) {
|
|
return null
|
|
}
|
|
return (
|
|
<div className="bg-red-50 border border-red-200 rounded-lg p-3">
|
|
<p className="text-sm text-red-800">{error}</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function buildCategoryChangeHandler(
|
|
draft: ArticleDraft,
|
|
onDraftChange: (draft: ArticleDraft) => void
|
|
): (value: ArticleCategory | undefined) => void {
|
|
return (value) => {
|
|
if (value === 'science-fiction' || value === 'scientific-research' || value === undefined) {
|
|
const nextDraft: ArticleDraft = { ...draft }
|
|
if (value) {
|
|
nextDraft.category = value
|
|
} else {
|
|
delete (nextDraft as { category?: ArticleDraft['category'] }).category
|
|
}
|
|
onDraftChange(nextDraft)
|
|
}
|
|
}
|
|
}
|
|
|
|
const ArticleFieldsLeft = ({
|
|
draft,
|
|
onDraftChange,
|
|
seriesOptions,
|
|
onSelectSeries,
|
|
}: {
|
|
draft: ArticleDraft
|
|
onDraftChange: (draft: ArticleDraft) => void
|
|
seriesOptions?: { id: string; title: string }[] | undefined
|
|
onSelectSeries?: ((seriesId: string | undefined) => void) | undefined
|
|
}) => (
|
|
<div className="space-y-4">
|
|
<CategoryField
|
|
value={draft.category}
|
|
onChange={buildCategoryChangeHandler(draft, onDraftChange)}
|
|
/>
|
|
{seriesOptions && (
|
|
<SeriesSelect
|
|
draft={draft}
|
|
onDraftChange={onDraftChange}
|
|
seriesOptions={seriesOptions}
|
|
onSelectSeries={onSelectSeries}
|
|
/>
|
|
)}
|
|
<ArticleTitleField draft={draft} onDraftChange={onDraftChange} />
|
|
<ArticlePreviewField draft={draft} onDraftChange={onDraftChange} />
|
|
</div>
|
|
)
|
|
|
|
function ArticleTitleField({ draft, onDraftChange }: { draft: ArticleDraft; onDraftChange: (draft: ArticleDraft) => void }) {
|
|
return (
|
|
<ArticleField
|
|
id="title"
|
|
label="Titre"
|
|
value={draft.title}
|
|
onChange={(value) => onDraftChange({ ...draft, title: value as string })}
|
|
required
|
|
placeholder="Entrez le titre de l'article"
|
|
/>
|
|
)
|
|
}
|
|
|
|
function ArticlePreviewField({
|
|
draft,
|
|
onDraftChange,
|
|
}: {
|
|
draft: ArticleDraft
|
|
onDraftChange: (draft: ArticleDraft) => void
|
|
}) {
|
|
return (
|
|
<ArticleField
|
|
id="preview"
|
|
label="Aperçu (Public)"
|
|
value={draft.preview}
|
|
onChange={(value) => onDraftChange({ ...draft, preview: value as string })}
|
|
required
|
|
type="textarea"
|
|
rows={4}
|
|
placeholder="Cet aperçu sera visible par tous gratuitement"
|
|
helpText="Ce contenu sera visible par tous"
|
|
/>
|
|
)
|
|
}
|
|
|
|
function SeriesSelect({
|
|
draft,
|
|
onDraftChange,
|
|
seriesOptions,
|
|
onSelectSeries,
|
|
}: {
|
|
draft: ArticleDraft
|
|
onDraftChange: (draft: ArticleDraft) => void
|
|
seriesOptions: { id: string; title: string }[]
|
|
onSelectSeries?: ((seriesId: string | undefined) => void) | undefined
|
|
}) {
|
|
const handleChange = buildSeriesChangeHandler(draft, onDraftChange, onSelectSeries)
|
|
|
|
return (
|
|
<div>
|
|
<label htmlFor="series" className="block text-sm font-medium text-gray-700">
|
|
Série
|
|
</label>
|
|
<select
|
|
id="series"
|
|
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm"
|
|
value={draft.seriesId ?? ''}
|
|
onChange={handleChange}
|
|
>
|
|
<option value="">Aucune (article indépendant)</option>
|
|
{seriesOptions.map((s) => (
|
|
<option key={s.id} value={s.id}>
|
|
{s.title}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function buildSeriesChangeHandler(
|
|
draft: ArticleDraft,
|
|
onDraftChange: (draft: ArticleDraft) => void,
|
|
onSelectSeries?: ((seriesId: string | undefined) => void) | undefined
|
|
) {
|
|
return (e: React.ChangeEvent<HTMLSelectElement>) => {
|
|
const value = e.target.value || undefined
|
|
const nextDraft = { ...draft }
|
|
if (value) {
|
|
nextDraft.seriesId = value
|
|
} else {
|
|
delete (nextDraft as { seriesId?: string }).seriesId
|
|
}
|
|
onDraftChange(nextDraft)
|
|
onSelectSeries?.(value)
|
|
}
|
|
}
|
|
|
|
const ArticleFieldsRight = ({
|
|
draft,
|
|
onDraftChange,
|
|
}: {
|
|
draft: ArticleDraft
|
|
onDraftChange: (draft: ArticleDraft) => void
|
|
}) => (
|
|
<div className="space-y-4">
|
|
<div className="space-y-2">
|
|
<div className="text-sm font-semibold text-gray-800">Contenu complet (Privé) — Markdown + preview</div>
|
|
<MarkdownEditor
|
|
value={draft.content}
|
|
onChange={(value) => onDraftChange({ ...draft, content: value })}
|
|
onMediaAdd={(media: MediaRef) => {
|
|
const nextMedia = [...(draft.media ?? []), media]
|
|
onDraftChange({ ...draft, media: nextMedia })
|
|
}}
|
|
onBannerChange={(url: string) => {
|
|
onDraftChange({ ...draft, bannerUrl: url })
|
|
}}
|
|
/>
|
|
<p className="text-xs text-gray-500">
|
|
Les médias sont uploadés via NIP-95 (images ≤5Mo, vidéos ≤45Mo) et insérés comme URL. Le contenu reste chiffré
|
|
pour les acheteurs.
|
|
</p>
|
|
</div>
|
|
<ArticleField
|
|
id="zapAmount"
|
|
label="Sponsoring (sats)"
|
|
value={draft.zapAmount}
|
|
onChange={(value) => onDraftChange({ ...draft, zapAmount: value as number })}
|
|
required
|
|
type="number"
|
|
min={1}
|
|
helpText="Montant de sponsoring en satoshis pour débloquer le contenu complet (zap uniquement)"
|
|
/>
|
|
</div>
|
|
)
|
|
|
|
export function ArticleEditorForm({
|
|
draft,
|
|
onDraftChange,
|
|
onSubmit,
|
|
loading,
|
|
error,
|
|
onCancel,
|
|
seriesOptions,
|
|
onSelectSeries,
|
|
}: ArticleEditorFormProps) {
|
|
return (
|
|
<form onSubmit={onSubmit} className="border rounded-lg p-6 bg-white space-y-4">
|
|
<h2 className="text-2xl font-bold mb-4">Publier une nouvelle publication</h2>
|
|
<div className="space-y-4">
|
|
<ArticleFieldsLeft
|
|
draft={draft}
|
|
onDraftChange={onDraftChange}
|
|
{...(seriesOptions ? { seriesOptions } : {})}
|
|
{...(onSelectSeries ? { onSelectSeries } : {})}
|
|
/>
|
|
<ArticleFieldsRight draft={draft} onDraftChange={onDraftChange} />
|
|
</div>
|
|
<ErrorAlert error={error} />
|
|
<ArticleFormButtons loading={loading} {...(onCancel ? { onCancel } : {})} />
|
|
</form>
|
|
)
|
|
}
|