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 (
{t('presentation.success')}
{t('presentation.successMessage')}
{pubkey && (
)}
)
}
function ValidationError({ message }: { message: string | null }): React.ReactElement | null {
if (!message) {
return null
}
return (
)
}
function PresentationField({
draft,
onChange,
}: {
draft: AuthorPresentationDraft
onChange: (next: AuthorPresentationDraft) => void
}): React.ReactElement {
return (
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 (
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 (
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 (
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 (
onChange({ ...draft, pictureUrl: url })}
/>
)
}
const PresentationFields = ({
draft,
onChange,
}: {
draft: AuthorPresentationDraft
onChange: (next: AuthorPresentationDraft) => void
}): React.ReactElement => (
)
function DeleteButton({ onDelete, deleting }: { onDelete: () => void; deleting: boolean }): React.ReactElement {
return (
)
}
type PresentationFormProps = {
draft: AuthorPresentationDraft
setDraft: (next: AuthorPresentationDraft) => void
validationError: string | null
error: string | null
loading: boolean
handleSubmit: (e: FormEvent) => Promise
deleting: boolean
handleDelete: () => void
hasExistingPresentation: boolean
}
function PresentationForm(props: PresentationFormProps): React.ReactElement {
return (
)
}
function getSubmitLabel(params: { loading: boolean; deleting: boolean; hasExistingPresentation: boolean }): string {
if (params.loading || params.deleting) {
return t('publish.publishing')
}
return params.hasExistingPresentation ? t('presentation.update.button') : t('publish.button')
}
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) => Promise
deleting: boolean
handleDelete: () => Promise
success: boolean
} {
const { loading, error, success, publishPresentation, deletePresentation } = useAuthorPresentation(pubkey)
const router = useRouter()
const [draft, setDraft] = useState(() => buildInitialDraft(existingPresentation, existingAuthorName))
const [validationError, setValidationError] = useState(null)
const [deleting, setDeleting] = useState(false)
// Update authorName when profile changes
useEffect(() => {
syncAuthorNameIntoDraft({ existingAuthorName, draftAuthorName: draft.authorName, hasExistingPresentation: Boolean(existingPresentation), setDraft })
}, [existingAuthorName, existingPresentation, draft.authorName])
const handleSubmit = useCallback(
async (e: FormEvent) => {
e.preventDefault()
await submitPresentationDraft({ draft, setValidationError, publishPresentation })
},
[draft, publishPresentation]
)
const handleDelete = useCallback(async () => {
await deletePresentationFlow({
existingPresentationId: existingPresentation?.id,
deletePresentation,
router,
setDeleting,
setValidationError,
})
}, [existingPresentation, deletePresentation, router])
return { loading, error, success, draft, setDraft, validationError, handleSubmit, deleting, handleDelete }
}
function buildInitialDraft(existingPresentation: Article | null | undefined, existingAuthorName: string | undefined): 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: '',
}
}
function syncAuthorNameIntoDraft(params: {
existingAuthorName: string | undefined
draftAuthorName: string
hasExistingPresentation: boolean
setDraft: (updater: (prev: AuthorPresentationDraft) => AuthorPresentationDraft) => void
}): void {
if (!params.existingAuthorName || params.hasExistingPresentation || params.existingAuthorName === params.draftAuthorName) {
return
}
params.setDraft((prev) => ({ ...prev, authorName: params.existingAuthorName as string }))
}
async function submitPresentationDraft(params: {
draft: AuthorPresentationDraft
setValidationError: (value: string | null) => void
publishPresentation: (draft: AuthorPresentationDraft) => Promise
}): Promise {
const error = validatePresentationDraft(params.draft)
if (error) {
params.setValidationError(error)
return
}
params.setValidationError(null)
await params.publishPresentation(params.draft)
}
function validatePresentationDraft(draft: AuthorPresentationDraft): string | null {
const address = draft.mainnetAddress.trim()
if (!ADDRESS_PATTERN.test(address)) {
return t('presentation.validation.invalidAddress')
}
if (!draft.authorName.trim()) {
return t('presentation.validation.authorNameRequired')
}
return null
}
async function deletePresentationFlow(params: {
existingPresentationId: string | undefined
deletePresentation: (articleId: string) => Promise
router: ReturnType
setDeleting: (value: boolean) => void
setValidationError: (value: string | null) => void
}): Promise {
if (!params.existingPresentationId) {
return
}
const confirmed = await userConfirm(t('presentation.delete.confirm'))
if (!confirmed) {
return
}
params.setDeleting(true)
params.setValidationError(null)
try {
await params.deletePresentation(params.existingPresentationId)
await params.router.push('/')
} catch (e) {
params.setValidationError(e instanceof Error ? e.message : t('presentation.delete.error'))
} finally {
params.setDeleting(false)
}
}
function NoAccountActionButtons({
onGenerate,
onImport,
}: {
onGenerate: () => void
onImport: () => void
}): React.ReactElement {
return (
)
}
function NoAccountView(): React.ReactElement {
const [showImportModal, setShowImportModal] = useState(false)
const [showRecoveryStep, setShowRecoveryStep] = useState(false)
const [showUnlockModal, setShowUnlockModal] = useState(false)
const [recoveryPhrase, setRecoveryPhrase] = useState([])
const [npub, setNpub] = useState('')
const [generating, setGenerating] = useState(false)
const [error, setError] = useState(null)
const handleGenerate = (): Promise => generateNoAccount({ setGenerating, setError, setRecoveryPhrase, setNpub, setShowRecoveryStep })
const handleRecoveryContinue = (): void => transitionToUnlock({ setShowRecoveryStep, setShowUnlockModal })
const handleUnlockSuccess = (): void => resetNoAccountAfterUnlock({ setShowUnlockModal, setRecoveryPhrase, setNpub })
const handleImportSuccess = (): void => {
setShowImportModal(false)
setShowUnlockModal(true)
}
return (
{ void handleGenerate() }}
onImport={() => setShowImportModal(true)}
modals={
setShowImportModal(false)}
onImportSuccess={handleImportSuccess}
showRecoveryStep={showRecoveryStep}
recoveryPhrase={recoveryPhrase}
npub={npub}
onRecoveryContinue={handleRecoveryContinue}
showUnlockModal={showUnlockModal}
onUnlockSuccess={handleUnlockSuccess}
onCloseUnlock={() => setShowUnlockModal(false)}
/>
}
/>
)
}
async function generateNoAccount(params: {
setGenerating: (value: boolean) => void
setError: (value: string | null) => void
setRecoveryPhrase: (value: string[]) => void
setNpub: (value: string) => void
setShowRecoveryStep: (value: boolean) => void
}): Promise {
params.setGenerating(true)
params.setError(null)
try {
const { nostrAuthService } = await import('@/lib/nostrAuth')
const result = await nostrAuthService.createAccount()
params.setRecoveryPhrase(result.recoveryPhrase)
params.setNpub(result.npub)
params.setShowRecoveryStep(true)
} catch (e) {
params.setError(e instanceof Error ? e.message : t('account.create.error.failed'))
} finally {
params.setGenerating(false)
}
}
function transitionToUnlock(params: { setShowRecoveryStep: (value: boolean) => void; setShowUnlockModal: (value: boolean) => void }): void {
params.setShowRecoveryStep(false)
params.setShowUnlockModal(true)
}
function resetNoAccountAfterUnlock(params: {
setShowUnlockModal: (value: boolean) => void
setRecoveryPhrase: (value: string[]) => void
setNpub: (value: string) => void
}): void {
params.setShowUnlockModal(false)
params.setRecoveryPhrase([])
params.setNpub('')
}
function NoAccountCard(params: {
error: string | null
generating: boolean
onGenerate: () => void
onImport: () => void
modals: React.ReactElement
}): React.ReactElement {
return (
Créez un compte ou importez votre clé secrète pour commencer
{params.error &&
{params.error}
}
{params.generating &&
Génération du compte...
}
{params.modals}
)
}
function NoAccountModals(params: {
showImportModal: boolean
onImportSuccess: () => void
onCloseImport: () => void
showRecoveryStep: boolean
recoveryPhrase: string[]
npub: string
onRecoveryContinue: () => void
showUnlockModal: boolean
onUnlockSuccess: () => void
onCloseUnlock: () => void
}): React.ReactElement {
return (
<>
{params.showImportModal && }
{params.showRecoveryStep && }
{params.showUnlockModal && }
>
)
}
function AuthorPresentationFormView(props: {
pubkey: string | null
profile: { name?: string; pubkey: string } | null
}): React.ReactElement {
const { checkPresentationExists } = useAuthorPresentation(props.pubkey)
const presentation = useExistingPresentation({ pubkey: props.pubkey, checkPresentationExists })
const state = useAuthorPresentationState(props.pubkey, props.profile?.name, presentation.existingPresentation)
if (!props.pubkey) {
return
}
if (presentation.loadingPresentation) {
return
}
if (state.success) {
return
}
return (
{ void state.handleDelete() }}
hasExistingPresentation={presentation.existingPresentation !== null}
/>
)
}
function LoadingNotice(): React.ReactElement {
return (
)
}
function useExistingPresentation(params: {
pubkey: string | null
checkPresentationExists: () => Promise
}): { existingPresentation: Article | null; loadingPresentation: boolean } {
const [existingPresentation, setExistingPresentation] = useState(null)
const [loadingPresentation, setLoadingPresentation] = useState(true)
const { pubkey, checkPresentationExists } = params
useEffect(() => {
void loadExistingPresentation({ pubkey, checkPresentationExists, setExistingPresentation, setLoadingPresentation })
}, [pubkey, checkPresentationExists])
return { existingPresentation, loadingPresentation }
}
async function loadExistingPresentation(params: {
pubkey: string | null
checkPresentationExists: () => Promise
setExistingPresentation: (value: Article | null) => void
setLoadingPresentation: (value: boolean) => void
}): Promise {
if (!params.pubkey) {
params.setLoadingPresentation(false)
return
}
try {
params.setExistingPresentation(await params.checkPresentationExists())
} catch (e) {
console.error('Error loading presentation:', e)
} finally {
params.setLoadingPresentation(false)
}
}
function useAutoLoadPubkey(accountExists: boolean | null, pubkey: string | null, connect: () => Promise): 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
}