story-research-zapwall/components/AuthorPresentationEditor.tsx
2026-01-06 17:45:45 +01:00

504 lines
15 KiB
TypeScript

import { useState, useCallback, useEffect, type FormEvent } from 'react'
import { useRouter } from 'next/router'
import { useNostrAuth } from '@/hooks/useNostrAuth'
import { useAuthorPresentation } from '@/hooks/useAuthorPresentation'
import { ArticleField } from './ArticleField'
import { CreateAccountModal } from './CreateAccountModal'
import { RecoveryStep } from './CreateAccountModalSteps'
import { UnlockAccountModal } from './UnlockAccountModal'
import { ImageUploadField } from './ImageUploadField'
import { PresentationFormHeader } from './PresentationFormHeader'
import { extractPresentationData } from '@/lib/presentationParsing'
import type { Article } from '@/types/nostr'
import { t } from '@/lib/i18n'
import { userConfirm } from '@/lib/userConfirm'
interface AuthorPresentationDraft {
authorName: string
presentation: string
contentDescription: string
mainnetAddress: string
pictureUrl?: string
}
const ADDRESS_PATTERN = /^(1|3|bc1)[a-zA-Z0-9]{25,62}$/
function SuccessNotice({ pubkey }: { pubkey: string | null }): React.ReactElement {
return (
<div className="border border-neon-green/50 rounded-lg p-6 bg-neon-green/10">
<h3 className="text-lg font-semibold text-neon-green mb-2">{t('presentation.success')}</h3>
<p className="text-cyber-accent mb-4">
{t('presentation.successMessage')}
</p>
{pubkey && (
<div className="mt-4">
<a
href={`/author/${pubkey}`}
className="inline-block px-4 py-2 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan rounded-lg font-medium transition-all border border-neon-cyan/50 hover:shadow-glow-cyan"
>
{t('presentation.manageSeries')}
</a>
</div>
)}
</div>
)
}
function ValidationError({ message }: { message: string | null }): React.ReactElement | null {
if (!message) {
return null
}
return (
<div className="bg-red-500/10 border border-red-500/50 rounded-lg p-3">
<p className="text-sm text-red-400">{message}</p>
</div>
)
}
function PresentationField({
draft,
onChange,
}: {
draft: AuthorPresentationDraft
onChange: (next: AuthorPresentationDraft) => void
}): React.ReactElement {
return (
<ArticleField
id="presentation"
label={t('presentation.field.presentation')}
value={draft.presentation}
onChange={(value) => onChange({ ...draft, presentation: value as string })}
required
type="textarea"
rows={6}
placeholder={t('presentation.field.presentation.placeholder')}
helpText={t('presentation.field.presentation.help')}
/>
)
}
function ContentDescriptionField({
draft,
onChange,
}: {
draft: AuthorPresentationDraft
onChange: (next: AuthorPresentationDraft) => void
}): React.ReactElement {
return (
<ArticleField
id="contentDescription"
label={t('presentation.field.contentDescription')}
value={draft.contentDescription}
onChange={(value) => onChange({ ...draft, contentDescription: value as string })}
required
type="textarea"
rows={6}
placeholder={t('presentation.field.contentDescription.placeholder')}
helpText={t('presentation.field.contentDescription.help')}
/>
)
}
function MainnetAddressField({
draft,
onChange,
}: {
draft: AuthorPresentationDraft
onChange: (next: AuthorPresentationDraft) => void
}): React.ReactElement {
return (
<ArticleField
id="mainnetAddress"
label={t('presentation.field.mainnetAddress')}
value={draft.mainnetAddress}
onChange={(value) => onChange({ ...draft, mainnetAddress: value as string })}
required
type="text"
placeholder={t('presentation.field.mainnetAddress.placeholder')}
helpText={t('presentation.field.mainnetAddress.help')}
/>
)
}
function AuthorNameField({
draft,
onChange,
}: {
draft: AuthorPresentationDraft
onChange: (next: AuthorPresentationDraft) => void
}): React.ReactElement {
return (
<ArticleField
id="authorName"
label={t('presentation.field.authorName')}
value={draft.authorName}
onChange={(value) => onChange({ ...draft, authorName: value as string })}
required
type="text"
placeholder={t('presentation.field.authorName.placeholder')}
helpText={t('presentation.field.authorName.help')}
/>
)
}
function PictureField({
draft,
onChange,
}: {
draft: AuthorPresentationDraft
onChange: (next: AuthorPresentationDraft) => void
}): React.ReactElement {
return (
<ImageUploadField
id="picture"
value={draft.pictureUrl}
onChange={(url) => onChange({ ...draft, pictureUrl: url })}
/>
)
}
const PresentationFields = ({
draft,
onChange,
}: {
draft: AuthorPresentationDraft
onChange: (next: AuthorPresentationDraft) => void
}): React.ReactElement => (
<div className="space-y-4">
<AuthorNameField draft={draft} onChange={onChange} />
<PictureField draft={draft} onChange={onChange} />
<PresentationField draft={draft} onChange={onChange} />
<ContentDescriptionField draft={draft} onChange={onChange} />
<MainnetAddressField draft={draft} onChange={onChange} />
</div>
)
function DeleteButton({ onDelete, deleting }: { onDelete: () => void; deleting: boolean }): React.ReactElement {
return (
<button
type="button"
onClick={onDelete}
disabled={deleting}
className="px-4 py-2 bg-red-500/20 hover:bg-red-500/30 text-red-400 rounded-lg text-sm font-medium transition-all border border-red-500/50 hover:shadow-glow-red disabled:opacity-50"
>
{deleting ? t('presentation.delete.deleting') : t('presentation.delete.button')}
</button>
)
}
function PresentationForm({
draft,
setDraft,
validationError,
error,
loading,
handleSubmit,
deleting,
handleDelete,
hasExistingPresentation,
}: {
draft: AuthorPresentationDraft
setDraft: (next: AuthorPresentationDraft) => void
validationError: string | null
error: string | null
loading: boolean
handleSubmit: (e: FormEvent<HTMLFormElement>) => Promise<void>
deleting: boolean
handleDelete: () => void
hasExistingPresentation: boolean
}): React.ReactElement {
return (
<form
onSubmit={(e: FormEvent<HTMLFormElement>) => {
void handleSubmit(e)
}}
className="border border-neon-cyan/20 rounded-lg p-6 bg-cyber-dark space-y-4"
>
<PresentationFormHeader />
<PresentationFields draft={draft} onChange={setDraft} />
<ValidationError message={validationError ?? error} />
<div className="flex items-center gap-4">
<div className="flex-1">
<button
type="submit"
disabled={loading ?? deleting}
className="w-full px-4 py-2 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan rounded-lg font-medium transition-all border border-neon-cyan/50 hover:shadow-glow-cyan disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ?? deleting
? t('publish.publishing')
: hasExistingPresentation === true
? t('presentation.update.button')
: t('publish.button')}
</button>
</div>
{hasExistingPresentation && (
<DeleteButton onDelete={() => { void handleDelete() }} deleting={deleting} />
)}
</div>
</form>
)
}
function useAuthorPresentationState(pubkey: string | null, existingAuthorName?: string, existingPresentation?: Article | null): {
draft: AuthorPresentationDraft
setDraft: (next: AuthorPresentationDraft) => void
validationError: string | null
error: string | null
loading: boolean
handleSubmit: (e: FormEvent<HTMLFormElement>) => Promise<void>
deleting: boolean
handleDelete: () => Promise<void>
success: boolean
} {
const { loading, error, success, publishPresentation, deletePresentation } = useAuthorPresentation(pubkey)
const router = useRouter()
const [draft, setDraft] = useState<AuthorPresentationDraft>(() => {
if (existingPresentation) {
const { presentation, contentDescription } = extractPresentationData(existingPresentation)
const authorName = existingPresentation.title.replace(/^Présentation de /, '') ?? existingAuthorName ?? ''
return {
authorName,
presentation,
contentDescription,
mainnetAddress: existingPresentation.mainnetAddress ?? '',
...(existingPresentation.bannerUrl ? { pictureUrl: existingPresentation.bannerUrl } : {}),
}
}
return {
authorName: existingAuthorName ?? '',
presentation: '',
contentDescription: '',
mainnetAddress: '',
}
})
const [validationError, setValidationError] = useState<string | null>(null)
const [deleting, setDeleting] = useState(false)
// Update authorName when profile changes
useEffect(() => {
if (existingAuthorName && existingAuthorName !== draft.authorName && !existingPresentation) {
setDraft((prev) => ({ ...prev, authorName: existingAuthorName }))
}
}, [existingAuthorName, existingPresentation])
const handleSubmit = useCallback(
async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault()
const address = draft.mainnetAddress.trim()
if (!ADDRESS_PATTERN.test(address)) {
setValidationError(t('presentation.validation.invalidAddress'))
return
}
if (!draft.authorName.trim()) {
setValidationError(t('presentation.validation.authorNameRequired'))
return
}
setValidationError(null)
await publishPresentation(draft)
},
[draft, publishPresentation]
)
const handleDelete = useCallback(async () => {
if (!existingPresentation?.id) {
return
}
if (!userConfirm(t('presentation.delete.confirm'))) {
return
}
setDeleting(true)
setValidationError(null)
try {
await deletePresentation(existingPresentation.id)
await router.push('/')
} catch (e) {
setValidationError(e instanceof Error ? e.message : t('presentation.delete.error'))
} finally {
setDeleting(false)
}
}, [existingPresentation, deletePresentation, router])
return { loading, error, success, draft, setDraft, validationError, handleSubmit, deleting, handleDelete }
}
function NoAccountActionButtons({
onGenerate,
onImport,
}: {
onGenerate: () => void
onImport: () => void
}): React.ReactElement {
return (
<div className="flex flex-col gap-3 w-full max-w-xs">
<button
onClick={onGenerate}
className="px-6 py-2 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan rounded-lg font-medium transition-all border border-neon-cyan/50 hover:shadow-glow-cyan"
>
{t('account.create.generateButton')}
</button>
<button
onClick={onImport}
className="px-6 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg font-medium transition-colors"
>
{t('account.create.importButton')}
</button>
</div>
)
}
function NoAccountView(): React.ReactElement {
const [showImportModal, setShowImportModal] = useState(false)
const [showRecoveryStep, setShowRecoveryStep] = useState(false)
const [showUnlockModal, setShowUnlockModal] = useState(false)
const [recoveryPhrase, setRecoveryPhrase] = useState<string[]>([])
const [npub, setNpub] = useState('')
const [generating, setGenerating] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleGenerate = async (): Promise<void> => {
setGenerating(true)
setError(null)
try {
const { nostrAuthService } = await import('@/lib/nostrAuth')
const result = await nostrAuthService.createAccount()
setRecoveryPhrase(result.recoveryPhrase)
setNpub(result.npub)
setShowRecoveryStep(true)
} catch (e) {
setError(e instanceof Error ? e.message : t('account.create.error.failed'))
} finally {
setGenerating(false)
}
}
const handleRecoveryContinue = (): void => {
setShowRecoveryStep(false)
setShowUnlockModal(true)
}
const handleUnlockSuccess = (): void => {
setShowUnlockModal(false)
setRecoveryPhrase([])
setNpub('')
}
return (
<div className="border border-neon-cyan/20 rounded-lg p-6 bg-cyber-dark/50">
<div className="flex flex-col items-center gap-4">
<p className="text-center text-cyber-accent mb-2">
Créez un compte ou importez votre clé secrète pour commencer
</p>
{error && <p className="text-sm text-red-400">{error}</p>}
<NoAccountActionButtons
onGenerate={() => { void handleGenerate() }}
onImport={() => setShowImportModal(true)}
/>
{generating && (
<p className="text-cyber-accent text-sm">Génération du compte...</p>
)}
{showImportModal && (
<CreateAccountModal
onSuccess={() => {
setShowImportModal(false)
setShowUnlockModal(true)
}}
onClose={() => setShowImportModal(false)}
initialStep="import"
/>
)}
{showRecoveryStep && (
<RecoveryStep
recoveryPhrase={recoveryPhrase}
npub={npub}
onContinue={handleRecoveryContinue}
/>
)}
{showUnlockModal && (
<UnlockAccountModal
onSuccess={handleUnlockSuccess}
onClose={() => setShowUnlockModal(false)}
/>
)}
</div>
</div>
)
}
function AuthorPresentationFormView({
pubkey,
profile,
}: {
pubkey: string | null
profile: { name?: string; pubkey: string } | null
}): React.ReactElement {
const { checkPresentationExists } = useAuthorPresentation(pubkey)
const [existingPresentation, setExistingPresentation] = useState<Article | null>(null)
const [loadingPresentation, setLoadingPresentation] = useState(true)
useEffect(() => {
const load = async (): Promise<void> => {
if (!pubkey) {
setLoadingPresentation(false)
return
}
try {
const presentation = await checkPresentationExists()
setExistingPresentation(presentation)
} catch (e) {
console.error('Error loading presentation:', e)
} finally {
setLoadingPresentation(false)
}
}
void load()
}, [pubkey, checkPresentationExists])
const state = useAuthorPresentationState(pubkey, profile?.name, existingPresentation)
if (!pubkey) {
return <NoAccountView />
}
if (loadingPresentation) {
return (
<div className="text-center py-12">
<p className="text-cyber-accent/70">{t('common.loading')}</p>
</div>
)
}
if (state.success) {
return <SuccessNotice pubkey={pubkey} />
}
return (
<PresentationForm
draft={state.draft}
setDraft={state.setDraft}
validationError={state.validationError}
error={state.error}
loading={state.loading}
handleSubmit={state.handleSubmit}
deleting={state.deleting}
handleDelete={() => { void state.handleDelete() }}
hasExistingPresentation={existingPresentation !== null && existingPresentation !== undefined}
/>
)
}
function useAutoLoadPubkey(accountExists: boolean | null, pubkey: string | null, connect: () => Promise<void>): void {
useEffect(() => {
if (accountExists === true && !pubkey) {
void connect()
}
}, [accountExists, pubkey, connect])
}
export function AuthorPresentationEditor(): React.ReactElement {
const { pubkey, profile, accountExists, connect } = useNostrAuth()
useAutoLoadPubkey(accountExists, pubkey ?? null, connect)
return <AuthorPresentationFormView pubkey={pubkey ?? null} profile={profile} />
}