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 (
+
+
+
+ )
+}
+
+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 (
+
+ )
+}
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 (
+
+ )
+ }
+
+ 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(
-