diff --git a/components/AuthorPresentationEditor.tsx b/components/AuthorPresentationEditor.tsx index 5213f2d..6ce2885 100644 --- a/components/AuthorPresentationEditor.tsx +++ b/components/AuthorPresentationEditor.tsx @@ -1,593 +1 @@ -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 && ( -
- - {t('presentation.manageSeries')} - -
- )} -
- ) -} - -function ValidationError({ message }: { message: string | null }): React.ReactElement | null { - if (!message) { - return null - } - return ( -
-

{message}

-
- ) -} - -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 ( -
) => { - void props.handleSubmit(e) - }} - className="border border-neon-cyan/20 rounded-lg p-6 bg-cyber-dark space-y-4" - > - - - -
-
- -
- {props.hasExistingPresentation && ( - { void props.handleDelete() }} deleting={props.deleting} /> - )} -
- - ) -} - -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 ( -
-

{t('common.loading')}

-
- ) -} - -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 -} +export { AuthorPresentationEditor } from './authorPresentationEditor/AuthorPresentationEditor' diff --git a/components/KeyManagementManager.tsx b/components/KeyManagementManager.tsx index e2b6c6a..ed62ef0 100644 --- a/components/KeyManagementManager.tsx +++ b/components/KeyManagementManager.tsx @@ -1,559 +1 @@ -import { useState, useEffect } from 'react' -import { nostrAuthService } from '@/lib/nostrAuth' -import { keyManagementService } from '@/lib/keyManagement' -import { nip19 } from 'nostr-tools' -import { t } from '@/lib/i18n' -import { SyncProgressBar } from './SyncProgressBar' - -interface PublicKeys { - publicKey: string - npub: string -} - -export function KeyManagementManager(): React.ReactElement { - const [publicKeys, setPublicKeys] = useState(null) - const [accountExists, setAccountExists] = useState(false) - const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) - const [importKey, setImportKey] = useState('') - const [importing, setImporting] = useState(false) - const [showImportForm, setShowImportForm] = useState(false) - const [showReplaceWarning, setShowReplaceWarning] = useState(false) - const [recoveryPhrase, setRecoveryPhrase] = useState(null) - const [newNpub, setNewNpub] = useState(null) - const [copiedNpub, setCopiedNpub] = useState(false) - const [copiedPublicKey, setCopiedPublicKey] = useState(false) - const [copiedRecoveryPhrase, setCopiedRecoveryPhrase] = useState(false) - - useEffect(() => { - void loadKeys() - }, []) - - async function loadKeys(): Promise { - try { - setLoading(true) - setError(null) - const exists = await nostrAuthService.accountExists() - setAccountExists(exists) - - if (exists) { - const keys = await keyManagementService.getPublicKeys() - if (keys) { - setPublicKeys(keys) - } - } - } catch (e) { - const errorMessage = e instanceof Error ? e.message : t('settings.keyManagement.loading') - setError(errorMessage) - console.error('Error loading keys:', e) - } finally { - setLoading(false) - } - } - - function extractKeyFromUrl(url: string): string | null { - try { - // Try to parse as URL - const urlObj = new URL(url) - // Check if it's a nostr:// URL with nsec - if (urlObj.protocol === 'nostr:' || urlObj.protocol === 'nostr://') { - const path = urlObj.pathname ?? urlObj.href.replace(/^nostr:?\/\//, '') - if (path.startsWith('nsec')) { - return path - } - } - // Check if URL contains nsec - const nsecMatch = url.match(/nsec1[a-z0-9]+/i) - if (nsecMatch) { - return nsecMatch[0] - } - return null - } catch { - return extractKeyFromText(url) - } - } - - function extractKeyFromText(text: string): string { - const nsec = extractNsec(text) - if (nsec) { - return nsec - } - return text.trim() - } - - function extractNsec(text: string): string | null { - const nsecMatch = text.match(/nsec1[a-z0-9]+/i) - return nsecMatch?.[0] ?? null - } - - function isValidPrivateKeyFormat(key: string): boolean { - try { - const decoded = nip19.decode(key) - if (decoded.type !== 'nsec') { - return false - } - return typeof decoded.data === 'string' || decoded.data instanceof Uint8Array - } catch { - return /^[0-9a-f]{64}$/i.test(key) - } - } - - async function handleImport(): Promise { - if (!importKey.trim()) { - setError(t('settings.keyManagement.import.error.required')) - return - } - - // Extract key from URL or text - const extractedKey = extractKeyFromUrl(importKey.trim()) - if (!extractedKey) { - setError(t('settings.keyManagement.import.error.invalid')) - return - } - - // Validate key format - if (!isValidPrivateKeyFormat(extractedKey)) { - setError(t('settings.keyManagement.import.error.invalid')) - return - } - - // If account exists, show warning - if (accountExists) { - setShowReplaceWarning(true) - return - } - - await performImport(extractedKey) - } - - async function performImport(key: string): Promise { - try { - setImporting(true) - setError(null) - setShowReplaceWarning(false) - - // If account exists, delete it first - if (accountExists) { - await nostrAuthService.deleteAccount() - } - - // Create new account with imported key - const result = await nostrAuthService.createAccount(key) - setRecoveryPhrase(result.recoveryPhrase) - setNewNpub(result.npub) - setImportKey('') - setShowImportForm(false) - await loadKeys() - - // Sync user content via Service Worker - if (result.publicKey) { - const { swClient } = await import('@/lib/swClient') - const isReady = await swClient.isReady() - if (isReady) { - void swClient.startUserSync(result.publicKey) - } - } - } catch (e) { - const errorMessage = e instanceof Error ? e.message : t('settings.keyManagement.import.error.failed') - setError(errorMessage) - console.error('Error importing key:', e) - } finally { - setImporting(false) - } - } - - async function handleCopyRecoveryPhrase(): Promise { - if (!recoveryPhrase) { - return - } - try { - await navigator.clipboard.writeText(recoveryPhrase.join(' ')) - setCopiedRecoveryPhrase(true) - setTimeout(() => { - setCopiedRecoveryPhrase(false) - }, 2000) - } catch (e) { - console.error('Error copying recovery phrase:', e) - } - } - - async function handleCopyNpub(): Promise { - if (!publicKeys?.npub) { - return - } - try { - await navigator.clipboard.writeText(publicKeys.npub) - setCopiedNpub(true) - setTimeout(() => { - setCopiedNpub(false) - }, 2000) - } catch (e) { - console.error('Error copying npub:', e) - } - } - - async function handleCopyPublicKey(): Promise { - if (!publicKeys?.publicKey) { - return - } - try { - await navigator.clipboard.writeText(publicKeys.publicKey) - setCopiedPublicKey(true) - setTimeout(() => { - setCopiedPublicKey(false) - }, 2000) - } catch (e) { - console.error('Error copying public key:', e) - } - } - - if (loading) { - return ( -
-

{t('settings.keyManagement.loading')}

-
- ) - } - - return ( -
-
-

{t('settings.keyManagement.title')}

- - - - - - {/* Sync Progress Bar - Always show if connected, even if publicKeys not loaded yet */} - - - - - { - setShowImportForm(true) - setError(null) - }} - /> - - { - setImportKey(value) - setError(null) - }} - onCancel={() => { - setShowImportForm(false) - setImportKey('') - setError(null) - }} - onImport={() => { - void handleImport() - }} - onDismissReplaceWarning={() => { - setShowReplaceWarning(false) - }} - onConfirmReplace={() => { - void performImport(extractKeyFromUrl(importKey.trim()) ?? importKey.trim()) - }} - /> - - {/* Recovery Phrase Display (after import) */} - { - setRecoveryPhrase(null) - setNewNpub(null) - void loadKeys() - }} - /> -
-
- ) -} - -function KeyManagementErrorBanner(params: { error: string | null }): React.ReactElement | null { - if (!params.error) { - return null - } - return ( -
-

{params.error}

-
- ) -} - -function KeyManagementPublicKeysPanel(params: { - publicKeys: PublicKeys | null - copiedNpub: boolean - copiedPublicKey: boolean - onCopyNpub: () => Promise - onCopyPublicKey: () => Promise -}): React.ReactElement | null { - if (!params.publicKeys) { - return null - } - return ( -
- - -
- ) -} - -function KeyManagementKeyCard(params: { - label: string - value: string - copied: boolean - onCopy: () => Promise -}): React.ReactElement { - return ( -
-
-

{params.label}

- -
-

{params.value}

-
- ) -} - -function KeyManagementNoAccountBanner(params: { - publicKeys: PublicKeys | null - accountExists: boolean -}): React.ReactElement | null { - if (params.publicKeys || params.accountExists) { - return null - } - return ( -
-

{t('settings.keyManagement.noAccount.title')}

-

{t('settings.keyManagement.noAccount.description')}

-
- ) -} - -function KeyManagementImportButton(params: { - accountExists: boolean - showImportForm: boolean - onClick: () => void -}): React.ReactElement | null { - if (params.showImportForm) { - return null - } - return ( - - ) -} - -function KeyManagementImportForm(params: { - accountExists: boolean - showImportForm: boolean - showReplaceWarning: boolean - importing: boolean - importKey: string - onChangeImportKey: (value: string) => void - onCancel: () => void - onImport: () => void - onDismissReplaceWarning: () => void - onConfirmReplace: () => void -}): React.ReactElement | null { - if (!params.showImportForm) { - return null - } - return ( -
-
-

{t('settings.keyManagement.import.warning.title')}

-

- {params.accountExists && ( -

- )} -

- -
- -