diff --git a/components/authorPage/AuthorPage.tsx b/components/authorPage/AuthorPage.tsx new file mode 100644 index 0000000..f250ebd --- /dev/null +++ b/components/authorPage/AuthorPage.tsx @@ -0,0 +1,49 @@ +import Head from 'next/head' +import { useRouter } from 'next/router' +import { Footer } from '@/components/Footer' +import { PageHeader } from '@/components/PageHeader' +import { t } from '@/lib/i18n' +import { AuthorPageContent } from './AuthorPageContent' +import { resolveAuthorHashIdOrPubkey } from './resolveAuthorHashIdOrPubkey' +import { useAuthorData } from './useAuthorData' + +export function AuthorPage(): React.ReactElement { + const router = useRouter() + const { pubkey } = router.query + const hashIdOrPubkey = resolveAuthorHashIdOrPubkey(pubkey) + + const { presentation, series, totalSponsoring, loading, error, reload } = useAuthorData(hashIdOrPubkey ?? '') + const onSeriesCreated = (): void => { + void reload() + } + + if (!hashIdOrPubkey) { + return
+ } + + const actualAuthorPubkey = presentation?.pubkey ?? '' + return ( + <> + + {t('author.title')} - {t('home.title')} + + + +
+ +
+ +
+
+
+ + ) +} diff --git a/components/authorPage/AuthorPageContent.tsx b/components/authorPage/AuthorPageContent.tsx new file mode 100644 index 0000000..fb4f718 --- /dev/null +++ b/components/authorPage/AuthorPageContent.tsx @@ -0,0 +1,49 @@ +import { t } from '@/lib/i18n' +import type { AuthorPresentationArticle, Series } from '@/types/nostr' +import { AuthorPageHeader } from './AuthorPageHeader' +import { SponsoringSummary } from './SponsoringSummary' +import { SeriesList } from './SeriesList' + +type AuthorPageContentProps = { + presentation: AuthorPresentationArticle | null + series: Series[] + totalSponsoring: number + authorPubkey: string + loading: boolean + error: string | null + onSeriesCreated: () => void +} + +export function AuthorPageContent({ + presentation, + series, + totalSponsoring, + authorPubkey, + loading, + error, + onSeriesCreated, +}: AuthorPageContentProps): React.ReactElement { + if (loading) { + return

{t('common.loading')}

+ } + + if (error) { + return

{error}

+ } + + if (!presentation) { + return ( +
+

{t('author.notFound')}

+
+ ) + } + + return ( + <> + + + + + ) +} diff --git a/components/authorPage/AuthorPageHeader.tsx b/components/authorPage/AuthorPageHeader.tsx new file mode 100644 index 0000000..4131647 --- /dev/null +++ b/components/authorPage/AuthorPageHeader.tsx @@ -0,0 +1,62 @@ +import Image from 'next/image' +import { t } from '@/lib/i18n' +import type { AuthorPresentationArticle } from '@/types/nostr' + +export function AuthorPageHeader(params: { presentation: AuthorPresentationArticle | null }): React.ReactElement | null { + if (!params.presentation) { + return null + } + + const authorName = getAuthorNameFromPresentationTitle(params.presentation.title) + return ( +
+
+ +
+ + + +
+
+
+ ) +} + +function getAuthorNameFromPresentationTitle(title: string): string { + const trimmed = title.replace(/^Présentation de /, '').trim() + return trimmed.length > 0 ? trimmed : title +} + +function AuthorProfileImage(params: { bannerUrl: string | undefined }): React.ReactElement | null { + if (!params.bannerUrl) { + return null + } + return ( +
+ {t('author.profilePicture')} +
+ ) +} + +function AuthorHeaderTitle(params: { authorName: string }): React.ReactElement { + return ( +
+

{params.authorName}

+

{t('author.profileNote')}

+
+ ) +} + +function AuthorPresentationSection(params: { title: string; text: string | undefined }): React.ReactElement | null { + if (!params.text) { + return null + } + return ( +
+

{params.title}

+
+

{params.text}

+
+
+ ) +} diff --git a/components/authorPage/SeriesList.tsx b/components/authorPage/SeriesList.tsx new file mode 100644 index 0000000..edda1ba --- /dev/null +++ b/components/authorPage/SeriesList.tsx @@ -0,0 +1,63 @@ +import Link from 'next/link' +import { useState } from 'react' +import { CreateSeriesModal } from '@/components/CreateSeriesModal' +import { SeriesCard } from '@/components/SeriesCard' +import { useNostrAuth } from '@/hooks/useNostrAuth' +import { t } from '@/lib/i18n' +import type { Series } from '@/types/nostr' + +export function SeriesList(params: { series: Series[]; authorPubkey: string; onSeriesCreated: () => void }): React.ReactElement { + const { pubkey, isUnlocked } = useNostrAuth() + const [showCreateModal, setShowCreateModal] = useState(false) + const isAuthor = pubkey === params.authorPubkey && isUnlocked + + return ( +
+ setShowCreateModal(true)} /> + + setShowCreateModal(false)} + onSuccess={params.onSeriesCreated} + authorPubkey={params.authorPubkey} + /> +
+ ) +} + +function SeriesListHeader(params: { isAuthor: boolean; onCreate: () => void }): React.ReactElement { + return ( +
+

{t('series.title')}

+ {params.isAuthor && ( + + )} +
+ ) +} + +function SeriesGrid(params: { series: Series[] }): React.ReactElement { + if (params.series.length === 0) { + return ( +
+

{t('series.empty')}

+
+ ) + } + + return ( +
+ {params.series.map((s) => ( + + {}} /> + + ))} +
+ ) +} diff --git a/components/authorPage/SponsoringSummary.tsx b/components/authorPage/SponsoringSummary.tsx new file mode 100644 index 0000000..07dd2d3 --- /dev/null +++ b/components/authorPage/SponsoringSummary.tsx @@ -0,0 +1,71 @@ +import { useState } from 'react' +import { SponsoringForm } from '@/components/SponsoringForm' +import { t } from '@/lib/i18n' +import type { AuthorPresentationArticle } from '@/types/nostr' + +type SponsoringSummaryProps = { + totalSponsoring: number + author: AuthorPresentationArticle | null + onSponsor: () => void +} + +export function SponsoringSummary({ totalSponsoring, author, onSponsor }: SponsoringSummaryProps): React.ReactElement { + const totalBTC = totalSponsoring / 100_000_000 + const [showForm, setShowForm] = useState(false) + + return ( +
+ setShowForm(true)} /> + + setShowForm(false)} onSponsor={onSponsor} /> +
+ ) +} + +function SponsoringSummaryHeader(params: { showSponsorButton: boolean; onSponsorClick: () => void }): React.ReactElement { + return ( +
+

{t('author.sponsoring')}

+ {params.showSponsorButton && ( + + )} +
+ ) +} + +function SponsoringTotals(params: { totalBTC: number; totalSats: number }): React.ReactElement { + return ( +
+

{t('author.sponsoring.total', { amount: params.totalBTC.toFixed(6) })}

+

{t('author.sponsoring.sats', { amount: params.totalSats.toLocaleString() })}

+
+ ) +} + +function SponsoringFormPanel(params: { + show: boolean + author: AuthorPresentationArticle | null + onClose: () => void + onSponsor: () => void +}): React.ReactElement | null { + if (!params.show || !params.author) { + return null + } + return ( +
+ { + params.onClose() + params.onSponsor() + }} + onCancel={params.onClose} + /> +
+ ) +} diff --git a/components/authorPage/resolveAuthorHashIdOrPubkey.ts b/components/authorPage/resolveAuthorHashIdOrPubkey.ts new file mode 100644 index 0000000..e3dd9e0 --- /dev/null +++ b/components/authorPage/resolveAuthorHashIdOrPubkey.ts @@ -0,0 +1,19 @@ +import { parseObjectUrl } from '@/lib/urlGenerator' + +export function resolveAuthorHashIdOrPubkey(pubkeyParam: string | string[] | undefined): string | null { + if (typeof pubkeyParam !== 'string') { + return null + } + + const urlMatch = pubkeyParam.match(/^([a-f0-9]+)_(\d+)_(\d+)$/i) + if (urlMatch?.[1]) { + return urlMatch[1] + } + + const parsedUrl = parseObjectUrl(`https://zapwall.fr/author/${pubkeyParam}`) + if (parsedUrl.objectType === 'author' && parsedUrl.idHash) { + return parsedUrl.idHash + } + + return pubkeyParam +} diff --git a/components/authorPage/useAuthorData.ts b/components/authorPage/useAuthorData.ts new file mode 100644 index 0000000..1e3d07f --- /dev/null +++ b/components/authorPage/useAuthorData.ts @@ -0,0 +1,70 @@ +import { useCallback, useEffect, useState } from 'react' +import { fetchAuthorByHashId } from '@/lib/authorQueries' +import { getSeriesByAuthor } from '@/lib/seriesQueries' +import { getAuthorSponsoring } from '@/lib/sponsoring' +import { nostrService } from '@/lib/nostr' +import type { AuthorPresentationArticle, Series } from '@/types/nostr' + +async function loadAuthorData(hashId: string): Promise<{ + pres: AuthorPresentationArticle | null + seriesList: Series[] + sponsoring: number +}> { + const pool = nostrService.getPool() + if (!pool) { + throw new Error('Pool not initialized') + } + + const pres = await fetchAuthorByHashId(pool, hashId) + if (!pres) { + return { pres: null, seriesList: [], sponsoring: 0 } + } + + const [seriesList, sponsoring] = await Promise.all([ + getSeriesByAuthor(pres.pubkey), + getAuthorSponsoring(pres.pubkey), + ]) + + return { pres, seriesList, sponsoring } +} + +export function useAuthorData(hashIdOrPubkey: string): { + presentation: AuthorPresentationArticle | null + series: Series[] + totalSponsoring: number + loading: boolean + error: string | null + reload: () => Promise +} { + const [presentation, setPresentation] = useState(null) + const [series, setSeries] = useState([]) + const [totalSponsoring, setTotalSponsoring] = useState(0) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + const reload = useCallback(async (): Promise => { + if (!hashIdOrPubkey) { + return + } + + setLoading(true) + setError(null) + + try { + const { pres, seriesList, sponsoring } = await loadAuthorData(hashIdOrPubkey) + setPresentation(pres) + setSeries(seriesList) + setTotalSponsoring(sponsoring) + } catch (e) { + setError(e instanceof Error ? e.message : 'Erreur lors du chargement') + } finally { + setLoading(false) + } + }, [hashIdOrPubkey]) + + useEffect(() => { + void reload() + }, [hashIdOrPubkey, reload]) + + return { presentation, series, totalSponsoring, loading, error, reload } +} diff --git a/components/relayManager/RelayCard.tsx b/components/relayManager/RelayCard.tsx index 9f0048d..7229e43 100644 --- a/components/relayManager/RelayCard.tsx +++ b/components/relayManager/RelayCard.tsx @@ -99,7 +99,7 @@ function UrlEditor(params: { relay: RelayConfig; onStop: () => void; onUpdate: ( ) } -function LastSync(params: { lastSyncDate: string }): React.ReactElement { +function LastSync(params: { lastSyncDate: number }): React.ReactElement { return (
{t('settings.relay.list.lastSync')}: {new Date(params.lastSyncDate).toLocaleString()} diff --git a/fixKnowledge/2026-01-13-lint-max-lines-author-page.md b/fixKnowledge/2026-01-13-lint-max-lines-author-page.md new file mode 100644 index 0000000..0a952a6 --- /dev/null +++ b/fixKnowledge/2026-01-13-lint-max-lines-author-page.md @@ -0,0 +1,44 @@ +## Contexte + +ESLint signalait un `max-lines` sur `pages/author/[pubkey].tsx` (fichier trop long). Le reste du projet passait. + +## Root cause + +La page regroupait dans un seul fichier : +- logique de chargement (hook + requêtes) +- composants de présentation (header, sponsoring, liste de séries) +- parsing de l’URL (compatibilité ancien format vs hash) + +Ce regroupement dépassait la limite `max-lines` (250). + +## Correctif + +Refactor purement structurel (sans changement fonctionnel) : +- extraction de la logique et des sous-composants dans `components/authorPage/*` +- remplacement de `pages/author/[pubkey].tsx` par un simple re-export du composant page + +## Pages / fichiers affectés + +- `pages/author/[pubkey].tsx` +- `components/authorPage/AuthorPage.tsx` +- `components/authorPage/AuthorPageContent.tsx` +- `components/authorPage/AuthorPageHeader.tsx` +- `components/authorPage/SponsoringSummary.tsx` +- `components/authorPage/SeriesList.tsx` +- `components/authorPage/useAuthorData.ts` +- `components/authorPage/resolveAuthorHashIdOrPubkey.ts` +- `pages/api/nip95-upload.ts` (shim de compatibilité pour la validation Next/TypeScript) +- `lib/metadataExtractor/reviewTip.ts` (validation stricte des champs requis) +- `lib/paymentNotes/sponsoring.ts` (exactOptionalPropertyTypes) +- `components/relayManager/RelayCard.tsx` (typage lastSyncDate) +- `lib/keyManagementTwoLevel/crypto.ts` (BufferSource) + +## Vérification + +- `npm run lint` (doit sortir en succès, sans erreur `max-lines` sur `pages/author/[pubkey].tsx`) +- `npm run type-check` (doit sortir en succès) + +## Risques / régressions possibles + +- erreurs d’import/chemins suite au déplacement de code +- oubli d’un export explicite ou d’un type de retour (règles TypeScript/ESLint strictes) diff --git a/lib/keyManagementTwoLevel/crypto.ts b/lib/keyManagementTwoLevel/crypto.ts index 30b6036..55073d4 100644 --- a/lib/keyManagementTwoLevel/crypto.ts +++ b/lib/keyManagementTwoLevel/crypto.ts @@ -36,7 +36,8 @@ export async function importKEK(keyBytes: Uint8Array): Promise { export async function encryptWithAesGcm(params: { key: CryptoKey; plaintext: Uint8Array }): Promise<{ iv: Uint8Array; ciphertext: Uint8Array }> { const iv = globalThis.crypto.getRandomValues(new Uint8Array(12)) - const encrypted = await globalThis.crypto.subtle.encrypt({ name: 'AES-GCM', iv }, params.key, params.plaintext) + const plaintext = new Uint8Array(params.plaintext) + const encrypted = await globalThis.crypto.subtle.encrypt({ name: 'AES-GCM', iv }, params.key, plaintext) return { iv, ciphertext: new Uint8Array(encrypted) } } diff --git a/lib/markdownRenderer.tsx b/lib/markdownRenderer.tsx index 7d3f942..645def5 100644 --- a/lib/markdownRenderer.tsx +++ b/lib/markdownRenderer.tsx @@ -5,6 +5,7 @@ interface RenderState { currentList: Array<{ key: string; line: string }> inCodeBlock: boolean codeBlockContent: string[] + keyCounts: Map } export function renderMarkdown(markdown: string): React.ReactElement[] { @@ -14,6 +15,7 @@ export function renderMarkdown(markdown: string): React.ReactElement[] { currentList: [], inCodeBlock: false, codeBlockContent: [], + keyCounts: new Map(), } lines.forEach((line, index) => { @@ -38,36 +40,36 @@ function processLine(line: string, index: number, state: RenderState, elements: closeListIfNeeded(line, index, state, elements) - if (renderHeading(line, index, elements)) { + if (renderHeading(line, index, state, elements)) { return } if (renderListLine(line, index, state)) { return } - if (renderLinkLine(line, index, elements)) { + if (renderLinkLine(line, index, state, elements)) { return } - if (renderBoldAndCodeLine(line, index, elements)) { + if (renderBoldAndCodeLine(line, index, state, elements)) { return } - renderParagraphOrBreak(line, index, elements) + renderParagraphOrBreak(line, index, state, elements) } -function renderHeading(line: string, index: number, elements: React.ReactElement[]): boolean { +function renderHeading(line: string, index: number, state: RenderState, elements: React.ReactElement[]): boolean { if (line.startsWith('# ')) { - elements.push(

{line.substring(2)}

) + elements.push(

{line.substring(2)}

) return true } if (line.startsWith('## ')) { - elements.push(

{line.substring(3)}

) + elements.push(

{line.substring(3)}

) return true } if (line.startsWith('### ')) { - elements.push(

{line.substring(4)}

) + elements.push(

{line.substring(4)}

) return true } if (line.startsWith('#### ')) { - elements.push(

{line.substring(5)}

) + elements.push(

{line.substring(5)}

) return true } return false @@ -75,37 +77,37 @@ function renderHeading(line: string, index: number, elements: React.ReactElement function renderListLine(line: string, index: number, state: RenderState): boolean { if (line.startsWith('- ') || line.startsWith('* ')) { - state.currentList.push({ key: `li-${index}-${line}`, line }) + state.currentList.push({ key: nextElementKey(state, 'li', line), line }) return true } return false } -function renderLinkLine(line: string, index: number, elements: React.ReactElement[]): boolean { +function renderLinkLine(line: string, index: number, state: RenderState, elements: React.ReactElement[]): boolean { if (line.includes('[') && line.includes('](')) { - renderLink(line, index, elements) + renderLink(line, index, state, elements) return true } return false } -function renderBoldAndCodeLine(line: string, index: number, elements: React.ReactElement[]): boolean { +function renderBoldAndCodeLine(line: string, index: number, state: RenderState, elements: React.ReactElement[]): boolean { if (line.includes('**') || line.includes('`')) { - renderBoldAndCode(line, index, elements) + renderBoldAndCode(line, index, state, elements) return true } return false } -function renderParagraphOrBreak(line: string, index: number, elements: React.ReactElement[]): void { +function renderParagraphOrBreak(line: string, index: number, state: RenderState, elements: React.ReactElement[]): void { if (line.trim() !== '') { - elements.push(

{line}

) + elements.push(

{line}

) return } if (elements.length > 0) { const last = elements[elements.length - 1] as { type?: unknown } if (last?.type !== 'br') { - elements.push(
) + elements.push(
) } } } @@ -119,7 +121,7 @@ function handleCodeBlock( const nextState = state if (state.inCodeBlock) { elements.push( -
+      
         {state.codeBlockContent.join('\n')}
       
) @@ -138,8 +140,9 @@ function closeListIfNeeded( ): void { const nextState = state if (state.currentList.length > 0 && !line.startsWith('- ') && !line.startsWith('* ') && line.trim() !== '') { + const keySeed = `${state.currentList[0]?.key ?? ''}-${state.currentList.length}-${index}` elements.push( -
    +
      {state.currentList.map((item) => (
    • {item.line.substring(2).trim()}
    • ))} @@ -170,7 +173,7 @@ function createLinkElement( ) } -function renderLink(line: string, index: number, elements: React.ReactElement[]): void { +function renderLink(line: string, index: number, state: RenderState, elements: React.ReactElement[]): void { const linkRegex = /\[([^\]]+)\]\(([^)]+)\)/g let lastIndex = 0 const parts: (string | React.ReactElement)[] = [] @@ -194,10 +197,10 @@ function renderLink(line: string, index: number, elements: React.ReactElement[]) parts.push(line.substring(lastIndex)) } - elements.push(

      {parts}

      ) + elements.push(

      {parts}

      ) } -function renderBoldAndCode(line: string, index: number, elements: React.ReactElement[]): void { +function renderBoldAndCode(line: string, index: number, state: RenderState, elements: React.ReactElement[]): void { const parts: (string | React.ReactElement)[] = [] const codeRegex = /`([^`]+)`/g let codeMatch @@ -221,7 +224,7 @@ function renderBoldAndCode(line: string, index: number, elements: React.ReactEle processBold(remaining, parts) } - elements.push(

      {parts.length > 0 ? parts : line}

      ) + elements.push(

      {parts.length > 0 ? parts : line}

      ) } function processBold(text: string, parts: (string | React.ReactElement)[]): void { @@ -237,3 +240,15 @@ function processBold(text: string, parts: (string | React.ReactElement)[]): void } }) } + +function nextElementKey(state: RenderState, prefix: string, raw: string): string { + const base = buildKeyBase(prefix, raw) + const nextCount = (state.keyCounts.get(base) ?? 0) + 1 + state.keyCounts.set(base, nextCount) + return `${base}-${nextCount}` +} + +function buildKeyBase(prefix: string, raw: string): string { + const normalized = raw.trim().replace(/\s+/g, ' ').slice(0, 80) + return `${prefix}-${normalized}` +} diff --git a/lib/metadataExtractor/reviewTip.ts b/lib/metadataExtractor/reviewTip.ts index 2aefe0a..383a2ab 100644 --- a/lib/metadataExtractor/reviewTip.ts +++ b/lib/metadataExtractor/reviewTip.ts @@ -26,15 +26,49 @@ function readReviewTipFields(event: Event): Omit): values is Record { - return Object.values(values).every((value) => typeof value === 'string' && value.length > 0) +function hasRequiredReviewTipFields(values: { + payerPubkey: string | undefined + reviewerPubkey: string | undefined + authorPubkey: string | undefined + articleId: string | undefined + reviewId: string | undefined + paymentHash: string | undefined +}): values is { + payerPubkey: string + reviewerPubkey: string + authorPubkey: string + articleId: string + reviewId: string + paymentHash: string +} { + const requiredKeys: ReadonlyArray = [ + 'payerPubkey', + 'reviewerPubkey', + 'authorPubkey', + 'articleId', + 'reviewId', + 'paymentHash', + ] + return requiredKeys.every((key) => isNonEmptyString(values[key])) +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.length > 0 } diff --git a/lib/paymentNotes/sponsoring.ts b/lib/paymentNotes/sponsoring.ts index 1c401f6..9bde195 100644 --- a/lib/paymentNotes/sponsoring.ts +++ b/lib/paymentNotes/sponsoring.ts @@ -47,7 +47,7 @@ async function buildSponsoringNotePayload(params: { const hashId = await generateSponsoringHashId(sponsoringData) const id = buildObjectId(hashId, 0, 0) const tags = buildSponsoringNoteTags({ ...params, hashId }) - tags.push(['json', buildSponsoringPaymentJson({ id, hashId, sponsoringData, text: params.text, transactionId: params.transactionId })]) + tags.push(['json', buildSponsoringPaymentJson(buildSponsoringPaymentJsonInput({ id, hashId, sponsoringData, text: params.text, transactionId: params.transactionId }))]) const parsedSponsoring = buildParsedSponsoring({ ...params, id, hashId }) return { hashId, eventTemplate: buildSponsoringEventTemplate({ tags, content: buildSponsoringNoteContent(params) }), parsedSponsoring } } @@ -160,3 +160,19 @@ function buildSponsoringPaymentJson(params: { ...(params.transactionId ? { transactionId: params.transactionId } : {}), }) } + +function buildSponsoringPaymentJsonInput(params: { + id: string + hashId: string + sponsoringData: Record + text: string | undefined + transactionId: string | undefined +}): { id: string; hashId: string; sponsoringData: Record; text?: string; transactionId?: string } { + return { + id: params.id, + hashId: params.hashId, + sponsoringData: params.sponsoringData, + ...(params.text ? { text: params.text } : {}), + ...(params.transactionId ? { transactionId: params.transactionId } : {}), + } +} diff --git a/pages/api/nip95-upload.ts b/pages/api/nip95-upload.ts new file mode 100644 index 0000000..f0c9971 --- /dev/null +++ b/pages/api/nip95-upload.ts @@ -0,0 +1 @@ +export { config, default } from './nip95-upload/index' diff --git a/pages/author/[pubkey].tsx b/pages/author/[pubkey].tsx index 0de9edf..3ceadac 100644 --- a/pages/author/[pubkey].tsx +++ b/pages/author/[pubkey].tsx @@ -1,355 +1 @@ -import { useRouter } from 'next/router' -import Head from 'next/head' -import { useEffect, useState, useCallback } from 'react' -import { fetchAuthorByHashId } from '@/lib/authorQueries' -import { getSeriesByAuthor } from '@/lib/seriesQueries' -import { getAuthorSponsoring } from '@/lib/sponsoring' -import { nostrService } from '@/lib/nostr' -import type { AuthorPresentationArticle, Series } from '@/types/nostr' -import { PageHeader } from '@/components/PageHeader' -import { Footer } from '@/components/Footer' -import { t } from '@/lib/i18n' -import Link from 'next/link' -import { SeriesCard } from '@/components/SeriesCard' -import Image from 'next/image' -import { CreateSeriesModal } from '@/components/CreateSeriesModal' -import { useNostrAuth } from '@/hooks/useNostrAuth' -import { parseObjectUrl } from '@/lib/urlGenerator' -import { SponsoringForm } from '@/components/SponsoringForm' - -function AuthorPageHeader({ presentation }: { presentation: AuthorPresentationArticle | null }): React.ReactElement | null { - if (!presentation) { - return null - } - const authorName = getAuthorNameFromPresentationTitle(presentation.title) - return ( -
      -
      - -
      - - - -
      -
      -
      - ) -} - -function getAuthorNameFromPresentationTitle(title: string): string { - const trimmed = title.replace(/^Présentation de /, '').trim() - return trimmed.length > 0 ? trimmed : title -} - -function AuthorProfileImage(params: { bannerUrl?: string }): React.ReactElement | null { - if (!params.bannerUrl) { - return null - } - return ( -
      - {t('author.profilePicture')} -
      - ) -} - -function AuthorHeaderTitle(params: { authorName: string }): React.ReactElement { - return ( -
      -

      {params.authorName}

      -

      {t('author.profileNote')}

      -
      - ) -} - -function AuthorPresentationSection(params: { title: string; text: string | undefined }): React.ReactElement | null { - if (!params.text) { - return null - } - return ( -
      -

      {params.title}

      -
      -

      {params.text}

      -
      -
      - ) -} - -type SponsoringSummaryProps = { - totalSponsoring: number - author: AuthorPresentationArticle | null - onSponsor: () => void -} - -function SponsoringSummary({ totalSponsoring, author, onSponsor }: SponsoringSummaryProps): React.ReactElement { - const totalBTC = totalSponsoring / 100_000_000 - const [showForm, setShowForm] = useState(false) - - return ( -
      - setShowForm(true)} /> - - setShowForm(false)} onSponsor={onSponsor} /> -
      - ) -} - -function SponsoringSummaryHeader(params: { showSponsorButton: boolean; onSponsorClick: () => void }): React.ReactElement { - return ( -
      -

      {t('author.sponsoring')}

      - {params.showSponsorButton && ( - - )} -
      - ) -} - -function SponsoringTotals(params: { totalBTC: number; totalSats: number }): React.ReactElement { - return ( -
      -

      {t('author.sponsoring.total', { amount: params.totalBTC.toFixed(6) })}

      -

      {t('author.sponsoring.sats', { amount: params.totalSats.toLocaleString() })}

      -
      - ) -} - -function SponsoringFormPanel(params: { - show: boolean - author: AuthorPresentationArticle | null - onClose: () => void - onSponsor: () => void -}): React.ReactElement | null { - if (!params.show || !params.author) { - return null - } - return ( -
      - { - params.onClose() - params.onSponsor() - }} - onCancel={params.onClose} - /> -
      - ) -} - -function SeriesList({ series, authorPubkey, onSeriesCreated }: { series: Series[]; authorPubkey: string; onSeriesCreated: () => void }): React.ReactElement { - const { pubkey, isUnlocked } = useNostrAuth() - const [showCreateModal, setShowCreateModal] = useState(false) - const isAuthor = pubkey === authorPubkey && isUnlocked - - return ( -
      -
      -

      {t('series.title')}

      - {isAuthor && ( - - )} -
      - {series.length === 0 ? ( -
      -

      {t('series.empty')}

      -
      - ) : ( -
      - {series.map((s) => ( - - {}} /> - - ))} -
      - )} - setShowCreateModal(false)} - onSuccess={onSeriesCreated} - authorPubkey={authorPubkey} - /> -
      - ) -} - -async function loadAuthorData(hashId: string): Promise<{ pres: AuthorPresentationArticle | null; seriesList: Series[]; sponsoring: number }> { - const pool = nostrService.getPool() - if (!pool) { - throw new Error('Pool not initialized') - } - - const pres = await fetchAuthorByHashId(pool, hashId) - if (!pres) { - return { pres: null, seriesList: [], sponsoring: 0 } - } - - const [seriesList, sponsoring] = await Promise.all([ - getSeriesByAuthor(pres.pubkey), - getAuthorSponsoring(pres.pubkey), - ]) - - return { pres, seriesList, sponsoring } -} - -function useAuthorData(hashIdOrPubkey: string): { - presentation: AuthorPresentationArticle | null - series: Series[] - totalSponsoring: number - loading: boolean - error: string | null - reload: () => Promise -} { - const [presentation, setPresentation] = useState(null) - const [series, setSeries] = useState([]) - const [totalSponsoring, setTotalSponsoring] = useState(0) - const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) - - const reload = useCallback(async (): Promise => { - if (!hashIdOrPubkey) { - return - } - - setLoading(true) - setError(null) - - try { - const { pres, seriesList, sponsoring } = await loadAuthorData(hashIdOrPubkey) - setPresentation(pres) - setSeries(seriesList) - setTotalSponsoring(sponsoring) - } catch (e) { - setError(e instanceof Error ? e.message : 'Erreur lors du chargement') - } finally { - setLoading(false) - } - }, [hashIdOrPubkey]) - - useEffect(() => { - void reload() - }, [hashIdOrPubkey, reload]) - - return { presentation, series, totalSponsoring, loading, error, reload } -} - -type AuthorPageContentProps = { - presentation: AuthorPresentationArticle | null - series: Series[] - totalSponsoring: number - authorPubkey: string - loading: boolean - error: string | null - onSeriesCreated: () => void -} - -function AuthorPageContent({ - presentation, - series, - totalSponsoring, - authorPubkey, - loading, - error, - onSeriesCreated, -}: AuthorPageContentProps): React.ReactElement { - if (loading) { - return

      {t('common.loading')}

      - } - - if (error) { - return

      {error}

      - } - - if (presentation) { - return ( - <> - - - - - ) - } - - return ( -
      -

      {t('author.notFound')}

      -
      - ) -} - -export default function AuthorPage(): React.ReactElement { - const router = useRouter() - const { pubkey } = router.query - - // Parse the URL parameter - it can be either: - // 1. Old format: /author/ (for backward compatibility) - // 2. New format: /author/__ (standard format) - const hashIdOrPubkey = resolveAuthorHashIdOrPubkey(pubkey) - - const { presentation, series, totalSponsoring, loading, error, reload } = useAuthorData(hashIdOrPubkey ?? '') - const onSeriesCreated = (): void => { - void reload() - } - - if (!hashIdOrPubkey) { - return
      - } - - // Get the actual pubkey from presentation - const actualAuthorPubkey = presentation?.pubkey ?? '' - - return ( - <> - - {t('author.title')} - {t('home.title')} - - - -
      - -
      - -
      -
      - - ) -} - -function resolveAuthorHashIdOrPubkey(pubkeyParam: string | string[] | undefined): string | null { - if (typeof pubkeyParam !== 'string') { - return null - } - const urlMatch = pubkeyParam.match(/^([a-f0-9]+)_(\d+)_(\d+)$/i) - if (urlMatch?.[1]) { - return urlMatch[1] - } - const parsedUrl = parseObjectUrl(`https://zapwall.fr/author/${pubkeyParam}`) - if (parsedUrl.objectType === 'author' && parsedUrl.idHash) { - return parsedUrl.idHash - } - return pubkeyParam -} +export { AuthorPage as default } from '@/components/authorPage/AuthorPage'